Ver Fonte

Merge branch 'main' of https://github.com/transloadit/uppy

Antoine du Hamel há 1 ano atrás
pai
commit
2a9cd56e6f
84 ficheiros alterados com 1660 adições e 1052 exclusões
  1. 192 26
      .github/workflows/e2e.yml
  2. 16 0
      .yarn/patches/@vitest-utils-npm-1.2.1-3028846845.patch
  3. 6 1
      bin/build-lib.js
  4. 1 1
      examples/aws-companion/package.json
  5. 1 1
      examples/custom-provider/package.json
  6. 1 1
      examples/digitalocean-spaces/package.json
  7. 1 1
      examples/multiple-instances/package.json
  8. 1 1
      examples/node-xhr/package.json
  9. 1 1
      examples/php-xhr/package.json
  10. 1 1
      examples/python-xhr/package.json
  11. 2 2
      examples/react-example/package.json
  12. 1 1
      examples/redux/package.json
  13. 1 1
      examples/transloadit-markdown-bin/package.json
  14. 1 1
      examples/transloadit/package.json
  15. 1 1
      examples/vue/package.json
  16. 2 2
      examples/vue3/package.json
  17. 1 1
      examples/xhr-bundle/package.json
  18. 3 2
      package.json
  19. 0 1
      packages/@uppy/angular/.gitignore
  20. 1 1
      packages/@uppy/audio/package.json
  21. 142 76
      packages/@uppy/audio/src/Audio.tsx
  22. 14 2
      packages/@uppy/audio/src/AudioSourceSelect.tsx
  23. 7 1
      packages/@uppy/audio/src/DiscardButton.tsx
  24. 0 12
      packages/@uppy/audio/src/PermissionsScreen.jsx
  25. 25 0
      packages/@uppy/audio/src/PermissionsScreen.tsx
  26. 0 35
      packages/@uppy/audio/src/RecordButton.jsx
  27. 66 0
      packages/@uppy/audio/src/RecordButton.tsx
  28. 0 12
      packages/@uppy/audio/src/RecordingLength.jsx
  29. 25 0
      packages/@uppy/audio/src/RecordingLength.tsx
  30. 48 32
      packages/@uppy/audio/src/RecordingScreen.tsx
  31. 12 2
      packages/@uppy/audio/src/SubmitButton.tsx
  32. 0 84
      packages/@uppy/audio/src/audio-oscilloscope/index.js
  33. 136 0
      packages/@uppy/audio/src/audio-oscilloscope/index.ts
  34. 0 12
      packages/@uppy/audio/src/formatSeconds.js
  35. 0 12
      packages/@uppy/audio/src/formatSeconds.test.js
  36. 12 0
      packages/@uppy/audio/src/formatSeconds.test.ts
  37. 7 0
      packages/@uppy/audio/src/formatSeconds.ts
  38. 0 1
      packages/@uppy/audio/src/index.js
  39. 1 0
      packages/@uppy/audio/src/index.ts
  40. 10 5
      packages/@uppy/audio/src/locale.ts
  41. 0 6
      packages/@uppy/audio/src/supportsMediaRecorder.js
  42. 8 4
      packages/@uppy/audio/src/supportsMediaRecorder.test.ts
  43. 8 0
      packages/@uppy/audio/src/supportsMediaRecorder.ts
  44. 25 0
      packages/@uppy/audio/tsconfig.build.json
  45. 21 0
      packages/@uppy/audio/tsconfig.json
  46. 1 1
      packages/@uppy/aws-s3-multipart/package.json
  47. 1 1
      packages/@uppy/aws-s3/package.json
  48. 1 1
      packages/@uppy/companion-client/package.json
  49. 1 5
      packages/@uppy/companion/.gitignore
  50. 1 1
      packages/@uppy/compressor/package.json
  51. 1 1
      packages/@uppy/core/package.json
  52. 11 5
      packages/@uppy/core/src/BasePlugin.ts
  53. 15 5
      packages/@uppy/core/src/UIPlugin.ts
  54. 65 0
      packages/@uppy/core/src/Uppy.test.ts
  55. 11 6
      packages/@uppy/core/src/Uppy.ts
  56. 5 5
      packages/@uppy/core/src/__snapshots__/Uppy.test.ts.snap
  57. 1 1
      packages/@uppy/dashboard/package.json
  58. 1 1
      packages/@uppy/remote-sources/package.json
  59. 145 40
      packages/@uppy/status-bar/src/Components.tsx
  60. 78 59
      packages/@uppy/status-bar/src/StatusBar.tsx
  61. 14 0
      packages/@uppy/status-bar/src/StatusBarOptions.ts
  62. 0 8
      packages/@uppy/status-bar/src/StatusBarStates.js
  63. 8 0
      packages/@uppy/status-bar/src/StatusBarStates.ts
  64. 78 24
      packages/@uppy/status-bar/src/StatusBarUI.tsx
  65. 11 6
      packages/@uppy/status-bar/src/calculateProcessingProgress.ts
  66. 0 1
      packages/@uppy/status-bar/src/index.js
  67. 2 0
      packages/@uppy/status-bar/src/index.ts
  68. 4 2
      packages/@uppy/status-bar/src/locale.ts
  69. 25 0
      packages/@uppy/status-bar/tsconfig.build.json
  70. 21 0
      packages/@uppy/status-bar/tsconfig.json
  71. 1 1
      packages/@uppy/store-default/package.json
  72. 1 1
      packages/@uppy/store-redux/package.json
  73. 0 2
      packages/@uppy/svelte/.gitignore
  74. 1 1
      packages/@uppy/thumbnail-generator/package.json
  75. 1 1
      packages/@uppy/transloadit/package.json
  76. 1 1
      packages/@uppy/tus/package.json
  77. 1 1
      packages/@uppy/utils/package.json
  78. 7 7
      packages/@uppy/utils/src/findDOMElement.ts
  79. 1 2
      packages/@uppy/vue/.gitignore
  80. 1 1
      packages/@uppy/webcam/package.json
  81. 1 1
      packages/@uppy/xhr-upload/package.json
  82. 1 1
      private/dev/package.json
  83. 2 2
      private/locale-pack/index.mjs
  84. 339 514
      yarn.lock

+ 192 - 26
.github/workflows/e2e.yml

@@ -24,19 +24,205 @@ on:
     paths:
       - .github/workflows/e2e.yml
 
-concurrency: ${{ github.workflow }}--${{ github.ref }}
+concurrency:
+  group:
+    ${{ github.workflow }}--${{ github.event.pull_request.head.repo.full_name ||
+    github.repository }} -- ${{ github.head_ref || github.ref }}
+  cancel-in-progress:
+    # For PRs coming from forks, we need the previous job to run until the end
+    # to be sure it can remove the `safe to test` label before it affects the next run.
+    ${{ github.event.pull_request.head.repo.full_name == github.repository }}
 
+permissions:
+  pull-requests: write
 env:
   YARN_ENABLE_GLOBAL_CACHE: false
 
 jobs:
+  compare_diff:
+    runs-on: ubuntu-latest
+    env:
+      DIFF_BUILDER: true
+    outputs:
+      diff: ${{ steps.diff.outputs.OUTPUT_DIFF }}
+      is_accurate_diff: ${{ steps.diff.outputs.IS_ACCURATE_DIFF }}
+    steps:
+      - name: Checkout sources
+        uses: actions/checkout@v3
+        with:
+          fetch-depth: 2
+          ref:
+            ${{ github.event.pull_request && format('refs/pull/{0}/merge',
+            github.event.pull_request.number) || github.sha }}
+      - name: Check if there are "unsafe" changes
+        id: build_chain_changes
+        # If there are changes in JS script that generates the output, we cannot
+        # test them here without human review to make sure they don't contain
+        # someting "nasty".
+        run: |
+          EOF=$(dd if=/dev/urandom bs=15 count=1 status=none | base64)
+          echo "MIGHT_CONTAIN_OTHER_CHANGES<<$EOF" >> "$GITHUB_OUTPUT"
+          git --no-pager diff HEAD^ --name-only bin package.json yarn.lock babel.config.js >> "$GITHUB_OUTPUT"
+          echo "$EOF" >> "$GITHUB_OUTPUT"
+      - run: git reset HEAD^ --hard
+      - name: Get yarn cache directory path
+        id: yarn-cache-dir-path
+        run:
+          echo "dir=$(corepack yarn config get cacheFolder)" >> $GITHUB_OUTPUT
+
+      - uses: actions/cache@v3
+        id: yarn-cache # use this to check for `cache-hit` (`steps.yarn-cache.outputs.cache-hit != 'true'`)
+        with:
+          path: ${{ steps.yarn-cache-dir-path.outputs.dir }}
+          key: ${{ runner.os }}-yarn-${{ hashFiles('**/yarn.lock') }}
+          restore-keys: |
+            ${{ runner.os }}-yarn-
+      - name: Install Node.js
+        uses: actions/setup-node@v3
+        with:
+          node-version: lts/*
+      - name: Install dependencies
+        run:
+          corepack yarn workspaces focus $(corepack yarn workspaces list --json
+          | jq -r .name | awk '/^@uppy-example/{ next } { if ($0!="uppy.io")
+          print $0 }')
+        env:
+          # https://docs.cypress.io/guides/references/advanced-installation#Skipping-installation
+          CYPRESS_INSTALL_BINARY: 0
+      - run: corepack yarn build:lib
+      - name: Store output file
+        run: tar cf /tmp/previousVersion.tar packages/@uppy/*/lib
+      - name: Fetch source from the PR
+        if: steps.build_chain_changes.outputs.MIGHT_CONTAIN_OTHER_CHANGES == ''
+        run: |
+          git checkout FETCH_HEAD -- packages
+          echo 'IS_ACCURATE_DIFF=true' >> "$GITHUB_ENV"
+      - name: Fetch source from the PR
+        if:
+          steps.build_chain_changes.outputs.MIGHT_CONTAIN_OTHER_CHANGES != '' &&
+          (!github.event.pull_request || (github.event.action == 'labeled' &&
+          github.event.label.name == 'safe to test' &&
+          github.event.pull_request.state == 'open') ||
+          (github.event.pull_request.head.repo.full_name == github.repository &&
+          github.event.event_name != 'labeled'))
+        run: |
+          git reset FETCH_HEAD --hard
+          corepack yarn workspaces focus $(\
+            corepack yarn workspaces list --json | \
+            jq -r .name | \
+            awk '/^@uppy-example/{ next } { if ($0!="uppy.io") print $0 }'\
+          )
+          echo 'IS_ACCURATE_DIFF=true' >> "$GITHUB_ENV"
+        env:
+          # https://docs.cypress.io/guides/references/advanced-installation#Skipping-installation
+          CYPRESS_INSTALL_BINARY: 0
+      - name: Fetch source from the PR
+        if:
+          steps.build_chain_changes.outputs.MIGHT_CONTAIN_OTHER_CHANGES != '' &&
+          github.event.pull_request.head.repo.full_name != github.repository &&
+          (github.event.action != 'labeled' || github.event.label.name != 'safe
+          to test')
+        run: |
+          git checkout FETCH_HEAD -- packages
+      - run: corepack yarn build:lib
+      - name: Store output file
+        run: tar cf /tmp/newVersion.tar packages/@uppy/*/lib
+      - name: Setup git
+        run: |
+          git config --global user.email "actions@github.com"
+          git config --global user.name "GitHub Actions"
+          git init /tmp/uppy
+          echo '*.map' > /tmp/uppy/.gitignore
+      - name: Install dformat
+        run: |
+          curl -fsSL https://dprint.dev/install.sh | sh
+          cd /tmp/uppy && echo '{"plugins":[]}' > dprint.json && "$HOME/.dprint/bin/dprint" config add typescript
+      - name: Extract previous version
+        run: cd /tmp/uppy && tar xf /tmp/previousVersion.tar
+      - name: Format previous output code
+        run: cd /tmp/uppy && "$HOME/.dprint/bin/dprint" fmt **/*.js
+      - name: Commit previous version
+        run: cd /tmp/uppy && git add -A . && git commit -m 'previous version'
+      - name: Extract new version
+        run: cd /tmp/uppy && tar xf /tmp/newVersion.tar
+      - name: Format new output code
+        run: cd /tmp/uppy && "$HOME/.dprint/bin/dprint" fmt **/*.js
+      - name: Build diff
+        id: diff
+        run: |
+          EOF=$(dd if=/dev/urandom bs=15 count=1 status=none | base64)
+          echo "OUTPUT_DIFF<<$EOF" >> "$GITHUB_OUTPUT"
+          cd /tmp/uppy && git --no-pager diff >> "$GITHUB_OUTPUT"
+          echo "$EOF" >> "$GITHUB_OUTPUT"
+          echo "IS_ACCURATE_DIFF=$IS_ACCURATE_DIFF" >> "$GITHUB_OUTPUT"
+      - name: Add/update comment
+        if: github.event.pull_request
+        uses: marocchino/sticky-pull-request-comment@v2
+        with:
+          message: |
+            <details><summary>Diff output files</summary>
+
+            ```diff
+            ${{ steps.diff.outputs.OUTPUT_DIFF || 'No diff' }}
+            ```
+
+            ${{ env.IS_ACCURATE_DIFF != 'true' && format(fromJson('"The following build files have been modified and might affect the actual diff:\n\n```\n{0}\n```"'), steps.build_chain_changes.outputs.MIGHT_CONTAIN_OTHER_CHANGES) || '' }}
+
+            </details>
+      - name: Remove 'safe to test' label if cancelled
+        if:
+          cancelled() && github.event.pull_request &&
+          github.event.pull_request.head.repo.full_name != github.repository
+        run: gh pr edit "$NUMBER" --remove-label 'safe to test'
+        env:
+          NUMBER: ${{ github.event.pull_request.number }}
+          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+  toggle-pending-e2e-label:
+    # Add the 'pending end-to-end tests' label for PRs that come from forks.
+    # For those PRs, we want to review the code before running e2e tests.
+    # See https://securitylab.github.com/research/github-actions-preventing-pwn-requests/.
+    needs: [compare_diff]
+    if:
+      github.event.pull_request.state == 'open' &&
+      github.event.pull_request.head.repo.full_name != github.repository &&
+      github.event.action != 'labeled'
+    runs-on: ubuntu-latest
+    steps:
+      - name: Add label
+        if:
+          (needs.compare_diff.outputs.diff != '' ||
+          !needs.compare_diff.outputs.is_accurate_diff) &&
+          (!contains(github.event.pull_request.labels.*.name, 'safe to test') &&
+          !contains(github.event.pull_request.labels.*.name, 'pending end-to-end
+          tests'))
+        env:
+          PR_URL: ${{ github.event.pull_request.html_url }}
+          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+        run:
+          gh pr edit "$PR_URL" --repo ${{ github.repository }} --add-label
+          'pending end-to-end tests'
+      - name: Remove label
+        if:
+          needs.compare_diff.outputs.diff == '' &&
+          needs.compare_diff.outputs.is_accurate_diff &&
+          (contains(github.event.pull_request.labels.*.name, 'safe to test') ||
+          contains(github.event.pull_request.labels.*.name, 'pending end-to-end
+          tests'))
+        env:
+          PR_URL: ${{ github.event.pull_request.html_url }}
+          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+        run:
+          gh pr edit "$PR_URL" --remove-label 'safe to test' --remove-label
+          'pending end-to-end tests'
+
   e2e:
+    needs: [compare_diff]
     if:
-      ${{ !github.event.pull_request ||
-      (contains(github.event.pull_request.labels.*.name, 'safe to test') &&
-      github.event.pull_request.state == 'open') ||
+      ${{ needs.compare_diff.outputs.diff != '' && (!github.event.pull_request
+      || (github.event.action == 'labeled' && github.event.label.name == 'safe
+      to test' && github.event.pull_request.state == 'open') ||
       (github.event.pull_request.head.repo.full_name == github.repository &&
-      github.event.event_name != 'labeled') }}
+      github.event.event_name != 'labeled')) }}
     name: Browser tests
     runs-on: ubuntu-latest
     steps:
@@ -109,7 +295,7 @@ jobs:
           path: |
             e2e/cypress/videos/
             e2e/cypress/screenshots/
-      - name: Remove 'pending end-to-end tests' label
+      - name: Remove labels
         # Remove the 'pending end-to-end tests' label if tests ran successfully
         if:
           github.event.pull_request &&
@@ -120,7 +306,6 @@ jobs:
           NUMBER: ${{ github.event.pull_request.number }}
           GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
       - name: Remove 'safe to test' label
-        # Remove the 'safe to test' label to ensure next commit needs approval before re-running this.
         if:
           always() && github.event.pull_request &&
           contains(github.event.pull_request.labels.*.name, 'safe to test')
@@ -128,22 +313,3 @@ jobs:
         env:
           NUMBER: ${{ github.event.pull_request.number }}
           GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
-  add-pending-e2e-label:
-    # Add the 'pending end-to-end tests' label for PRs that come from forks.
-    # For those PRs, we want to review the code before running e2e tests.
-    # See https://securitylab.github.com/research/github-actions-preventing-pwn-requests/.
-    if:
-      github.event.pull_request.state == 'open' &&
-      github.event.pull_request.head.repo.full_name != github.repository &&
-      !contains(github.event.pull_request.labels.*.name, 'safe to test') &&
-      !contains(github.event.pull_request.labels.*.name, 'pending end-to-end
-      tests')
-    runs-on: ubuntu-latest
-    steps:
-      - name: Add label
-        env:
-          NUMBER: ${{ github.event.pull_request.number }}
-          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
-        run:
-          gh pr edit "$NUMBER" --repo ${{ github.repository }} --add-label
-          'pending end-to-end tests'

+ 16 - 0
.yarn/patches/@vitest-utils-npm-1.2.1-3028846845.patch

@@ -0,0 +1,16 @@
+diff --git a/dist/error.d.ts b/dist/error.d.ts
+index bd657d5311ff3d255dc57a2f7224301d532a3177..8924e0982c64c566f4d9808cde62292d7ed334ac 100644
+--- a/dist/error.d.ts
++++ b/dist/error.d.ts
+@@ -1,9 +1,9 @@
+ import { D as DiffOptions } from './types-widbdqe5.js';
+ import 'pretty-format';
+ 
+-declare function serializeError(val: any, seen?: WeakMap<WeakKey, any>): any;
++declare function serializeError(val: any, seen?: WeakMap<object, any>): any;
+ declare function processError(err: any, diffOptions?: DiffOptions): any;
+-declare function replaceAsymmetricMatcher(actual: any, expected: any, actualReplaced?: WeakSet<WeakKey>, expectedReplaced?: WeakSet<WeakKey>): {
++declare function replaceAsymmetricMatcher(actual: any, expected: any, actualReplaced?: WeakSet<object>, expectedReplaced?: WeakSet<object>): {
+     replacedActual: any;
+     replacedExpected: any;
+ };

+ 6 - 1
bin/build-lib.js

@@ -116,7 +116,12 @@ async function buildLib () {
     const isTSX = file.endsWith('.tsx')
     if (isTSX || file.endsWith('.ts')) { plugins.push(['@babel/plugin-transform-typescript', { disallowAmbiguousJSXLike: true, isTSX, jsxPragma: 'h' }]) }
 
-    const { code, map } = await babel.transformFileAsync(file, { sourceMaps: true, plugins })
+    const { code, map } = await babel.transformFileAsync(file, {
+      sourceMaps: true,
+      plugins,
+      // no comments because https://github.com/transloadit/uppy/pull/4868#issuecomment-1897717779
+      comments: !process.env.DIFF_BUILDER,
+    })
     const [{ default: chalk }] = await Promise.all([
       import('chalk'),
       writeFile(libFile, code),

+ 1 - 1
examples/aws-companion/package.json

@@ -18,7 +18,7 @@
     "express": "^4.18.1",
     "express-session": "^1.17.3",
     "npm-run-all": "^4.1.5",
-    "vite": "^4.0.0"
+    "vite": "^5.0.0"
   },
   "private": true,
   "engines": {

+ 1 - 1
examples/custom-provider/package.json

@@ -21,7 +21,7 @@
     "express": "^4.16.2",
     "express-session": "^1.15.6",
     "npm-run-all": "^4.1.2",
-    "vite": "^4.0.0"
+    "vite": "^5.0.0"
   },
   "private": true,
   "scripts": {

+ 1 - 1
examples/digitalocean-spaces/package.json

@@ -12,7 +12,7 @@
   "devDependencies": {
     "dotenv": "^16.0.1",
     "express": "^4.16.2",
-    "vite": "^4.0.0"
+    "vite": "^5.0.0"
   },
   "private": true,
   "scripts": {

+ 1 - 1
examples/multiple-instances/package.json

@@ -8,7 +8,7 @@
     "@uppy/golden-retriever": "workspace:*"
   },
   "devDependencies": {
-    "vite": "^4.0.0"
+    "vite": "^5.0.0"
   },
   "private": true,
   "scripts": {

+ 1 - 1
examples/node-xhr/package.json

@@ -11,7 +11,7 @@
   },
   "devDependencies": {
     "npm-run-all": "^4.1.3",
-    "vite": "^4.0.0"
+    "vite": "^5.0.0"
   },
   "private": true,
   "scripts": {

+ 1 - 1
examples/php-xhr/package.json

@@ -10,7 +10,7 @@
   },
   "devDependencies": {
     "npm-run-all": "^4.1.3",
-    "vite": "^4.0.0"
+    "vite": "^5.0.0"
   },
   "private": true,
   "scripts": {

+ 1 - 1
examples/python-xhr/package.json

@@ -10,7 +10,7 @@
   },
   "devDependencies": {
     "npm-run-all": "^4.1.3",
-    "vite": "^4.0.0"
+    "vite": "^5.0.0"
   },
   "private": true,
   "scripts": {

+ 2 - 2
examples/react-example/package.json

@@ -21,7 +21,7 @@
     "preview": "vite preview --port 5050"
   },
   "devDependencies": {
-    "@vitejs/plugin-react": "^2.0.0",
-    "vite": "^4.0.0"
+    "@vitejs/plugin-react": "^4.0.0",
+    "vite": "^5.0.0"
   }
 }

+ 1 - 1
examples/redux/package.json

@@ -12,7 +12,7 @@
     "redux-logger": "^3.0.6"
   },
   "devDependencies": {
-    "vite": "^4.0.0"
+    "vite": "^5.0.0"
   },
   "private": true,
   "scripts": {

+ 1 - 1
examples/transloadit-markdown-bin/package.json

@@ -13,7 +13,7 @@
     "marked": "^4.0.18"
   },
   "devDependencies": {
-    "vite": "^4.0.0"
+    "vite": "^5.0.0"
   },
   "private": true,
   "scripts": {

+ 1 - 1
examples/transloadit/package.json

@@ -4,7 +4,7 @@
   "type": "module",
   "devDependencies": {
     "npm-run-all": "^4.1.5",
-    "vite": "^4.0.0"
+    "vite": "^5.0.0"
   },
   "dependencies": {
     "@uppy/core": "workspace:*",

+ 1 - 1
examples/vue/package.json

@@ -17,7 +17,7 @@
     "vue": "^2.6.14"
   },
   "devDependencies": {
-    "vite": "^4.0.0",
+    "vite": "^5.0.0",
     "vite-plugin-vue2": "^2.0.1",
     "vue-template-compiler": "^2.6.14"
   }

+ 2 - 2
examples/vue3/package.json

@@ -17,7 +17,7 @@
     "vue": "^3.2.33"
   },
   "devDependencies": {
-    "@vitejs/plugin-vue": "^3.0.0",
-    "vite": "^4.0.0"
+    "@vitejs/plugin-vue": "^5.0.0",
+    "vite": "^5.0.0"
   }
 }

+ 1 - 1
examples/xhr-bundle/package.json

@@ -11,7 +11,7 @@
   },
   "devDependencies": {
     "npm-run-all": "^4.1.5",
-    "vite": "^4.0.0"
+    "vite": "^5.0.0"
   },
   "private": true,
   "scripts": {

+ 3 - 2
package.json

@@ -107,14 +107,14 @@
     "stylelint-config-standard-scss": "^10.0.0",
     "tar": "^6.1.0",
     "typescript": "~5.1",
-    "vitest": "^0.34.5",
+    "vitest": "^1.2.1",
     "vue-template-compiler": "workspace:*"
   },
   "scripts": {
     "start:companion": "bash bin/companion.sh",
     "start:companion:with-loadbalancer": "e2e/start-companion-with-load-balancer.mjs",
     "build:bundle": "yarn node ./bin/build-bundle.mjs",
-    "build:clean": "git clean -e node_modules -Xfd packages e2e .parcel-cache coverage",
+    "build:clean": "cp .gitignore .gitignore.bak && printf '!node_modules\n!**/node_modules/**/*\n' >> .gitignore; git clean -Xfd packages e2e .parcel-cache coverage; mv .gitignore.bak .gitignore",
     "build:companion": "yarn workspace @uppy/companion build",
     "build:css": "yarn node ./bin/build-css.js",
     "build:svelte": "yarn workspace @uppy/svelte build",
@@ -166,6 +166,7 @@
     "@types/eslint@^7.2.13": "^8.2.0",
     "@types/react": "^17",
     "@types/webpack-dev-server": "^4",
+    "@vitest/utils": "patch:@vitest/utils@npm%3A1.2.1#./.yarn/patches/@vitest-utils-npm-1.2.1-3028846845.patch",
     "p-queue": "patch:p-queue@npm%3A7.4.1#./.yarn/patches/p-queue-npm-7.4.1-e0cf0a6f17.patch",
     "pre-commit": "patch:pre-commit@npm:1.2.2#.yarn/patches/pre-commit-npm-1.2.2-f30af83877.patch",
     "preact": "patch:preact@npm:10.10.0#.yarn/patches/preact-npm-10.10.0-dd04de05e8.patch",

+ 0 - 1
packages/@uppy/angular/.gitignore

@@ -7,7 +7,6 @@
 /bazel-out
 
 # Node
-/node_modules
 npm-debug.log
 yarn-error.log
 

+ 1 - 1
packages/@uppy/audio/package.json

@@ -29,7 +29,7 @@
     "preact": "^10.5.13"
   },
   "devDependencies": {
-    "vitest": "^0.34.5"
+    "vitest": "^1.2.1"
   },
   "peerDependencies": {
     "@uppy/core": "workspace:^"

+ 142 - 76
packages/@uppy/audio/src/Audio.jsx → packages/@uppy/audio/src/Audio.tsx

@@ -1,44 +1,83 @@
 import { h } from 'preact'
 
-import { UIPlugin } from '@uppy/core'
+import { UIPlugin, type UIPluginOptions } from '@uppy/core'
+import type { Body, Meta } from '@uppy/utils/lib/UppyFile'
+import type { Uppy, MinimalRequiredUppyFile } from '@uppy/core/lib/Uppy.ts'
 
 import getFileTypeExtension from '@uppy/utils/lib/getFileTypeExtension'
-import supportsMediaRecorder from './supportsMediaRecorder.js'
-import RecordingScreen from './RecordingScreen.jsx'
-import PermissionsScreen from './PermissionsScreen.jsx'
-import locale from './locale.js'
-
+import supportsMediaRecorder from './supportsMediaRecorder.ts'
+import RecordingScreen from './RecordingScreen.tsx'
+import PermissionsScreen from './PermissionsScreen.tsx'
+import locale from './locale.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 AudioOptions extends UIPluginOptions {
+  target?: HTMLElement | string
+  showAudioSourceDropdown?: boolean
+}
+interface AudioState {
+  audioReady: boolean
+  recordingLengthSeconds: number
+  hasAudio: boolean
+  cameraError: null
+  audioSources: MediaDeviceInfo[]
+  currentDeviceId?: null | string | MediaStreamTrack
+  isRecording: boolean
+  showAudioSourceDropdown: boolean
+  [id: string]: unknown
+}
+
 /**
  * Audio recording plugin
  */
-export default class Audio extends UIPlugin {
+export default class Audio<M extends Meta, B extends Body> extends UIPlugin<
+  AudioOptions,
+  M,
+  B,
+  AudioState
+> {
   static VERSION = packageJson.version
 
-  #stream = null
+  private recordingLengthTimer: ReturnType<typeof setInterval>
+
+  private icon
+
+  #stream: MediaStream | null = null
 
   #audioActive = false
 
-  #recordingChunks = null
+  #recordingChunks: Blob[] | null = null
 
-  #recorder = null
+  #recorder: MediaRecorder | null = null
 
-  #capturedMediaFile = null
+  #capturedMediaFile: MinimalRequiredUppyFile<M, B> | null = null
 
-  #mediaDevices = null
+  #mediaDevices
 
-  #supportsUserMedia = null
+  #supportsUserMedia
 
-  constructor (uppy, opts) {
+  constructor(uppy: Uppy<M, B>, opts?: AudioOptions) {
     super(uppy, opts)
     this.#mediaDevices = navigator.mediaDevices
     this.#supportsUserMedia = this.#mediaDevices != null
     this.id = this.opts.id || 'Audio'
     this.type = 'acquirer'
     this.icon = () => (
-      <svg className="uppy-DashboardTab-iconAudio" aria-hidden="true" focusable="false" width="32px" height="32px" viewBox="0 0 32 32">
-        <path d="M21.143 12.297c.473 0 .857.383.857.857v2.572c0 3.016-2.24 5.513-5.143 5.931v2.64h2.572a.857.857 0 110 1.714H12.57a.857.857 0 110-1.714h2.572v-2.64C12.24 21.24 10 18.742 10 15.726v-2.572a.857.857 0 111.714 0v2.572A4.29 4.29 0 0016 20.01a4.29 4.29 0 004.286-4.285v-2.572c0-.474.384-.857.857-.857zM16 6.5a3 3 0 013 3v6a3 3 0 01-6 0v-6a3 3 0 013-3z" fill="currentcolor" fill-rule="nonzero" />
+      <svg
+        className="uppy-DashboardTab-iconAudio"
+        aria-hidden="true"
+        focusable="false"
+        width="32px"
+        height="32px"
+        viewBox="0 0 32 32"
+      >
+        <path
+          d="M21.143 12.297c.473 0 .857.383.857.857v2.572c0 3.016-2.24 5.513-5.143 5.931v2.64h2.572a.857.857 0 110 1.714H12.57a.857.857 0 110-1.714h2.572v-2.64C12.24 21.24 10 18.742 10 15.726v-2.572a.857.857 0 111.714 0v2.572A4.29 4.29 0 0016 20.01a4.29 4.29 0 004.286-4.285v-2.572c0-.474.384-.857.857-.857zM16 6.5a3 3 0 013 3v6a3 3 0 01-6 0v-6a3 3 0 013-3z"
+          fill="currentcolor"
+          fill-rule="nonzero"
+        />
       </svg>
     )
 
@@ -59,44 +98,43 @@ export default class Audio extends UIPlugin {
     })
   }
 
-  #hasAudioCheck () {
+  #hasAudioCheck(): Promise<boolean> {
     if (!this.#mediaDevices) {
       return Promise.resolve(false)
     }
 
-    return this.#mediaDevices.enumerateDevices().then(devices => {
-      return devices.some(device => device.kind === 'audioinput')
+    return this.#mediaDevices.enumerateDevices().then((devices) => {
+      return devices.some((device) => device.kind === 'audioinput')
     })
   }
 
   // eslint-disable-next-line consistent-return
-  #start = (options = null) => {
+  #start = (options?: { deviceId?: string }): Promise<never> | void => {
     if (!this.#supportsUserMedia) {
       return Promise.reject(new Error('Microphone access not supported'))
     }
 
     this.#audioActive = true
 
-    this.#hasAudioCheck().then(hasAudio => {
+    this.#hasAudioCheck().then((hasAudio) => {
       this.setPluginState({
         hasAudio,
       })
 
       // ask user for access to their camera
-      return this.#mediaDevices.getUserMedia({ audio: true })
+      return this.#mediaDevices
+        .getUserMedia({ audio: true })
         .then((stream) => {
           this.#stream = stream
 
           let currentDeviceId = null
           const tracks = stream.getAudioTracks()
 
-          if (!options || !options.deviceId) {
+          if (!options?.deviceId) {
             currentDeviceId = tracks[0].getSettings().deviceId
           } else {
-            tracks.forEach((track) => {
-              if (track.getSettings().deviceId === options.deviceId) {
-                currentDeviceId = track.getSettings().deviceId
-              }
+            currentDeviceId = tracks.findLast((track) => {
+              return track.getSettings().deviceId === options.deviceId
             })
           }
 
@@ -118,24 +156,34 @@ export default class Audio extends UIPlugin {
     })
   }
 
-  #startRecording = () => {
+  #startRecording = (): void => {
     // only used if supportsMediaRecorder() returned true
     // eslint-disable-next-line compat/compat
-    this.#recorder = new MediaRecorder(this.#stream)
+    this.#recorder = new MediaRecorder(this.#stream!)
     this.#recordingChunks = []
     let stoppingBecauseOfMaxSize = false
     this.#recorder.addEventListener('dataavailable', (event) => {
-      this.#recordingChunks.push(event.data)
+      this.#recordingChunks!.push(event.data)
 
       const { restrictions } = this.uppy.opts
-      if (this.#recordingChunks.length > 1
-          && restrictions.maxFileSize != null
-          && !stoppingBecauseOfMaxSize) {
-        const totalSize = this.#recordingChunks.reduce((acc, chunk) => acc + chunk.size, 0)
+      if (
+        this.#recordingChunks!.length > 1 &&
+        restrictions.maxFileSize != null &&
+        !stoppingBecauseOfMaxSize
+      ) {
+        const totalSize = this.#recordingChunks!.reduce(
+          (acc, chunk) => acc + chunk.size,
+          0,
+        )
         // Exclude the initial chunk from the average size calculation because it is likely to be a very small outlier
-        const averageChunkSize = (totalSize - this.#recordingChunks[0].size) / (this.#recordingChunks.length - 1)
+        const averageChunkSize =
+          (totalSize - this.#recordingChunks![0].size) /
+          (this.#recordingChunks!.length - 1)
         const expectedEndChunkSize = averageChunkSize * 3
-        const maxSize = Math.max(0, restrictions.maxFileSize - expectedEndChunkSize)
+        const maxSize = Math.max(
+          0,
+          restrictions.maxFileSize - expectedEndChunkSize,
+        )
 
         if (totalSize > maxSize) {
           stoppingBecauseOfMaxSize = true
@@ -150,9 +198,13 @@ export default class Audio extends UIPlugin {
     this.#recorder.start(500)
 
     // Start the recordingLengthTimer if we are showing the recording length.
+    // TODO: switch this to a private field
     this.recordingLengthTimer = setInterval(() => {
-      const currentRecordingLength = this.getPluginState().recordingLengthSeconds
-      this.setPluginState({ recordingLengthSeconds: currentRecordingLength + 1 })
+      const currentRecordingLength = this.getPluginState()
+        .recordingLengthSeconds as number
+      this.setPluginState({
+        recordingLengthSeconds: currentRecordingLength + 1,
+      })
     }, 1000)
 
     this.setPluginState({
@@ -160,43 +212,49 @@ export default class Audio extends UIPlugin {
     })
   }
 
-  #stopRecording = () => {
-    const stopped = new Promise((resolve) => {
-      this.#recorder.addEventListener('stop', () => {
+  #stopRecording = (): Promise<void> => {
+    const stopped = new Promise<void>((resolve) => {
+      this.#recorder!.addEventListener('stop', () => {
         resolve()
       })
-      this.#recorder.stop()
+      this.#recorder!.stop()
 
       clearInterval(this.recordingLengthTimer)
       this.setPluginState({ recordingLengthSeconds: 0 })
     })
 
-    return stopped.then(() => {
-      this.setPluginState({
-        isRecording: false,
-      })
-      return this.#getAudio()
-    }).then((file) => {
-      try {
-        this.#capturedMediaFile = file
-        // create object url for capture result preview
+    return stopped
+      .then(() => {
         this.setPluginState({
-          recordedAudio: URL.createObjectURL(file.data),
+          isRecording: false,
         })
-      } catch (err) {
-        // Logging the error, exept restrictions, which is handled in Core
-        if (!err.isRestriction) {
-          this.uppy.log(err)
+        return this.#getAudio()
+      })
+      .then((file) => {
+        try {
+          this.#capturedMediaFile = file
+          // create object url for capture result preview
+          this.setPluginState({
+            recordedAudio: URL.createObjectURL(file.data),
+          })
+        } catch (err) {
+          // Logging the error, exept restrictions, which is handled in Core
+          if (!err.isRestriction) {
+            this.uppy.log(err)
+          }
         }
-      }
-    }).then(() => {
-      this.#recordingChunks = null
-      this.#recorder = null
-    }, (error) => {
-      this.#recordingChunks = null
-      this.#recorder = null
-      throw error
-    })
+      })
+      .then(
+        () => {
+          this.#recordingChunks = null
+          this.#recorder = null
+        },
+        (error) => {
+          this.#recordingChunks = null
+          this.#recorder = null
+          throw error
+        },
+      )
   }
 
   #discardRecordedAudio = () => {
@@ -225,8 +283,8 @@ export default class Audio extends UIPlugin {
 
     if (this.#recorder) {
       await new Promise((resolve) => {
-        this.#recorder.addEventListener('stop', resolve, { once: true })
-        this.#recorder.stop()
+        this.#recorder!.addEventListener('stop', resolve, { once: true })
+        this.#recorder!.stop()
 
         clearInterval(this.recordingLengthTimer)
       })
@@ -244,20 +302,26 @@ export default class Audio extends UIPlugin {
     })
   }
 
-  #getAudio () {
+  #getAudio() {
     // Sometimes in iOS Safari, Blobs (especially the first Blob in the recordingChunks Array)
     // have empty 'type' attributes (e.g. '') so we need to find a Blob that has a defined 'type'
     // attribute in order to determine the correct MIME type.
-    const mimeType = this.#recordingChunks.find(blob => blob.type?.length > 0).type
+    const mimeType = this.#recordingChunks!.find(
+      (blob) => blob.type?.length > 0,
+    )!.type
 
     const fileExtension = getFileTypeExtension(mimeType)
 
     if (!fileExtension) {
-      return Promise.reject(new Error(`Could not retrieve recording: Unsupported media type "${mimeType}"`))
+      return Promise.reject(
+        new Error(
+          `Could not retrieve recording: Unsupported media type "${mimeType}"`,
+        ),
+      )
     }
 
     const name = `audio-${Date.now()}.${fileExtension}`
-    const blob = new Blob(this.#recordingChunks, { type: mimeType })
+    const blob = new Blob(this.#recordingChunks!, { type: mimeType })
     const file = {
       source: this.id,
       name,
@@ -268,20 +332,20 @@ export default class Audio extends UIPlugin {
     return Promise.resolve(file)
   }
 
-  #changeSource = (deviceId) => {
+  #changeSource = (deviceId?: string): void => {
     this.#stop()
     this.#start({ deviceId })
   }
 
   #updateSources = () => {
-    this.#mediaDevices.enumerateDevices().then(devices => {
+    this.#mediaDevices.enumerateDevices().then((devices) => {
       this.setPluginState({
         audioSources: devices.filter((device) => device.kind === 'audioinput'),
       })
     })
   }
 
-  render () {
+  render(): JSX.Element {
     if (!this.#audioActive) {
       this.#start()
     }
@@ -302,6 +366,8 @@ export default class Audio extends UIPlugin {
       <RecordingScreen
         // eslint-disable-next-line react/jsx-props-no-spreading
         {...audioState}
+        // eslint-disable-next-line @typescript-eslint/ban-ts-comment
+        // @ts-ignore TODO: remove unused
         audioActive={this.#audioActive}
         onChangeSource={this.#changeSource}
         onStartRecording={this.#startRecording}
@@ -318,7 +384,7 @@ export default class Audio extends UIPlugin {
     )
   }
 
-  install () {
+  install(): void {
     this.setPluginState({
       audioReady: false,
       recordingLengthSeconds: 0,
@@ -355,7 +421,7 @@ export default class Audio extends UIPlugin {
     }
   }
 
-  uninstall () {
+  uninstall(): void {
     if (this.#stream) {
       this.#stop()
     }

+ 14 - 2
packages/@uppy/audio/src/AudioSourceSelect.jsx → packages/@uppy/audio/src/AudioSourceSelect.tsx

@@ -1,11 +1,23 @@
 import { h } from 'preact'
 
-export default ({ currentDeviceId, audioSources, onChangeSource }) => {
+export interface AudioSourceSelectProps {
+  currentDeviceId: string
+  audioSources: MediaDeviceInfo[]
+  onChangeSource: (value: string) => void
+}
+
+export default ({
+  currentDeviceId,
+  audioSources,
+  onChangeSource,
+}: AudioSourceSelectProps): JSX.Element => {
   return (
     <div className="uppy-Audio-videoSource">
       <select
         className="uppy-u-reset uppy-Audio-audioSource-select"
-        onChange={(event) => { onChangeSource(event.target.value) }}
+        onChange={(event) => {
+          onChangeSource(event.target.value)
+        }}
       >
         {audioSources.map((audioSource) => (
           <option

+ 7 - 1
packages/@uppy/audio/src/DiscardButton.jsx → packages/@uppy/audio/src/DiscardButton.tsx

@@ -1,6 +1,12 @@
 import { h } from 'preact'
+import type { I18n } from '@uppy/utils/lib/Translator'
 
-function DiscardButton ({ onDiscard, i18n }) {
+interface DiscardButtonProps {
+  onDiscard: () => void
+  i18n: I18n
+}
+
+function DiscardButton({ onDiscard, i18n }: DiscardButtonProps): JSX.Element {
   return (
     <button
       className="uppy-u-reset uppy-c-btn uppy-Audio-button"

+ 0 - 12
packages/@uppy/audio/src/PermissionsScreen.jsx

@@ -1,12 +0,0 @@
-import { h } from 'preact'
-
-export default (props) => {
-  const { icon, hasAudio, i18n } = props
-  return (
-    <div className="uppy-Audio-permissons">
-      <div className="uppy-Audio-permissonsIcon">{icon()}</div>
-      <h1 className="uppy-Audio-title">{hasAudio ? i18n('allowAudioAccessTitle') : i18n('noAudioTitle')}</h1>
-      <p>{hasAudio ? i18n('allowAudioAccessDescription') : i18n('noAudioDescription')}</p>
-    </div>
-  )
-}

+ 25 - 0
packages/@uppy/audio/src/PermissionsScreen.tsx

@@ -0,0 +1,25 @@
+import type { I18n } from '@uppy/utils/lib/Translator'
+import { h } from 'preact'
+
+interface PermissionsScreenProps {
+  icon: () => JSX.Element | null
+  hasAudio: boolean
+  i18n: I18n
+}
+
+export default (props: PermissionsScreenProps): JSX.Element => {
+  const { icon, hasAudio, i18n } = props
+  return (
+    <div className="uppy-Audio-permissons">
+      <div className="uppy-Audio-permissonsIcon">{icon()}</div>
+      <h1 className="uppy-Audio-title">
+        {hasAudio ? i18n('allowAudioAccessTitle') : i18n('noAudioTitle')}
+      </h1>
+      <p>
+        {hasAudio
+          ? i18n('allowAudioAccessDescription')
+          : i18n('noAudioDescription')}
+      </p>
+    </div>
+  )
+}

+ 0 - 35
packages/@uppy/audio/src/RecordButton.jsx

@@ -1,35 +0,0 @@
-import { h } from 'preact'
-
-export default function RecordButton ({ recording, onStartRecording, onStopRecording, i18n }) {
-  if (recording) {
-    return (
-      <button
-        className="uppy-u-reset uppy-c-btn uppy-Audio-button"
-        type="button"
-        title={i18n('stopAudioRecording')}
-        aria-label={i18n('stopAudioRecording')}
-        onClick={onStopRecording}
-        data-uppy-super-focusable
-      >
-        <svg aria-hidden="true" focusable="false" className="uppy-c-icon" width="100" height="100" viewBox="0 0 100 100">
-          <rect x="15" y="15" width="70" height="70" />
-        </svg>
-      </button>
-    )
-  }
-
-  return (
-    <button
-      className="uppy-u-reset uppy-c-btn uppy-Audio-button"
-      type="button"
-      title={i18n('startAudioRecording')}
-      aria-label={i18n('startAudioRecording')}
-      onClick={onStartRecording}
-      data-uppy-super-focusable
-    >
-      <svg aria-hidden="true" focusable="false" className="uppy-c-icon" width="14px" height="20px" viewBox="0 0 14 20">
-        <path d="M7 14c2.21 0 4-1.71 4-3.818V3.818C11 1.71 9.21 0 7 0S3 1.71 3 3.818v6.364C3 12.29 4.79 14 7 14zm6.364-7h-.637a.643.643 0 0 0-.636.65V9.6c0 3.039-2.565 5.477-5.6 5.175-2.645-.264-4.582-2.692-4.582-5.407V7.65c0-.36-.285-.65-.636-.65H.636A.643.643 0 0 0 0 7.65v1.631c0 3.642 2.544 6.888 6.045 7.382v1.387H3.818a.643.643 0 0 0-.636.65v.65c0 .36.285.65.636.65h6.364c.351 0 .636-.29.636-.65v-.65c0-.36-.285-.65-.636-.65H7.955v-1.372C11.363 16.2 14 13.212 14 9.6V7.65c0-.36-.285-.65-.636-.65z" fill="#FFF" fill-rule="nonzero" />
-      </svg>
-    </button>
-  )
-}

+ 66 - 0
packages/@uppy/audio/src/RecordButton.tsx

@@ -0,0 +1,66 @@
+import type { I18n } from '@uppy/utils/lib/Translator'
+import { h } from 'preact'
+
+interface RecordButtonProps {
+  recording: boolean
+  onStartRecording: () => void
+  onStopRecording: () => void
+  i18n: I18n
+}
+
+export default function RecordButton({
+  recording,
+  onStartRecording,
+  onStopRecording,
+  i18n,
+}: RecordButtonProps): JSX.Element {
+  if (recording) {
+    return (
+      <button
+        className="uppy-u-reset uppy-c-btn uppy-Audio-button"
+        type="button"
+        title={i18n('stopAudioRecording')}
+        aria-label={i18n('stopAudioRecording')}
+        onClick={onStopRecording}
+        data-uppy-super-focusable
+      >
+        <svg
+          aria-hidden="true"
+          focusable="false"
+          className="uppy-c-icon"
+          width="100"
+          height="100"
+          viewBox="0 0 100 100"
+        >
+          <rect x="15" y="15" width="70" height="70" />
+        </svg>
+      </button>
+    )
+  }
+
+  return (
+    <button
+      className="uppy-u-reset uppy-c-btn uppy-Audio-button"
+      type="button"
+      title={i18n('startAudioRecording')}
+      aria-label={i18n('startAudioRecording')}
+      onClick={onStartRecording}
+      data-uppy-super-focusable
+    >
+      <svg
+        aria-hidden="true"
+        focusable="false"
+        className="uppy-c-icon"
+        width="14px"
+        height="20px"
+        viewBox="0 0 14 20"
+      >
+        <path
+          d="M7 14c2.21 0 4-1.71 4-3.818V3.818C11 1.71 9.21 0 7 0S3 1.71 3 3.818v6.364C3 12.29 4.79 14 7 14zm6.364-7h-.637a.643.643 0 0 0-.636.65V9.6c0 3.039-2.565 5.477-5.6 5.175-2.645-.264-4.582-2.692-4.582-5.407V7.65c0-.36-.285-.65-.636-.65H.636A.643.643 0 0 0 0 7.65v1.631c0 3.642 2.544 6.888 6.045 7.382v1.387H3.818a.643.643 0 0 0-.636.65v.65c0 .36.285.65.636.65h6.364c.351 0 .636-.29.636-.65v-.65c0-.36-.285-.65-.636-.65H7.955v-1.372C11.363 16.2 14 13.212 14 9.6V7.65c0-.36-.285-.65-.636-.65z"
+          fill="#FFF"
+          fill-rule="nonzero"
+        />
+      </svg>
+    </button>
+  )
+}

+ 0 - 12
packages/@uppy/audio/src/RecordingLength.jsx

@@ -1,12 +0,0 @@
-import { h } from 'preact'
-import formatSeconds from './formatSeconds.js'
-
-export default function RecordingLength ({ recordingLengthSeconds, i18n }) {
-  const formattedRecordingLengthSeconds = formatSeconds(recordingLengthSeconds)
-
-  return (
-    <span aria-label={i18n('recordingLength', { recording_length: formattedRecordingLengthSeconds })}>
-      {formattedRecordingLengthSeconds}
-    </span>
-  )
-}

+ 25 - 0
packages/@uppy/audio/src/RecordingLength.tsx

@@ -0,0 +1,25 @@
+import { h } from 'preact'
+import type { I18n } from '@uppy/utils/lib/Translator'
+import formatSeconds from './formatSeconds.ts'
+
+interface RecordingLengthProps {
+  recordingLengthSeconds: number
+  i18n: I18n
+}
+
+export default function RecordingLength({
+  recordingLengthSeconds,
+  i18n,
+}: RecordingLengthProps): JSX.Element {
+  const formattedRecordingLengthSeconds = formatSeconds(recordingLengthSeconds)
+
+  return (
+    <span
+      aria-label={i18n('recordingLength', {
+        recording_length: formattedRecordingLengthSeconds,
+      })}
+    >
+      {formattedRecordingLengthSeconds}
+    </span>
+  )
+}

+ 48 - 32
packages/@uppy/audio/src/RecordingScreen.jsx → packages/@uppy/audio/src/RecordingScreen.tsx

@@ -1,14 +1,34 @@
 /* eslint-disable jsx-a11y/media-has-caption */
 import { h } from 'preact'
 import { useEffect, useRef } from 'preact/hooks'
-import RecordButton from './RecordButton.jsx'
-import RecordingLength from './RecordingLength.jsx'
-import AudioSourceSelect from './AudioSourceSelect.jsx'
-import AudioOscilloscope from './audio-oscilloscope/index.js'
-import SubmitButton from './SubmitButton.jsx'
-import DiscardButton from './DiscardButton.jsx'
+import type { I18n } from '@uppy/utils/lib/Translator'
+import RecordButton from './RecordButton.tsx'
+import RecordingLength from './RecordingLength.tsx'
+import AudioSourceSelect, {
+  type AudioSourceSelectProps,
+} from './AudioSourceSelect.tsx'
+import AudioOscilloscope from './audio-oscilloscope/index.ts'
+import SubmitButton from './SubmitButton.tsx'
+import DiscardButton from './DiscardButton.tsx'
 
-export default function RecordingScreen (props) {
+interface RecordingScreenProps extends AudioSourceSelectProps {
+  stream: MediaStream | null | undefined
+  recordedAudio: string
+  recording: boolean
+  supportsRecording: boolean
+  showAudioSourceDropdown: boolean | undefined
+  onSubmit: () => void
+  i18n: I18n
+  onStartRecording: () => void
+  onStopRecording: () => void
+  onStop: () => void
+  onDiscardRecordedAudio: () => void
+  recordingLengthSeconds: number
+}
+
+export default function RecordingScreen(
+  props: RecordingScreenProps,
+): JSX.Element {
   const {
     stream,
     recordedAudio,
@@ -25,8 +45,8 @@ export default function RecordingScreen (props) {
     recordingLengthSeconds,
   } = props
 
-  const canvasEl = useRef(null)
-  const oscilloscope = useRef(null)
+  const canvasEl = useRef<HTMLCanvasElement>(null)
+  const oscilloscope = useRef<AudioOscilloscope | null>()
 
   // componentDidMount / componentDidUnmount
   useEffect(() => {
@@ -39,7 +59,7 @@ export default function RecordingScreen (props) {
   // componentDidUpdate
   useEffect(() => {
     if (!recordedAudio) {
-      oscilloscope.current = new AudioOscilloscope(canvasEl.current, {
+      oscilloscope.current = new AudioOscilloscope(canvasEl.current!, {
         canvas: {
           width: 600,
           height: 600,
@@ -62,33 +82,24 @@ export default function RecordingScreen (props) {
 
   const hasRecordedAudio = recordedAudio != null
   const shouldShowRecordButton = !hasRecordedAudio && supportsRecording
-  const shouldShowAudioSourceDropdown = showAudioSourceDropdown
-    && !hasRecordedAudio
-    && audioSources
-    && audioSources.length > 1
+  const shouldShowAudioSourceDropdown =
+    showAudioSourceDropdown &&
+    !hasRecordedAudio &&
+    audioSources &&
+    audioSources.length > 1
 
   return (
     <div className="uppy-Audio-container">
       <div className="uppy-Audio-audioContainer">
-        {hasRecordedAudio
-          ? (
-            <audio
-              className="uppy-Audio-player"
-              controls
-              src={recordedAudio}
-            />
-          ) : (
-            <canvas
-              ref={canvasEl}
-              className="uppy-Audio-canvas"
-            />
-          )}
+        {hasRecordedAudio ? (
+          <audio className="uppy-Audio-player" controls src={recordedAudio} />
+        ) : (
+          <canvas ref={canvasEl} className="uppy-Audio-canvas" />
+        )}
       </div>
       <div className="uppy-Audio-footer">
         <div className="uppy-Audio-audioSourceContainer">
-          {shouldShowAudioSourceDropdown
-            ? AudioSourceSelect(props)
-            : null}
+          {shouldShowAudioSourceDropdown ? AudioSourceSelect(props) : null}
         </div>
         <div className="uppy-Audio-buttonContainer">
           {shouldShowRecordButton && (
@@ -102,12 +113,17 @@ export default function RecordingScreen (props) {
 
           {hasRecordedAudio && <SubmitButton onSubmit={onSubmit} i18n={i18n} />}
 
-          {hasRecordedAudio && <DiscardButton onDiscard={onDiscardRecordedAudio} i18n={i18n} />}
+          {hasRecordedAudio && (
+            <DiscardButton onDiscard={onDiscardRecordedAudio} i18n={i18n} />
+          )}
         </div>
 
         <div className="uppy-Audio-recordingLength">
           {!hasRecordedAudio && (
-            <RecordingLength recordingLengthSeconds={recordingLengthSeconds} i18n={i18n} />
+            <RecordingLength
+              recordingLengthSeconds={recordingLengthSeconds}
+              i18n={i18n}
+            />
           )}
         </div>
       </div>

+ 12 - 2
packages/@uppy/audio/src/SubmitButton.jsx → packages/@uppy/audio/src/SubmitButton.tsx

@@ -1,6 +1,12 @@
 import { h } from 'preact'
+import type { I18n } from '@uppy/utils/lib/Translator'
 
-function SubmitButton ({ onSubmit, i18n }) {
+interface SubmitButtonProps {
+  onSubmit: () => void
+  i18n: I18n
+}
+
+function SubmitButton({ onSubmit, i18n }: SubmitButtonProps): JSX.Element {
   return (
     <button
       className="uppy-u-reset uppy-c-btn uppy-Audio-button uppy-Audio-button--submit"
@@ -19,7 +25,11 @@ function SubmitButton ({ onSubmit, i18n }) {
         focusable="false"
         className="uppy-c-icon"
       >
-        <path fill="#fff" fillRule="nonzero" d="M10.66 0L12 1.31 4.136 9 0 4.956l1.34-1.31L4.136 6.38z" />
+        <path
+          fill="#fff"
+          fillRule="nonzero"
+          d="M10.66 0L12 1.31 4.136 9 0 4.956l1.34-1.31L4.136 6.38z"
+        />
       </svg>
     </button>
   )

+ 0 - 84
packages/@uppy/audio/src/audio-oscilloscope/index.js

@@ -1,84 +0,0 @@
-function isFunction (v) {
-  return typeof v === 'function'
-}
-
-function result (v) {
-  return isFunction(v) ? v() : v
-}
-
-/* Audio Oscilloscope
-  https://github.com/miguelmota/audio-oscilloscope
-*/
-export default class AudioOscilloscope {
-  constructor (canvas, options = {}) {
-    const canvasOptions = options.canvas || {}
-    const canvasContextOptions = options.canvasContext || {}
-    this.analyser = null
-    this.bufferLength = 0
-    this.dataArray = []
-    this.canvas = canvas
-    this.width = result(canvasOptions.width) || this.canvas.width
-    this.height = result(canvasOptions.height) || this.canvas.height
-    this.canvas.width = this.width
-    this.canvas.height = this.height
-    this.canvasContext = this.canvas.getContext('2d')
-    this.canvasContext.fillStyle = result(canvasContextOptions.fillStyle) || 'rgb(255, 255, 255)'
-    this.canvasContext.strokeStyle = result(canvasContextOptions.strokeStyle) || 'rgb(0, 0, 0)'
-    this.canvasContext.lineWidth = result(canvasContextOptions.lineWidth) || 1
-    this.onDrawFrame = isFunction(options.onDrawFrame) ? options.onDrawFrame : () => {}
-  }
-
-  addSource (streamSource) {
-    this.streamSource = streamSource
-    this.audioContext = this.streamSource.context
-    this.analyser = this.audioContext.createAnalyser()
-    this.analyser.fftSize = 2048
-    this.bufferLength = this.analyser.frequencyBinCount
-    this.source = this.audioContext.createBufferSource()
-    this.dataArray = new Uint8Array(this.bufferLength)
-    this.analyser.getByteTimeDomainData(this.dataArray)
-    this.streamSource.connect(this.analyser)
-  }
-
-  draw () {
-    const { analyser, dataArray, bufferLength } = this
-    const ctx = this.canvasContext
-    const w = this.width
-    const h = this.height
-
-    if (analyser) {
-      analyser.getByteTimeDomainData(dataArray)
-    }
-
-    ctx.fillRect(0, 0, w, h)
-    ctx.beginPath()
-
-    const sliceWidth = (w * 1.0) / bufferLength
-    let x = 0
-
-    if (!bufferLength) {
-      ctx.moveTo(0, this.height / 2)
-    }
-
-    for (let i = 0; i < bufferLength; i++) {
-      const v = dataArray[i] / 128.0
-      const y = v * (h / 2)
-
-      if (i === 0) {
-        ctx.moveTo(x, y)
-      } else {
-        ctx.lineTo(x, y)
-      }
-
-      x += sliceWidth
-    }
-
-    ctx.lineTo(w, h / 2)
-    ctx.stroke()
-
-    this.onDrawFrame(this)
-    requestAnimationFrame(this.#draw)
-  }
-
-  #draw = () => this.draw()
-}

+ 136 - 0
packages/@uppy/audio/src/audio-oscilloscope/index.ts

@@ -0,0 +1,136 @@
+// eslint-disable-next-line @typescript-eslint/ban-types
+function isFunction(v: any): v is Function {
+  return typeof v === 'function'
+}
+
+function result<T>(v: T): T extends (...args: any) => any ? ReturnType<T> : T {
+  return isFunction(v) ? v() : v
+}
+
+type MaybeFunction<T> = T | (() => T)
+
+interface AudioOscilloscopeOptions {
+  canvas?: {
+    width?: number
+    height?: number
+  }
+  canvasContext?: {
+    width?: MaybeFunction<number>
+    height?: MaybeFunction<number>
+    lineWidth?: MaybeFunction<number>
+    fillStyle?: MaybeFunction<string>
+    strokeStyle?: MaybeFunction<string>
+  }
+
+  // eslint-disable-next-line no-use-before-define
+  onDrawFrame?: (oscilloscope: AudioOscilloscope) => void
+}
+
+/* Audio Oscilloscope
+  https://github.com/miguelmota/audio-oscilloscope
+*/
+export default class AudioOscilloscope {
+  private canvas: HTMLCanvasElement
+
+  private canvasContext: CanvasRenderingContext2D
+
+  private width: number
+
+  private height: number
+
+  private analyser: null | AnalyserNode
+
+  private bufferLength: number
+
+  private dataArray: Uint8Array
+
+  // eslint-disable-next-line no-use-before-define
+  private onDrawFrame: (oscilloscope: AudioOscilloscope) => void
+
+  private streamSource?: MediaStreamAudioSourceNode
+
+  private audioContext: BaseAudioContext
+
+  public source: AudioBufferSourceNode
+
+  constructor(
+    canvas: HTMLCanvasElement,
+    options: AudioOscilloscopeOptions = {},
+  ) {
+    const canvasOptions =
+      options.canvas || ({} as NonNullable<AudioOscilloscopeOptions['canvas']>)
+    const canvasContextOptions =
+      options.canvasContext ||
+      ({} as NonNullable<AudioOscilloscopeOptions['canvasContext']>)
+    this.analyser = null
+    this.bufferLength = 0
+    this.canvas = canvas
+    this.width = result(canvasOptions.width) || this.canvas.width
+    this.height = result(canvasOptions.height) || this.canvas.height
+    this.canvas.width = this.width
+    this.canvas.height = this.height
+    this.canvasContext = this.canvas.getContext('2d')!
+    this.canvasContext.fillStyle =
+      result(canvasContextOptions.fillStyle) || 'rgb(255, 255, 255)'
+    this.canvasContext.strokeStyle =
+      result(canvasContextOptions.strokeStyle) || 'rgb(0, 0, 0)'
+    this.canvasContext.lineWidth = result(canvasContextOptions.lineWidth) || 1
+    this.onDrawFrame = isFunction(options.onDrawFrame)
+      ? options.onDrawFrame
+      : () => {} // eslint-disable-line @typescript-eslint/no-empty-function
+  }
+
+  addSource(streamSource: MediaStreamAudioSourceNode): void {
+    this.streamSource = streamSource
+    this.audioContext = this.streamSource.context
+    this.analyser = this.audioContext.createAnalyser()
+    this.analyser.fftSize = 2048
+    this.bufferLength = this.analyser.frequencyBinCount
+    this.source = this.audioContext.createBufferSource()
+    this.dataArray = new Uint8Array(this.bufferLength)
+    this.analyser.getByteTimeDomainData(this.dataArray)
+    this.streamSource.connect(this.analyser)
+  }
+
+  draw(): void {
+    const { analyser, dataArray, bufferLength } = this
+    const ctx = this.canvasContext
+    const w = this.width
+    const h = this.height
+
+    if (analyser) {
+      analyser.getByteTimeDomainData(dataArray)
+    }
+
+    ctx.fillRect(0, 0, w, h)
+    ctx.beginPath()
+
+    const sliceWidth = (w * 1.0) / bufferLength
+    let x = 0
+
+    if (!bufferLength) {
+      ctx.moveTo(0, this.height / 2)
+    }
+
+    for (let i = 0; i < bufferLength; i++) {
+      const v = dataArray[i] / 128.0
+      const y = v * (h / 2)
+
+      if (i === 0) {
+        ctx.moveTo(x, y)
+      } else {
+        ctx.lineTo(x, y)
+      }
+
+      x += sliceWidth
+    }
+
+    ctx.lineTo(w, h / 2)
+    ctx.stroke()
+
+    this.onDrawFrame(this)
+    requestAnimationFrame(this.#draw)
+  }
+
+  #draw = () => this.draw()
+}

+ 0 - 12
packages/@uppy/audio/src/formatSeconds.js

@@ -1,12 +0,0 @@
-/**
- * Takes an Integer value of seconds (e.g. 83) and converts it into a human-readable formatted string (e.g. '1:23').
- *
- * @param {Integer} seconds
- * @returns {string} the formatted seconds (e.g. '1:23' for 1 minute and 23 seconds)
- *
- */
-export default function formatSeconds (seconds) {
-  return `${Math.floor(
-    seconds / 60,
-  )}:${String(seconds % 60).padStart(2, 0)}`
-}

+ 0 - 12
packages/@uppy/audio/src/formatSeconds.test.js

@@ -1,12 +0,0 @@
-import { describe, expect, it } from 'vitest'
-import formatSeconds from './formatSeconds.js'
-
-describe('formatSeconds', () => {
-  it('should return a value of \'0:43\' when an argument of 43 seconds is supplied', () => {
-    expect(formatSeconds(43)).toEqual('0:43')
-  })
-
-  it('should return a value of \'1:43\' when an argument of 103 seconds is supplied', () => {
-    expect(formatSeconds(103)).toEqual('1:43')
-  })
-})

+ 12 - 0
packages/@uppy/audio/src/formatSeconds.test.ts

@@ -0,0 +1,12 @@
+import { describe, expect, it } from 'vitest'
+import formatSeconds from './formatSeconds.ts'
+
+describe('formatSeconds', () => {
+  it("should return a value of '0:43' when an argument of 43 seconds is supplied", () => {
+    expect(formatSeconds(43)).toEqual('0:43')
+  })
+
+  it("should return a value of '1:43' when an argument of 103 seconds is supplied", () => {
+    expect(formatSeconds(103)).toEqual('1:43')
+  })
+})

+ 7 - 0
packages/@uppy/audio/src/formatSeconds.ts

@@ -0,0 +1,7 @@
+/**
+ * Takes an Integer value of seconds (e.g. 83) and converts it into a
+ * human-readable formatted string (e.g. '1:23').
+ */
+export default function formatSeconds(seconds: number): string {
+  return `${Math.floor(seconds / 60)}:${String(seconds % 60).padStart(2, '0')}`
+}

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

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

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

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

+ 10 - 5
packages/@uppy/audio/src/locale.js → packages/@uppy/audio/src/locale.ts

@@ -1,3 +1,5 @@
+import type { Locale } from '@uppy/utils/lib/Translator'
+
 export default {
   strings: {
     pluginNameAudio: 'Audio',
@@ -10,13 +12,16 @@ export default {
     // Title on the “allow access” screen
     allowAudioAccessTitle: 'Please allow access to your microphone',
     // Description on the “allow access” screen
-    allowAudioAccessDescription: 'In order to record audio, please allow microphone access for this site.',
+    allowAudioAccessDescription:
+      'In order to record audio, please allow microphone access for this site.',
     // Title on the “device not available” screen
     noAudioTitle: 'Microphone Not Available',
     // Description on the “device not available” screen
-    noAudioDescription: 'In order to record audio, please connect a microphone or another audio input device',
+    noAudioDescription:
+      'In order to record audio, please connect a microphone or another audio input device',
     // Message about file size will be shown in an Informer bubble
-    recordingStoppedMaxSize: 'Recording stopped because the file size is about to exceed the limit',
+    recordingStoppedMaxSize:
+      'Recording stopped because the file size is about to exceed the limit',
     // Used as the label for the counter that shows recording length (`1:25`).
     // This is not visibly rendered but is picked up by screen readers.
     recordingLength: 'Recording length %{recording_length}',
@@ -26,5 +31,5 @@ export default {
     // Used as the label for the discard cross button.
     // This is not visibly rendered but is picked up by screen readers.
     discardRecordedFile: 'Discard recorded file',
-  },
-}
+  } as Locale<0>['strings'],
+} as any as Locale

+ 0 - 6
packages/@uppy/audio/src/supportsMediaRecorder.js

@@ -1,6 +0,0 @@
-export default function supportsMediaRecorder () {
-  /* eslint-disable compat/compat */
-  return typeof MediaRecorder === 'function'
-    && typeof MediaRecorder.prototype?.start === 'function'
-  /* eslint-enable compat/compat */
-}

+ 8 - 4
packages/@uppy/audio/src/supportsMediaRecorder.test.js → packages/@uppy/audio/src/supportsMediaRecorder.test.ts

@@ -1,24 +1,28 @@
-/* eslint-disable max-classes-per-file */
+/* eslint-disable max-classes-per-file, compat/compat */
 import { describe, expect, it } from 'vitest'
-import supportsMediaRecorder from './supportsMediaRecorder.js'
+import supportsMediaRecorder from './supportsMediaRecorder.ts'
 
 describe('supportsMediaRecorder', () => {
   it('should return true if MediaRecorder is supported', () => {
+    // @ts-expect-error just a test
     globalThis.MediaRecorder = class MediaRecorder {
-      start () {} // eslint-disable-line
+      start() {} // eslint-disable-line
     }
     expect(supportsMediaRecorder()).toEqual(true)
   })
 
   it('should return false if MediaRecorder is not supported', () => {
+    // @ts-expect-error just a test
     globalThis.MediaRecorder = undefined
     expect(supportsMediaRecorder()).toEqual(false)
 
+    // @ts-expect-error just a test
     globalThis.MediaRecorder = class MediaRecorder {}
     expect(supportsMediaRecorder()).toEqual(false)
 
+    // @ts-expect-error just a test
     globalThis.MediaRecorder = class MediaRecorder {
-      foo () {} // eslint-disable-line
+      foo() {} // eslint-disable-line
     }
     expect(supportsMediaRecorder()).toEqual(false)
   })

+ 8 - 0
packages/@uppy/audio/src/supportsMediaRecorder.ts

@@ -0,0 +1,8 @@
+export default function supportsMediaRecorder(): boolean {
+  /* eslint-disable compat/compat */
+  return (
+    typeof MediaRecorder === 'function' &&
+    typeof MediaRecorder.prototype?.start === 'function'
+  )
+  /* eslint-enable compat/compat */
+}

+ 25 - 0
packages/@uppy/audio/tsconfig.build.json

@@ -0,0 +1,25 @@
+{
+  "extends": "../../../tsconfig.shared",
+  "compilerOptions": {
+    "noImplicitAny": false,
+    "outDir": "./lib",
+    "paths": {
+      "@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": "../utils/tsconfig.build.json"
+    },
+    {
+      "path": "../core/tsconfig.build.json"
+    }
+  ]
+}

+ 21 - 0
packages/@uppy/audio/tsconfig.json

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

+ 1 - 1
packages/@uppy/aws-s3-multipart/package.json

@@ -30,7 +30,7 @@
     "@aws-sdk/client-s3": "^3.362.0",
     "@aws-sdk/s3-request-presigner": "^3.362.0",
     "nock": "^13.1.0",
-    "vitest": "^0.34.5",
+    "vitest": "^1.2.1",
     "whatwg-fetch": "3.6.2"
   },
   "peerDependencies": {

+ 1 - 1
packages/@uppy/aws-s3/package.json

@@ -29,7 +29,7 @@
     "nanoid": "^4.0.0"
   },
   "devDependencies": {
-    "vitest": "^0.34.5",
+    "vitest": "^1.2.1",
     "whatwg-fetch": "3.6.2"
   },
   "peerDependencies": {

+ 1 - 1
packages/@uppy/companion-client/package.json

@@ -26,6 +26,6 @@
     "p-retry": "^6.1.0"
   },
   "devDependencies": {
-    "vitest": "^0.34.5"
+    "vitest": "^1.2.1"
   }
 }

+ 1 - 5
packages/@uppy/companion/.gitignore

@@ -22,10 +22,6 @@ coverage
 # Compiled binary addons (http://nodejs.org/api/addons.html)
 build/Release
 
-# Dependency directory
-# https://www.npmjs.org/doc/misc/npm-faq.html#should-i-check-my-node_modules-folder-into-git
-node_modules
-
 # oAuth Configuration
 config/auth.js
 
@@ -38,6 +34,6 @@ test/output/*
 .DS_Store
 
 # Transpiled
-lib/
+./lib/
 infra/kube/companion/uppy-env.yaml
 scripts/.tl-deploy-hosts-danger.txt

+ 1 - 1
packages/@uppy/compressor/package.json

@@ -35,6 +35,6 @@
     "access": "public"
   },
   "devDependencies": {
-    "vitest": "^0.34.5"
+    "vitest": "^1.2.1"
   }
 }

+ 1 - 1
packages/@uppy/core/package.json

@@ -33,6 +33,6 @@
     "preact": "^10.5.13"
   },
   "devDependencies": {
-    "vitest": "^0.34.5"
+    "vitest": "^1.2.1"
   }
 }

+ 11 - 5
packages/@uppy/core/src/BasePlugin.ts

@@ -21,10 +21,16 @@ export type PluginOpts = {
   [key: string]: unknown
 }
 
+export type DefinePluginOpts<
+  Opts extends PluginOpts,
+  AlwaysDefinedKeys extends string,
+> = Opts & Required<Pick<Opts, AlwaysDefinedKeys>>
+
 export default class BasePlugin<
   Opts extends PluginOpts,
   M extends Meta,
   B extends Body,
+  PluginState extends Record<string, unknown> = Record<string, unknown>,
 > {
   uppy: Uppy<M, B>
 
@@ -42,17 +48,17 @@ export default class BasePlugin<
 
   VERSION: string
 
-  constructor(uppy: Uppy<M, B>, opts: Opts) {
+  constructor(uppy: Uppy<M, B>, opts?: Opts) {
     this.uppy = uppy
-    this.opts = opts ?? {}
+    this.opts = opts ?? ({} as Opts)
   }
 
-  getPluginState(): Record<string, unknown> {
+  getPluginState(): PluginState {
     const { plugins } = this.uppy.getState()
-    return plugins?.[this.id] || {}
+    return (plugins?.[this.id] || {}) as PluginState
   }
 
-  setPluginState(update: unknown): void {
+  setPluginState(update?: Partial<PluginState>): void {
     if (!update) return
     const { plugins } = this.uppy.getState()
 

+ 15 - 5
packages/@uppy/core/src/UIPlugin.ts

@@ -48,7 +48,8 @@ class UIPlugin<
   Opts extends UIPluginOptions,
   M extends Meta,
   B extends Body,
-> extends BasePlugin<Opts, M, B> {
+  PluginState extends Record<string, unknown> = Record<string, unknown>,
+> extends BasePlugin<Opts, M, B, PluginState> {
   #updateUI: (state: Partial<State<M, B>>) => void
 
   isTargetDOMEl: boolean
@@ -59,7 +60,9 @@ class UIPlugin<
 
   title: string
 
-  getTargetPlugin(target: unknown): UIPlugin<any, any, any> | undefined {
+  getTargetPlugin<Me extends Meta, Bo extends Body>(
+    target: PluginTarget<Me, Bo>, // eslint-disable-line no-use-before-define
+  ): UIPlugin<any, Me, Bo> | undefined {
     let targetPlugin
     if (typeof target === 'object' && target instanceof UIPlugin) {
       // Targeting a plugin *instance*
@@ -83,9 +86,9 @@ class UIPlugin<
    * If it’s an object — target is a plugin, and we search `plugins`
    * for a plugin with same name and return its target.
    */
-  mount(
-    target: HTMLElement | string,
-    plugin: UIPlugin<any, any, any>,
+  mount<Me extends Meta, Bo extends Body>(
+    target: PluginTarget<Me, Bo>, // eslint-disable-line no-use-before-define
+    plugin: UIPlugin<any, Me, Bo>,
   ): HTMLElement {
     const callerPluginName = plugin.id
 
@@ -195,3 +198,10 @@ class UIPlugin<
 }
 
 export default UIPlugin
+
+export type PluginTarget<M extends Meta, B extends Body> =
+  | string
+  | Element
+  | typeof BasePlugin
+  | typeof UIPlugin
+  | BasePlugin<any, M, B>

+ 65 - 0
packages/@uppy/core/src/Uppy.test.ts

@@ -7,8 +7,13 @@ import assert from 'node:assert'
 import fs from 'node:fs'
 import path from 'node:path'
 import prettierBytes from '@transloadit/prettier-bytes'
+import type { Body, Meta } from '@uppy/utils/lib/UppyFile'
 import Core from './index.ts'
 import UIPlugin from './UIPlugin.ts'
+import BasePlugin, {
+  type DefinePluginOpts,
+  type PluginOpts,
+} from './BasePlugin.ts'
 import { debugLogger } from './loggers.ts'
 import AcquirerPlugin1 from './mocks/acquirerPlugin1.ts'
 import AcquirerPlugin2 from './mocks/acquirerPlugin2.ts'
@@ -61,6 +66,66 @@ describe('src/Core', () => {
       ).toEqual(1)
     })
 
+    it('should be able to .use() without passing generics again', () => {
+      {
+        interface TestOpts extends PluginOpts {
+          foo?: string
+          bar: string
+        }
+        class TestPlugin<M extends Meta, B extends Body> extends BasePlugin<
+          TestOpts,
+          M,
+          B
+        > {
+          foo: string
+
+          bar: string
+
+          constructor(uppy: Core<M, B>, opts: TestOpts) {
+            super(uppy, opts)
+            this.id = 'Test'
+            this.type = 'acquirer'
+            this.foo = this.opts.foo ?? 'defaultFoo'
+            this.bar = this.opts.bar
+          }
+        }
+        new Core().use(TestPlugin)
+        new Core().use(TestPlugin, { foo: '', bar: '' })
+        // @ts-expect-error boolean not allowed
+        new Core().use(TestPlugin, { bar: false })
+        // @ts-expect-error missing option
+        new Core().use(TestPlugin, { foo: '' })
+      }
+
+      {
+        interface TestOpts extends PluginOpts {
+          foo?: string
+          bar?: string
+        }
+        const defaultOptions = {
+          foo: 'defaultFoo',
+        }
+        class TestPlugin<M extends Meta, B extends Body> extends BasePlugin<
+          DefinePluginOpts<TestOpts, keyof typeof defaultOptions>,
+          M,
+          B
+        > {
+          constructor(uppy: Core<M, B>, opts?: TestOpts) {
+            super(uppy, { ...defaultOptions, ...opts })
+            this.id = this.opts.id ?? 'Test'
+            this.type = 'acquirer'
+          }
+        }
+
+        new Core().use(TestPlugin)
+        new Core().use(TestPlugin, { foo: '', bar: '' })
+        new Core().use(TestPlugin, { foo: '' })
+        new Core().use(TestPlugin, { bar: '' })
+        // @ts-expect-error boolean not allowed
+        new Core().use(TestPlugin, { foo: false })
+      }
+    })
+
     it('should prevent the same plugin from being added more than once', () => {
       const core = new Core()
       core.use(AcquirerPlugin1)

+ 11 - 6
packages/@uppy/core/src/Uppy.ts

@@ -37,7 +37,6 @@ import locale from './locale.ts'
 import type BasePlugin from './BasePlugin.ts'
 import type UIPlugin from './UIPlugin.ts'
 import type { Restrictions } from './Restricter.ts'
-import type { PluginOpts } from './BasePlugin.ts'
 
 type Processor = (fileIDs: string[], uploadID: string) => Promise<void> | void
 
@@ -59,7 +58,7 @@ type UnknownProviderPlugin<M extends Meta, B extends Body> = UnknownPlugin<
 }
 
 // The user facing type for UppyFile used in uppy.addFile() and uppy.setOptions()
-type MinimalRequiredUppyFile<M extends Meta, B extends Body> = Required<
+export type MinimalRequiredUppyFile<M extends Meta, B extends Body> = Required<
   Pick<UppyFile<M, B>, 'name' | 'data' | 'type' | 'source'>
 > &
   Partial<
@@ -543,7 +542,7 @@ export class Uppy<M extends Meta, B extends Body> {
     this.setState(undefined) // so that UI re-renders with new options
   }
 
-  // todo next major: rename to something better? (it doesn't just reset progress)
+  // todo next major: remove
   resetProgress(): void {
     const defaultProgress: Omit<FileProgressNotStarted, 'bytesTotal'> = {
       percentage: 0,
@@ -569,6 +568,8 @@ export class Uppy<M extends Meta, B extends Body> {
     this.emit('reset-progress')
   }
 
+  // @todo next major: rename to `clear()`, make it also cancel ongoing uploads
+  // or throw and say you need to cancel manually
   protected clearUploadedFiles(): void {
     this.setState({ ...defaultUploadState, files: {} })
   }
@@ -1650,9 +1651,9 @@ export class Uppy<M extends Meta, B extends Body> {
   /**
    * Registers a plugin with Core.
    */
-  use<O extends PluginOpts, I extends UIPlugin<O, M, B> | BasePlugin<O, M, B>>(
-    Plugin: new (uppy: this, opts?: O) => I,
-    opts?: O,
+  use<T extends typeof BasePlugin<any, M, B>>(
+    Plugin: T,
+    opts?: ConstructorParameters<T>[1],
   ): this {
     if (typeof Plugin !== 'function') {
       const msg =
@@ -1762,6 +1763,10 @@ export class Uppy<M extends Meta, B extends Body> {
   /**
    * Uninstall all plugins and close down this Uppy instance.
    */
+  // @todo next major: rename to `destroy`.
+  // Cancel local uploads, cancel remote uploads, DON'T cancel assemblies
+  // document that if you do want to cancel assemblies, you need to call smth manually.
+  // Potentially remove reason, as it’s confusing, just come up with a default behaviour.
   close({ reason }: { reason?: FileRemoveReason } | undefined = {}): void {
     this.log(
       `Closing Uppy instance ${this.opts.id}: removing all files and uninstalling plugins`,

+ 5 - 5
packages/@uppy/core/src/__snapshots__/Uppy.test.ts.snap

@@ -1,14 +1,14 @@
 // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
 
-exports[`src/Core > plugins > should not be able to add a plugin that has no id 1`] = `"Your plugin must have an id"`;
+exports[`src/Core > plugins > should not be able to add a plugin that has no id 1`] = `[Error: Your plugin must have an id]`;
 
-exports[`src/Core > plugins > should not be able to add a plugin that has no type 1`] = `"Your plugin must have a type"`;
+exports[`src/Core > plugins > should not be able to add a plugin that has no type 1`] = `[Error: Your plugin must have a type]`;
 
-exports[`src/Core > plugins > should not be able to add an invalid plugin 1`] = `"Expected a plugin class, but got object. Please verify that the plugin was imported and spelled correctly."`;
+exports[`src/Core > plugins > should not be able to add an invalid plugin 1`] = `[TypeError: Expected a plugin class, but got object. Please verify that the plugin was imported and spelled correctly.]`;
 
 exports[`src/Core > plugins > should prevent the same plugin from being added more than once 1`] = `
-"Already found a plugin named 'TestSelector1'. Tried to use: 'TestSelector1'.
-Uppy plugins must have unique \`id\` options. See https://uppy.io/docs/plugins/#id."
+[Error: Already found a plugin named 'TestSelector1'. Tried to use: 'TestSelector1'.
+Uppy plugins must have unique \`id\` options. See https://uppy.io/docs/plugins/#id.]
 `;
 
 exports[`src/Core > uploading a file > should only upload files that are not already assigned to another upload id 1`] = `

+ 1 - 1
packages/@uppy/dashboard/package.json

@@ -41,7 +41,7 @@
     "@uppy/url": "workspace:^",
     "@uppy/webcam": "workspace:^",
     "resize-observer-polyfill": "^1.5.0",
-    "vitest": "^0.34.5"
+    "vitest": "^1.2.1"
   },
   "peerDependencies": {
     "@uppy/core": "workspace:^"

+ 1 - 1
packages/@uppy/remote-sources/package.json

@@ -45,6 +45,6 @@
   },
   "devDependencies": {
     "resize-observer-polyfill": "^1.5.1",
-    "vitest": "^0.34.5"
+    "vitest": "^1.2.1"
   }
 }

+ 145 - 40
packages/@uppy/status-bar/src/Components.jsx → packages/@uppy/status-bar/src/Components.tsx

@@ -1,14 +1,30 @@
+import type { Body, Meta } from '@uppy/utils/lib/UppyFile'
+import type { State, Uppy } from '@uppy/core/src/Uppy.ts'
+import type { FileProcessingInfo } from '@uppy/utils/lib/FileProgress'
+import type { I18n } from '@uppy/utils/lib/Translator'
 import { h } from 'preact'
 import classNames from 'classnames'
 import prettierBytes from '@transloadit/prettier-bytes'
 import prettyETA from '@uppy/utils/lib/prettyETA'
 
-import statusBarStates from './StatusBarStates.js'
+import statusBarStates from './StatusBarStates.ts'
 
 const DOT = `\u00B7`
-const renderDot = () => ` ${DOT} `
+const renderDot = (): string => ` ${DOT} `
+
+interface UploadBtnProps<M extends Meta, B extends Body> {
+  newFiles: number
+  isUploadStarted: boolean
+  recoveredState: null | State<M, B>
+  i18n: I18n
+  uploadState: string
+  isSomeGhost: boolean
+  startUpload: () => void
+}
 
-function UploadBtn (props) {
+function UploadBtn<M extends Meta, B extends Body>(
+  props: UploadBtnProps<M, B>,
+): JSX.Element {
   const {
     newFiles,
     isUploadStarted,
@@ -30,9 +46,10 @@ function UploadBtn (props) {
     { 'uppy-StatusBar-actionBtn--disabled': isSomeGhost },
   )
 
-  const uploadBtnText = newFiles && isUploadStarted && !recoveredState
-    ? i18n('uploadXNewFiles', { smart_count: newFiles })
-    : i18n('uploadXFiles', { smart_count: newFiles })
+  const uploadBtnText =
+    newFiles && isUploadStarted && !recoveredState
+      ? i18n('uploadXNewFiles', { smart_count: newFiles })
+      : i18n('uploadXFiles', { smart_count: newFiles })
 
   return (
     <button
@@ -48,7 +65,14 @@ function UploadBtn (props) {
   )
 }
 
-function RetryBtn (props) {
+interface RetryBtnProps<M extends Meta, B extends Body> {
+  i18n: I18n
+  uppy: Uppy<M, B>
+}
+
+function RetryBtn<M extends Meta, B extends Body>(
+  props: RetryBtnProps<M, B>,
+): JSX.Element {
   const { i18n, uppy } = props
 
   return (
@@ -56,7 +80,11 @@ function RetryBtn (props) {
       type="button"
       className="uppy-u-reset uppy-c-btn uppy-StatusBar-actionBtn uppy-StatusBar-actionBtn--retry"
       aria-label={i18n('retryUpload')}
-      onClick={() => uppy.retryAll().catch(() => { /* Error reported and handled via an event */ })}
+      onClick={() =>
+        uppy.retryAll().catch(() => {
+          /* Error reported and handled via an event */
+        })
+      }
       data-uppy-super-focusable
       data-cy="retry"
     >
@@ -75,7 +103,14 @@ function RetryBtn (props) {
   )
 }
 
-function CancelBtn (props) {
+interface CancelBtnProps<M extends Meta, B extends Body> {
+  i18n: I18n
+  uppy: Uppy<M, B>
+}
+
+function CancelBtn<M extends Meta, B extends Body>(
+  props: CancelBtnProps<M, B>,
+): JSX.Element {
   const { i18n, uppy } = props
 
   return (
@@ -84,7 +119,7 @@ function CancelBtn (props) {
       className="uppy-u-reset uppy-StatusBar-actionCircleBtn"
       title={i18n('cancel')}
       aria-label={i18n('cancel')}
-      onClick={() => uppy.cancelAll()}
+      onClick={(): void => uppy.cancelAll()}
       data-cy="cancel"
       data-uppy-super-focusable
     >
@@ -108,22 +143,34 @@ function CancelBtn (props) {
   )
 }
 
-function PauseResumeButton (props) {
+interface PauseResumeButtonProps<M extends Meta, B extends Body> {
+  i18n: I18n
+  uppy: Uppy<M, B>
+  isAllPaused: boolean
+  isAllComplete: boolean
+  resumableUploads: boolean
+}
+
+function PauseResumeButton<M extends Meta, B extends Body>(
+  props: PauseResumeButtonProps<M, B>,
+): JSX.Element {
   const { isAllPaused, i18n, isAllComplete, resumableUploads, uppy } = props
   const title = isAllPaused ? i18n('resume') : i18n('pause')
 
-  function togglePauseResume () {
-    if (isAllComplete) return null
+  function togglePauseResume(): void {
+    if (isAllComplete) return
 
     if (!resumableUploads) {
-      return uppy.cancelAll()
+      uppy.cancelAll()
+      return
     }
 
     if (isAllPaused) {
-      return uppy.resumeAll()
+      uppy.resumeAll()
+      return
     }
 
-    return uppy.pauseAll()
+    uppy.pauseAll()
   }
 
   return (
@@ -160,14 +207,19 @@ function PauseResumeButton (props) {
   )
 }
 
-function DoneBtn (props) {
+interface DoneBtnProps {
+  i18n: I18n
+  doneButtonHandler: (() => void) | null
+}
+
+function DoneBtn(props: DoneBtnProps): JSX.Element {
   const { i18n, doneButtonHandler } = props
 
   return (
     <button
       type="button"
       className="uppy-u-reset uppy-c-btn uppy-StatusBar-actionBtn uppy-StatusBar-actionBtn--done"
-      onClick={doneButtonHandler}
+      onClick={doneButtonHandler!}
       data-uppy-super-focusable
     >
       {i18n('done')}
@@ -175,7 +227,7 @@ function DoneBtn (props) {
   )
 }
 
-function LoadingSpinner () {
+function LoadingSpinner(): JSX.Element {
   return (
     <svg
       className="uppy-StatusBar-spinner"
@@ -192,10 +244,14 @@ function LoadingSpinner () {
   )
 }
 
-function ProgressBarProcessing (props) {
+interface ProgressBarProcessingProps {
+  progress: FileProcessingInfo
+}
+
+function ProgressBarProcessing(props: ProgressBarProcessingProps): JSX.Element {
   const { progress } = props
   const { value, mode, message } = progress
-  const roundedValue = Math.round(value * 100)
+  const roundedValue = Math.round(value! * 100)
   const dot = `\u00B7`
 
   return (
@@ -207,22 +263,25 @@ function ProgressBarProcessing (props) {
   )
 }
 
-function ProgressDetails (props) {
-  const {
-    numUploads,
-    complete,
-    totalUploadedSize,
-    totalSize,
-    totalETA,
-    i18n,
-  } = props
+interface ProgressDetailsProps {
+  i18n: I18n
+  numUploads: number
+  complete: number
+  totalUploadedSize: number
+  totalSize: number
+  totalETA: number
+}
+
+function ProgressDetails(props: ProgressDetailsProps): JSX.Element {
+  const { numUploads, complete, totalUploadedSize, totalSize, totalETA, i18n } =
+    props
 
   const ifShowFilesUploadedOfTotal = numUploads > 1
 
   return (
     <div className="uppy-StatusBar-statusSecondary">
-      {ifShowFilesUploadedOfTotal
-        && i18n('filesUploadedOfTotal', {
+      {ifShowFilesUploadedOfTotal &&
+        i18n('filesUploadedOfTotal', {
           complete,
           smart_count: numUploads,
         })}
@@ -248,7 +307,13 @@ function ProgressDetails (props) {
   )
 }
 
-function FileUploadCount (props) {
+interface FileUploadCountProps {
+  i18n: I18n
+  complete: number
+  numUploads: number
+}
+
+function FileUploadCount(props: FileUploadCountProps): JSX.Element {
   const { i18n, complete, numUploads } = props
 
   return (
@@ -258,7 +323,13 @@ function FileUploadCount (props) {
   )
 }
 
-function UploadNewlyAddedFiles (props) {
+interface UploadNewlyAddedFilesProps {
+  i18n: I18n
+  newFiles: number
+  startUpload: () => void
+}
+
+function UploadNewlyAddedFiles(props: UploadNewlyAddedFilesProps): JSX.Element {
   const { i18n, newFiles, startUpload } = props
   const uploadBtnClassNames = classNames(
     'uppy-u-reset',
@@ -284,7 +355,26 @@ function UploadNewlyAddedFiles (props) {
   )
 }
 
-function ProgressBarUploading (props) {
+interface ProgressBarUploadingProps {
+  i18n: I18n
+  supportsUploadProgress: boolean
+  totalProgress: number
+  showProgressDetails: boolean | undefined
+  isUploadStarted: boolean
+  isAllComplete: boolean
+  isAllPaused: boolean
+  newFiles: number
+  numUploads: number
+  complete: number
+  totalUploadedSize: number
+  totalSize: number
+  totalETA: number
+  startUpload: () => void
+}
+
+function ProgressBarUploading(
+  props: ProgressBarUploadingProps,
+): JSX.Element | null {
   const {
     i18n,
     supportsUploadProgress,
@@ -310,7 +400,7 @@ function ProgressBarUploading (props) {
 
   const title = isAllPaused ? i18n('paused') : i18n('uploading')
 
-  function renderProgressDetails () {
+  function renderProgressDetails(): JSX.Element | null {
     if (!isAllPaused && !showUploadNewlyAddedFiles && showProgressDetails) {
       if (supportsUploadProgress) {
         return (
@@ -357,7 +447,11 @@ function ProgressBarUploading (props) {
   )
 }
 
-function ProgressBarComplete (props) {
+interface ProgressBarCompleteProps {
+  i18n: I18n
+}
+
+function ProgressBarComplete(props: ProgressBarCompleteProps): JSX.Element {
   const { i18n } = props
 
   return (
@@ -385,10 +479,17 @@ function ProgressBarComplete (props) {
   )
 }
 
-function ProgressBarError (props) {
+interface ProgressBarErrorProps {
+  i18n: I18n
+  error: any
+  complete: number
+  numUploads: number
+}
+
+function ProgressBarError(props: ProgressBarErrorProps): JSX.Element {
   const { error, i18n, complete, numUploads } = props
 
-  function displayErrorAlert () {
+  function displayErrorAlert(): void {
     const errorMessage = `${i18n('uploadFailed')} \n\n ${error}`
     // eslint-disable-next-line no-alert
     alert(errorMessage) // TODO: move to custom alert implementation
@@ -422,7 +523,11 @@ function ProgressBarError (props) {
           </button>
         </div>
 
-        <FileUploadCount i18n={i18n} complete={complete} numUploads={numUploads} />
+        <FileUploadCount
+          i18n={i18n}
+          complete={complete}
+          numUploads={numUploads}
+        />
       </div>
     </div>
   )

+ 78 - 59
packages/@uppy/status-bar/src/StatusBar.jsx → packages/@uppy/status-bar/src/StatusBar.tsx

@@ -1,16 +1,26 @@
+import type { Body, Meta, UppyFile } from '@uppy/utils/lib/UppyFile'
+import type { Uppy, State } from '@uppy/core/src/Uppy.ts'
+import type { DefinePluginOpts } from '@uppy/core/lib/BasePlugin.ts'
 import { UIPlugin } from '@uppy/core'
 import emaFilter from '@uppy/utils/lib/emaFilter'
 import getTextDirection from '@uppy/utils/lib/getTextDirection'
-import statusBarStates from './StatusBarStates.js'
-import StatusBarUI from './StatusBarUI.jsx'
-
+import statusBarStates from './StatusBarStates.ts'
+import StatusBarUI, { type StatusBarUIProps } from './StatusBarUI.tsx'
+// 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'
-import locale from './locale.js'
+import locale from './locale.ts'
+import type { StatusBarOptions } from './StatusBarOptions.ts'
 
 const speedFilterHalfLife = 2000
 const ETAFilterHalfLife = 2000
 
-function getUploadingState (error, isAllComplete, recoveredState, files) {
+function getUploadingState(
+  error: unknown,
+  isAllComplete: boolean,
+  recoveredState: any,
+  files: Record<string, UppyFile<any, any>>,
+): StatusBarUIProps<any, any>['uploadState'] {
   if (error) {
     return statusBarStates.STATE_ERROR
   }
@@ -23,7 +33,8 @@ function getUploadingState (error, isAllComplete, recoveredState, files) {
     return statusBarStates.STATE_WAITING
   }
 
-  let state = statusBarStates.STATE_WAITING
+  let state: StatusBarUIProps<any, any>['uploadState'] =
+    statusBarStates.STATE_WAITING
   const fileIDs = Object.keys(files)
   for (let i = 0; i < fileIDs.length; i++) {
     const { progress } = files[fileIDs[i]]
@@ -33,66 +44,68 @@ function getUploadingState (error, isAllComplete, recoveredState, files) {
     }
     // If files are being preprocessed AND postprocessed at this time, we show the
     // preprocess state. If any files are being uploaded we show uploading.
-    if (progress.preprocess && state !== statusBarStates.STATE_UPLOADING) {
+    if (progress.preprocess) {
       state = statusBarStates.STATE_PREPROCESSING
     }
     // If NO files are being preprocessed or uploaded right now, but some files are
     // being postprocessed, show the postprocess state.
-    if (
-      progress.postprocess
-      && state !== statusBarStates.STATE_UPLOADING
-      && state !== statusBarStates.STATE_PREPROCESSING
-    ) {
+    if (progress.postprocess && state !== statusBarStates.STATE_PREPROCESSING) {
       state = statusBarStates.STATE_POSTPROCESSING
     }
   }
   return state
 }
 
+// set default options, must be kept in sync with @uppy/react/src/StatusBar.js
+const defaultOptions = {
+  target: 'body',
+  hideUploadButton: false,
+  hideRetryButton: false,
+  hidePauseResumeButton: false,
+  hideCancelButton: false,
+  showProgressDetails: false,
+  hideAfterFinish: true,
+  doneButtonHandler: null,
+} satisfies StatusBarOptions
+
 /**
  * StatusBar: renders a status bar with upload/pause/resume/cancel/retry buttons,
  * progress percentage and time remaining.
  */
-export default class StatusBar extends UIPlugin {
+export default class StatusBar<M extends Meta, B extends Body> extends UIPlugin<
+  DefinePluginOpts<StatusBarOptions, keyof typeof defaultOptions>,
+  M,
+  B
+> {
   static VERSION = packageJson.version
 
-  #lastUpdateTime
+  #lastUpdateTime: ReturnType<typeof performance.now>
 
-  #previousUploadedBytes
+  #previousUploadedBytes: number | null
 
-  #previousSpeed
+  #previousSpeed: number | null
 
-  #previousETA
+  #previousETA: number | null
 
-  constructor (uppy, opts) {
-    super(uppy, opts)
+  constructor(uppy: Uppy<M, B>, opts?: StatusBarOptions) {
+    super(uppy, { ...defaultOptions, ...opts })
     this.id = this.opts.id || 'StatusBar'
     this.title = 'StatusBar'
     this.type = 'progressindicator'
 
     this.defaultLocale = locale
 
-    // set default options, must be kept in sync with @uppy/react/src/StatusBar.js
-    const defaultOptions = {
-      target: 'body',
-      hideUploadButton: false,
-      hideRetryButton: false,
-      hidePauseResumeButton: false,
-      hideCancelButton: false,
-      showProgressDetails: false,
-      hideAfterFinish: true,
-      doneButtonHandler: null,
-    }
-
-    this.opts = { ...defaultOptions, ...opts }
-
     this.i18nInit()
 
     this.render = this.render.bind(this)
     this.install = this.install.bind(this)
   }
 
-  #computeSmoothETA (totalBytes) {
+  #computeSmoothETA(totalBytes: {
+    uploaded: number
+    total: number
+    remaining: number
+  }): number {
     if (totalBytes.total === 0 || totalBytes.remaining === 0) {
       return 0
     }
@@ -104,7 +117,8 @@ export default class StatusBar extends UIPlugin {
       return Math.round((this.#previousETA ?? 0) / 100) / 10
     }
 
-    const uploadedBytesSinceLastTick = totalBytes.uploaded - this.#previousUploadedBytes
+    const uploadedBytesSinceLastTick =
+      totalBytes.uploaded - this.#previousUploadedBytes!
     this.#previousUploadedBytes = totalBytes.uploaded
 
     // uploadedBytesSinceLastTick can be negative in some cases (packet loss?)
@@ -113,29 +127,31 @@ export default class StatusBar extends UIPlugin {
       return Math.round((this.#previousETA ?? 0) / 100) / 10
     }
     const currentSpeed = uploadedBytesSinceLastTick / dt
-    const filteredSpeed = this.#previousSpeed == null
-      ? currentSpeed
-      : emaFilter(currentSpeed, this.#previousSpeed, speedFilterHalfLife, dt)
+    const filteredSpeed =
+      this.#previousSpeed == null
+        ? currentSpeed
+        : emaFilter(currentSpeed, this.#previousSpeed, speedFilterHalfLife, dt)
     this.#previousSpeed = filteredSpeed
     const instantETA = totalBytes.remaining / filteredSpeed
 
-    const updatedPreviousETA = Math.max(this.#previousETA - dt, 0)
-    const filteredETA = this.#previousETA == null
-      ? instantETA
-      : emaFilter(instantETA, updatedPreviousETA, ETAFilterHalfLife, dt)
+    const updatedPreviousETA = Math.max(this.#previousETA! - dt, 0)
+    const filteredETA =
+      this.#previousETA == null
+        ? instantETA
+        : emaFilter(instantETA, updatedPreviousETA, ETAFilterHalfLife, dt)
     this.#previousETA = filteredETA
     this.#lastUpdateTime = performance.now()
 
     return Math.round(filteredETA / 100) / 10
   }
 
-  startUpload = () => {
-    return this.uppy.upload().catch(() => {
+  startUpload = (): ReturnType<Uppy<M, B>['upload']> => {
+    return this.uppy.upload().catch((() => {
       // Error logged in Core
-    })
+    }) as () => undefined)
   }
 
-  render (state) {
+  render(state: State<M, B>): JSX.Element {
     const {
       capabilities,
       files,
@@ -161,9 +177,7 @@ export default class StatusBar extends UIPlugin {
     // If some state was recovered, we want to show Upload button/counter
     // for all the files, because in this case it’s not an Upload button,
     // but “Confirm Restore Button”
-    const newFilesOrRecovered = recoveredState
-      ? Object.values(files)
-      : newFiles
+    const newFilesOrRecovered = recoveredState ? Object.values(files) : newFiles
     const resumableUploads = !!capabilities.resumableUploads
     const supportsUploadProgress = capabilities.uploadProgress !== false
 
@@ -194,6 +208,7 @@ export default class StatusBar extends UIPlugin {
       totalUploadedSize,
       isAllComplete: false,
       isAllPaused,
+      // @ts-expect-error TODO: remove this in 4.x branch
       isAllErrored,
       isUploadStarted,
       isUploadInProgress,
@@ -216,27 +231,30 @@ export default class StatusBar extends UIPlugin {
       hidePauseResumeButton: this.opts.hidePauseResumeButton,
       hideCancelButton: this.opts.hideCancelButton,
       hideAfterFinish: this.opts.hideAfterFinish,
+      // ts-expect-error TODO: remove this in 4.x branch
       isTargetDOMEl: this.isTargetDOMEl,
     })
   }
 
-  onMount () {
+  onMount(): void {
     // Set the text direction if the page has not defined one.
     const element = this.el
-    const direction = getTextDirection(element)
+    const direction = getTextDirection(element!)
     if (!direction) {
-      element.dir = 'ltr'
+      element!.dir = 'ltr'
     }
   }
 
-  #onUploadStart = () => {
+  #onUploadStart = (): void => {
     const { recoveredState } = this.uppy.getState()
 
     this.#previousSpeed = null
     this.#previousETA = null
     if (recoveredState) {
-      this.#previousUploadedBytes = Object.values(recoveredState.files)
-        .reduce((pv, { progress }) => pv + progress.bytesUploaded, 0)
+      this.#previousUploadedBytes = Object.values(recoveredState.files).reduce(
+        (pv, { progress }) => pv + (progress.bytesUploaded as number),
+        0,
+      )
 
       // We don't set `#lastUpdateTime` at this point because the upload won't
       // actually resume until the user asks for it.
@@ -248,7 +266,7 @@ export default class StatusBar extends UIPlugin {
     this.#previousUploadedBytes = 0
   }
 
-  install () {
+  install(): void {
     const { target } = this.opts
     if (target) {
       this.mount(target, this)
@@ -258,11 +276,12 @@ export default class StatusBar extends UIPlugin {
     // To cover the use case where the status bar is installed while the upload
     // has started, we set `lastUpdateTime` right away.
     this.#lastUpdateTime = performance.now()
-    this.#previousUploadedBytes = this.uppy.getFiles()
-      .reduce((pv, file) => pv + file.progress.bytesUploaded, 0)
+    this.#previousUploadedBytes = this.uppy
+      .getFiles()
+      .reduce((pv, file) => pv + (file.progress.bytesUploaded as number), 0)
   }
 
-  uninstall () {
+  uninstall(): void {
     this.unmount()
     this.uppy.off('upload', this.#onUploadStart)
   }

+ 14 - 0
packages/@uppy/status-bar/src/StatusBarOptions.ts

@@ -0,0 +1,14 @@
+import type { UIPluginOptions } from '@uppy/core/lib/UIPlugin'
+import type StatusBarLocale from './locale.ts'
+
+export interface StatusBarOptions extends UIPluginOptions {
+  target?: HTMLElement | string
+  showProgressDetails?: boolean
+  hideUploadButton?: boolean
+  hideAfterFinish?: boolean
+  hideRetryButton?: boolean
+  hidePauseResumeButton?: boolean
+  hideCancelButton?: boolean
+  doneButtonHandler?: (() => void) | null
+  locale?: typeof StatusBarLocale
+}

+ 0 - 8
packages/@uppy/status-bar/src/StatusBarStates.js

@@ -1,8 +0,0 @@
-export default {
-  STATE_ERROR: 'error',
-  STATE_WAITING: 'waiting',
-  STATE_PREPROCESSING: 'preprocessing',
-  STATE_UPLOADING: 'uploading',
-  STATE_POSTPROCESSING: 'postprocessing',
-  STATE_COMPLETE: 'complete',
-}

+ 8 - 0
packages/@uppy/status-bar/src/StatusBarStates.ts

@@ -0,0 +1,8 @@
+export default {
+  STATE_ERROR: 'error' as const,
+  STATE_WAITING: 'waiting' as const,
+  STATE_PREPROCESSING: 'preprocessing' as const,
+  STATE_UPLOADING: 'uploading' as const,
+  STATE_POSTPROCESSING: 'postprocessing' as const,
+  STATE_COMPLETE: 'complete' as const,
+}

+ 78 - 24
packages/@uppy/status-bar/src/StatusBarUI.jsx → packages/@uppy/status-bar/src/StatusBarUI.tsx

@@ -1,7 +1,10 @@
+import type { Body, Meta, UppyFile } from '@uppy/utils/lib/UppyFile'
+import type { I18n } from '@uppy/utils/lib/Translator'
+import type { Uppy, State } from '@uppy/core/src/Uppy.ts'
 import { h } from 'preact'
 import classNames from 'classnames'
-import statusBarStates from './StatusBarStates.js'
-import calculateProcessingProgress from './calculateProcessingProgress.js'
+import statusBarStates from './StatusBarStates.ts'
+import calculateProcessingProgress from './calculateProcessingProgress.ts'
 
 import {
   UploadBtn,
@@ -13,7 +16,7 @@ import {
   ProgressBarError,
   ProgressBarUploading,
   ProgressBarComplete,
-} from './Components.jsx'
+} from './Components.tsx'
 
 const {
   STATE_ERROR,
@@ -24,8 +27,42 @@ const {
   STATE_COMPLETE,
 } = statusBarStates
 
+export interface StatusBarUIProps<M extends Meta, B extends Body> {
+  newFiles: number
+  allowNewUpload: boolean
+  isUploadInProgress: boolean
+  isAllPaused: boolean
+  resumableUploads: boolean
+  error: any
+  hideUploadButton?: boolean
+  hidePauseResumeButton?: boolean
+  hideCancelButton?: boolean
+  hideRetryButton?: boolean
+  recoveredState: null | State<M, B>
+  uploadState: (typeof statusBarStates)[keyof typeof statusBarStates]
+  totalProgress: number
+  files: Record<string, UppyFile<M, B>>
+  supportsUploadProgress: boolean
+  hideAfterFinish?: boolean
+  isSomeGhost: boolean
+  doneButtonHandler?: (() => void) | null
+  isUploadStarted: boolean
+  i18n: I18n
+  startUpload: () => void
+  uppy: Uppy<M, B>
+  isAllComplete: boolean
+  showProgressDetails?: boolean
+  numUploads: number
+  complete: number
+  totalSize: number
+  totalETA: number
+  totalUploadedSize: number
+}
+
 // TODO: rename the function to StatusBarUI on the next major.
-export default function StatusBar (props) {
+export default function StatusBar<M extends Meta, B extends Body>(
+  props: StatusBarUIProps<M, B>,
+): JSX.Element {
   const {
     newFiles,
     allowNewUpload,
@@ -58,7 +95,7 @@ export default function StatusBar (props) {
     totalUploadedSize,
   } = props
 
-  function getProgressValue () {
+  function getProgressValue(): number | null {
     switch (uploadState) {
       case STATE_POSTPROCESSING:
       case STATE_PREPROCESSING: {
@@ -83,7 +120,7 @@ export default function StatusBar (props) {
     }
   }
 
-  function getIsIndeterminate () {
+  function getIsIndeterminate(): boolean {
     switch (uploadState) {
       case STATE_POSTPROCESSING:
       case STATE_PREPROCESSING: {
@@ -101,7 +138,7 @@ export default function StatusBar (props) {
     }
   }
 
-  function getIsHidden () {
+  function getIsHidden(): boolean | undefined {
     if (recoveredState) {
       return false
     }
@@ -122,20 +159,23 @@ export default function StatusBar (props) {
 
   const width = progressValue ?? 100
 
-  const showUploadBtn = !error
-    && newFiles
-    && !isUploadInProgress
-    && !isAllPaused
-    && allowNewUpload
-    && !hideUploadButton
+  const showUploadBtn =
+    !error &&
+    newFiles &&
+    !isUploadInProgress &&
+    !isAllPaused &&
+    allowNewUpload &&
+    !hideUploadButton
 
-  const showCancelBtn = !hideCancelButton
-    && uploadState !== STATE_WAITING
-    && uploadState !== STATE_COMPLETE
+  const showCancelBtn =
+    !hideCancelButton &&
+    uploadState !== STATE_WAITING &&
+    uploadState !== STATE_COMPLETE
 
-  const showPauseResumeBtn = resumableUploads
-    && !hidePauseResumeButton
-    && uploadState === STATE_UPLOADING
+  const showPauseResumeBtn =
+    resumableUploads &&
+    !hidePauseResumeButton &&
+    uploadState === STATE_UPLOADING
 
   const showRetryBtn = error && !isAllComplete && !hideRetryButton
 
@@ -159,16 +199,20 @@ export default function StatusBar (props) {
         role="progressbar"
         aria-label={`${width}%`}
         aria-valuetext={`${width}%`}
-        aria-valuemin="0"
-        aria-valuemax="100"
-        aria-valuenow={progressValue}
+        aria-valuemin={0}
+        aria-valuemax={100}
+        aria-valuenow={progressValue!}
       />
 
-      {(() => {
+      {((): JSX.Element | null => {
         switch (uploadState) {
           case STATE_PREPROCESSING:
           case STATE_POSTPROCESSING:
-            return <ProgressBarProcessing progress={calculateProcessingProgress(files)} />
+            return (
+              <ProgressBarProcessing
+                progress={calculateProcessingProgress(files)}
+              />
+            )
           case STATE_COMPLETE:
             return <ProgressBarComplete i18n={i18n} />
           case STATE_ERROR:
@@ -238,3 +282,13 @@ export default function StatusBar (props) {
     </div>
   )
 }
+
+StatusBar.defaultProps = {
+  doneButtonHandler: undefined,
+  hideAfterFinish: false,
+  hideCancelButton: false,
+  hidePauseResumeButton: false,
+  hideRetryButton: false,
+  hideUploadButton: undefined,
+  showProgressDetails: undefined,
+}

+ 11 - 6
packages/@uppy/status-bar/src/calculateProcessingProgress.js → packages/@uppy/status-bar/src/calculateProcessingProgress.ts

@@ -1,14 +1,19 @@
-export default function calculateProcessingProgress (files) {
-  const values = []
-  let mode
-  let message
+import type { FileProcessingInfo } from '@uppy/utils/lib/FileProgress'
+import type { UppyFile } from '@uppy/utils/lib/UppyFile'
+
+export default function calculateProcessingProgress(
+  files: Record<string, UppyFile<any, any>>,
+): FileProcessingInfo {
+  const values: number[] = []
+  let mode: FileProcessingInfo['mode'] = 'indeterminate'
+  let message: FileProcessingInfo['message']
 
   for (const { progress } of Object.values(files)) {
     const { preprocess, postprocess } = progress
     // In the future we should probably do this differently. For now we'll take the
     // mode and message from the first file…
     if (message == null && (preprocess || postprocess)) {
-      ({ mode, message } = preprocess || postprocess)
+      ;({ mode, message } = preprocess || postprocess!) // eslint-disable-line @typescript-eslint/no-non-null-assertion
     }
     if (preprocess?.mode === 'determinate') values.push(preprocess.value)
     if (postprocess?.mode === 'determinate') values.push(postprocess.value)
@@ -22,5 +27,5 @@ export default function calculateProcessingProgress (files) {
     mode,
     message,
     value,
-  }
+  } as FileProcessingInfo
 }

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

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

+ 2 - 0
packages/@uppy/status-bar/src/index.ts

@@ -0,0 +1,2 @@
+export { default } from './StatusBar.tsx'
+export type { StatusBarOptions } from './StatusBarOptions.ts'

+ 4 - 2
packages/@uppy/status-bar/src/locale.js → packages/@uppy/status-bar/src/locale.ts

@@ -1,3 +1,5 @@
+import type { Locale } from '@uppy/utils/lib/Translator'
+
 export default {
   strings: {
     // Shown in the status bar while files are being uploaded.
@@ -45,5 +47,5 @@ export default {
       1: '%{smart_count} more files added',
     },
     showErrorDetails: 'Show error details',
-  },
-}
+  } as Locale<0 | 1>['strings'],
+} as any as Locale

+ 25 - 0
packages/@uppy/status-bar/tsconfig.build.json

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

+ 21 - 0
packages/@uppy/status-bar/tsconfig.json

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

+ 1 - 1
packages/@uppy/store-default/package.json

@@ -15,7 +15,7 @@
     "url": "https://github.com/transloadit/uppy/issues"
   },
   "devDependencies": {
-    "vitest": "^0.34.5"
+    "vitest": "^1.2.1"
   },
   "repository": {
     "type": "git",

+ 1 - 1
packages/@uppy/store-redux/package.json

@@ -24,6 +24,6 @@
   },
   "devDependencies": {
     "redux": "^4.0.0",
-    "vitest": "^0.34.5"
+    "vitest": "^1.2.1"
   }
 }

+ 0 - 2
packages/@uppy/svelte/.gitignore

@@ -1,5 +1,3 @@
 .DS_Store
-node_modules
 /dist/
 /src/empty.*
-package-lock.json

+ 1 - 1
packages/@uppy/thumbnail-generator/package.json

@@ -27,7 +27,7 @@
   },
   "devDependencies": {
     "namespace-emitter": "2.0.1",
-    "vitest": "^0.34.5"
+    "vitest": "^1.2.1"
   },
   "peerDependencies": {
     "@uppy/core": "workspace:^"

+ 1 - 1
packages/@uppy/transloadit/package.json

@@ -37,7 +37,7 @@
     "@uppy/core": "workspace:^"
   },
   "devDependencies": {
-    "vitest": "^0.34.5",
+    "vitest": "^1.2.1",
     "whatwg-fetch": "^3.6.2"
   }
 }

+ 1 - 1
packages/@uppy/tus/package.json

@@ -27,7 +27,7 @@
     "tus-js-client": "^3.1.3"
   },
   "devDependencies": {
-    "vitest": "^0.34.5"
+    "vitest": "^1.2.1"
   },
   "peerDependencies": {
     "@uppy/core": "workspace:^"

+ 1 - 1
packages/@uppy/utils/package.json

@@ -75,6 +75,6 @@
   },
   "devDependencies": {
     "@types/lodash": "^4.14.199",
-    "vitest": "^0.34.5"
+    "vitest": "^1.2.1"
   }
 }

+ 7 - 7
packages/@uppy/utils/src/findDOMElement.ts

@@ -1,19 +1,19 @@
 import isDOMElement from './isDOMElement.ts'
 
-/**
- * Find a DOM element.
- */
-export default function findDOMElement(
-  element: Node | string,
-  context = document,
-): Element | null {
+export default function findDOMElement<T>(
+  element: T,
+  context: Document = document,
+): T extends Element ? T : T extends Node | string ? Element | null : null {
   if (typeof element === 'string') {
+    // @ts-expect-error ????
     return context.querySelector(element)
   }
 
   if (isDOMElement(element)) {
+    // @ts-expect-error ????
     return element
   }
 
+  // @ts-expect-error ????
   return null
 }

+ 1 - 2
packages/@uppy/vue/.gitignore

@@ -1,5 +1,4 @@
 .DS_Store
-node_modules
 /dist
 
 
@@ -22,5 +21,5 @@ pnpm-debug.log*
 *.sln
 *.sw?
 
-lib
+./lib
 *.vue.js

+ 1 - 1
packages/@uppy/webcam/package.json

@@ -31,7 +31,7 @@
     "preact": "^10.5.13"
   },
   "devDependencies": {
-    "vitest": "^0.34.5"
+    "vitest": "^1.2.1"
   },
   "peerDependencies": {
     "@uppy/core": "workspace:^"

+ 1 - 1
packages/@uppy/xhr-upload/package.json

@@ -30,7 +30,7 @@
   },
   "devDependencies": {
     "nock": "^13.1.0",
-    "vitest": "^0.34.5"
+    "vitest": "^1.2.1"
   },
   "peerDependencies": {
     "@uppy/core": "workspace:^"

+ 1 - 1
private/dev/package.json

@@ -13,7 +13,7 @@
     "autoprefixer": "^10.2.6",
     "postcss-dir-pseudo-class": "^6.0.0",
     "postcss-logical": "^5.0.0",
-    "vite": "^4.0.0"
+    "vite": "^5.0.0"
   },
   "private": true,
   "type": "module",

+ 2 - 2
private/locale-pack/index.mjs

@@ -11,8 +11,8 @@ const localesPath = path.join(root, 'packages', '@uppy', 'locales')
 const templatePath = path.join(localesPath, 'template.js')
 const englishLocalePath = path.join(localesPath, 'src', 'en_US.js')
 
-async function getLocalesAndCombinedLocale() {
-  const locales = await getLocales(`${root}/packages/@uppy/**/src/locale.js`)
+async function getLocalesAndCombinedLocale () {
+  const locales = await getLocales(`${root}/packages/@uppy/**/lib/locale.js`)
 
   const combinedLocale = {}
   for (const [pluginName, locale] of Object.entries(locales)) {

Diff do ficheiro suprimidas por serem muito extensas
+ 339 - 514
yarn.lock


Alguns ficheiros não foram mostrados porque muitos ficheiros mudaram neste diff