Bläddra i källkod

Merge branch 'master' into patch-1

Artur Paikin 6 år sedan
förälder
incheckning
95b539fedb
100 ändrade filer med 2347 tillägg och 1162 borttagningar
  1. 8 0
      .browserslistrc
  2. 6 2
      .eslintrc.json
  3. 120 35
      CHANGELOG.md
  4. 11 3
      README.md
  5. 19 3
      bin/endtoend-build
  6. 2 1
      bin/upload-to-cdn.sh
  7. 2 2
      examples/cdn-example/index.html
  8. 3 2
      examples/react-example/App.js
  9. 2 2
      examples/uppy-with-server/client/index.html
  10. 542 1
      package-lock.json
  11. 11 4
      package.json
  12. 48 20
      src/core/Core.js
  13. 78 79
      src/core/Core.test.js
  14. 9 4
      src/core/Plugin.js
  15. 0 41
      src/core/PromiseWaiter.js
  16. 0 468
      src/core/Utils.js
  17. 0 3
      src/core/Utils.test.js
  18. 19 20
      src/plugins/AwsS3/Multipart.js
  19. 25 2
      src/plugins/AwsS3/index.js
  20. 4 11
      src/plugins/Dashboard/Dashboard.js
  21. 2 2
      src/plugins/Dashboard/FileCard.js
  22. 55 29
      src/plugins/Dashboard/FileItem.js
  23. 13 10
      src/plugins/Dashboard/FileItemProgress.js
  24. 9 1
      src/plugins/Dashboard/FileList.js
  25. 1 1
      src/plugins/Dashboard/Tabs.js
  26. 100 54
      src/plugins/Dashboard/index.js
  27. 21 13
      src/plugins/DragDrop/index.js
  28. 1 0
      src/plugins/Dropbox/index.js
  29. 5 1
      src/plugins/Dummy.js
  30. 11 7
      src/plugins/FileInput.js
  31. 1 1
      src/plugins/Form.js
  32. 6 0
      src/plugins/GoldenRetriever/.eslintrc.json
  33. 8 9
      src/plugins/GoldenRetriever/index.js
  34. 1 0
      src/plugins/GoogleDrive/index.js
  35. 7 1
      src/plugins/Instagram/index.js
  36. 4 4
      src/plugins/ReduxDevTools.js
  37. 9 8
      src/plugins/StatusBar/StatusBar.js
  38. 5 5
      src/plugins/StatusBar/index.js
  39. 11 12
      src/plugins/ThumbnailGenerator/index.js
  40. 4 4
      src/plugins/ThumbnailGenerator/index.test.js
  41. 3 3
      src/plugins/Transloadit/Client.js
  42. 18 18
      src/plugins/Transloadit/index.js
  43. 2 2
      src/plugins/Transloadit/index.test.js
  44. 26 43
      src/plugins/Tus.js
  45. 1 0
      src/plugins/Url/UrlUI.js
  46. 10 6
      src/plugins/Url/index.js
  47. 3 2
      src/plugins/Webcam/RecordButton.js
  48. 14 6
      src/plugins/Webcam/index.js
  49. 70 48
      src/plugins/XHRUpload.js
  50. 37 0
      src/plugins/XHRUpload.test.js
  51. 1 1
      src/react/Dashboard.js
  52. 35 0
      src/react/Dashboard.test.js
  53. 1 1
      src/react/DashboardModal.js
  54. 81 0
      src/react/DashboardModal.test.js
  55. 1 1
      src/react/DragDrop.js
  56. 35 0
      src/react/DragDrop.test.js
  57. 1 1
      src/react/ProgressBar.js
  58. 35 0
      src/react/ProgressBar.test.js
  59. 1 1
      src/react/StatusBar.js
  60. 35 0
      src/react/StatusBar.test.js
  61. 18 0
      src/react/__mocks__/DashboardPlugin.js
  62. 18 0
      src/react/__mocks__/DragDropPlugin.js
  63. 18 0
      src/react/__mocks__/ProgressBarPlugin.js
  64. 18 0
      src/react/__mocks__/StatusBarPlugin.js
  65. 0 23
      src/scss/_animation.scss
  66. 122 104
      src/scss/_dashboard.scss
  67. 8 4
      src/scss/_informer.scss
  68. 9 15
      src/scss/_provider.scss
  69. 5 1
      src/scss/_statusbar.scss
  70. 15 0
      src/server/Provider.js
  71. 42 17
      src/server/RequestClient.js
  72. 12 0
      src/server/RequestClient.test.js
  73. 18 0
      src/utils/canvasToBlob.js
  74. 51 0
      src/utils/copyToClipboard.js
  75. 7 0
      src/utils/copyToClipboard.test.js
  76. 25 0
      src/utils/dataURItoBlob.js
  77. 11 0
      src/utils/dataURItoBlob.test.js
  78. 5 0
      src/utils/dataURItoFile.js
  79. 12 0
      src/utils/dataURItoFile.test.js
  80. 15 0
      src/utils/emitSocketProgress.js
  81. 18 0
      src/utils/findAllDOMElements.js
  82. 17 0
      src/utils/findDOMElement.js
  83. 18 0
      src/utils/generateFileID.js
  84. 18 0
      src/utils/generateFileID.test.js
  85. 15 0
      src/utils/getArrayBuffer.js
  86. 30 0
      src/utils/getArrayBuffer.test.js
  87. 3 0
      src/utils/getBytesRemaining.js
  88. 11 0
      src/utils/getBytesRemaining.test.js
  89. 12 0
      src/utils/getETA.js
  90. 14 0
      src/utils/getETA.test.js
  91. 15 0
      src/utils/getFileNameAndExtension.js
  92. 17 0
      src/utils/getFileNameAndExtension.test.js
  93. 24 0
      src/utils/getFileType.js
  94. 47 0
      src/utils/getFileType.test.js
  95. 16 0
      src/utils/getFileTypeExtension.js
  96. 13 0
      src/utils/getFileTypeExtension.test.js
  97. 8 0
      src/utils/getSocketHost.js
  98. 9 0
      src/utils/getSocketHost.test.js
  99. 7 0
      src/utils/getSpeed.js
  100. 13 0
      src/utils/getSpeed.test.js

+ 8 - 0
.browserslistrc

@@ -0,0 +1,8 @@
+IE 10
+last 2 Safari versions
+last 2 Chrome versions
+last 2 ChromeAndroid versions
+last 2 Firefox versions
+last 2 FirefoxAndroid versions
+last 2 Edge versions
+iOS 11.2

+ 6 - 2
.eslintrc → .eslintrc.json

@@ -9,8 +9,12 @@
     "window": true,
     "hexo": true
   },
-  "plugins": ["jest"],
+  "plugins": ["jest", "compat"],
   "rules": {
-    "jsx-quotes": ["error", "prefer-double"]
+    "jsx-quotes": ["error", "prefer-double"],
+    "compat/compat": ["error"]
+  },
+  "settings": {
+    "polyfills": ["fetch", "promises"]
   }
 }

+ 120 - 35
CHANGELOG.md

@@ -41,15 +41,14 @@ PRs are welcome! Please do open an issue to discuss first if it's a big feature,
 - [ ] statusbar: add option to always show
 - [ ] have a `resetProgress` method for resetting a single file, and call it before starting an upload. see comment in #393
 - [ ] “Custom Provider” plugin for  Dashboard — shows already uploaded files or files from a custom service; accepts an array of files to show in options, no uppy-server required #362
-- [ ] WordPress plugin
+- [ ] WordPress plugin https://www.producthunt.com/posts/uppy-io#comment-559327 (“And Gravity forms”)
 - [ ] Transformations, cropping, filters for images, see #53
 - [ ] Prepare for (piwik-) tracking of usage of uppy ? see #83
 - [ ] screenshot+screencast support similar to Webcam #148
 - [ ] Webcam modes #198
 - [ ] feature: React Native support
 - [ ] consider iframe / more security for Transloadit/Uppy integration widget and Uppy itself. Page can’t get files from Google Drive if its an iframe; possibility for folder restriction for provider plugins
-- [ ] It would be nice in the long run to have a dynamic package builder here right on the website where you can select the plugins you need/want and it builds and downloads a minified version of them?
-Sort of like jQuery UI: https://jqueryui.com/download/
+- [ ] It would be nice in the long run to have a dynamic package builder here right on the website where you can select the plugins you need/want and it builds and downloads a minified version of them? Sort of like jQuery UI: https://jqueryui.com/download/
 - [ ] test: add https://github.com/pa11y/pa11y for automated accessibility testing?
 - [ ] test: add tests for `npm pack`
 - [ ] test: add deepFreeze to test that state in not mutated anywhere by accident #320
@@ -68,68 +67,154 @@ Sort of like jQuery UI: https://jqueryui.com/download/
 - [ ] dashboard: add option to disable uploading from local disk #657
 - [ ] dashboard: display data like image resolution on file cards #783
 - [ ] server: pass metadata to S3 `getKey` option, see https://github.com/transloadit/uppy/issues/689
+- [ ] core: I think there is a use case for having a single-use mode or something for Uppy, where pressing "Upload" locks it down (no new files can be added) and once the upload is finished it's just done. especially with the Form plugin
+- [] dashboard: hiding pause/resume from the UI by default (with option) would be good too probably (we could auto pause and show a resume button when detecting a network change to a metered network using https://devdocs.io/dom/networkinformation/type)
 
 ## 1.0 Goals
 
 What we need to do to release Uppy 1.0
 
-- [ ] QA: test how everything works together: user experience from `npm install` to production build with Webpack, using in React/Redux environment (npm pack)
-- [ ] QA: test in multiple browsers and mobile devices again
-- [x] QA: test uppy server. benchmarks / stress test. multiple connections, different setups, large files (10 GB)
-- [ ] QA: tests for some plugins
+- [ ] website: big release blog post
+- [ ] chore: hunt down all `@TODO`s and either fix, or remove, or move to github issues/changelog backlog
+- [ ] chore: remove dead code/commented blocks
+- [ ] chore: rewrite all code based on prettier+standardjs.com
+- [ ] ~refactoring: Make `uppy-server` module live in main Uppy repo in `./server` as a second stage todo (after Lerna is done and we're happy) (@ife)
+- [ ] QA: manually test in multiple browsers and mobile devices again (SauceLabs can do Android/iOS too) (@nqst)
+- [ ] QA: add one integration test that uses a Webpack and React/Redux environment (e.g. via `create-react-app`) (@goto-bus-stop)
+- [ ] QA: add one integration test that uses a Provider (investigate if possible with a dedicated Google Drive API key for uppy server, so _with_ oauth dance) (@ife)
+- [ ] QA: add one integration test that uses more exotic (tus) options such as `useFastRemoteRetry` (@arturi)
+- [ ] feature: preset for Transloadit that mimics jQuery SDK, check https://github.com/transloadit/jquery-sdk docs (@goto-bus-stop)
+- [ ] feature: basic React Native support (@arturi owner+ios, @ife android)
+- [ ] refactoring: split uppy into small packages, Lerna.js repo? and figure out how to share styles (during work, maybe add PR warning in `.github/*`? use `git mv` for everything) (@goto-bus-stop, @arturi)
+- [x] QA: make it so that all integration tests use `npm pack` and `npm install` first (@ife)
 - [x] docs: on using plugins, all options, list of plugins, i18n
-- [ ] feature: preset for Transloadit that mimics jQuery SDK, check https://github.com/transloadit/jquery-sdk docs
-- [ ] refactoring: possibly add CSS-in-JS, style encapsulation
-- [x] refactoring: possibly switch from Yo-Yo to Preact, because it’s more stable, solves a few issues we are struggling with (onload being weird/hard/modern-browsers-only with bel; no way to pass refs to elements; extra network requests with base64 urls) and mature, “new standard”, larger community
-- [ ] refactoring: split uppy into small packages, lerna repo?
-- [x] QA: tests for core and utils
-- [ ] feature: basic Reacte Native support
-- [x] feature: Redux and ReduxDevTools support (currently mirrors Uppy state to Redux)
 - [x] feature: beta file recovering after closed tab / browser crash
 - [x] feature: easy integration with React (UppyReact components)
 - [x] feature: finish the direct-to-s3 upload plugin and test it with the flow to then upload to :transloadit: afterwards. This is because this might influence the inner flow of the plugin architecture quite a bit
+- [x] feature: Redux and ReduxDevTools support (currently mirrors Uppy state to Redux)
 - [x] feature: restrictions: by size, number of files, file type
+- [x] QA: test uppy server. benchmarks / stress test. multiple connections, different setups, large files (10 GB)
+- [x] QA: tests for core and utils
+- [x] refactoring: possibly switch from Yo-Yo to Preact, because it’s more stable, solves a few issues we are struggling with (onload being weird/hard/modern-browsers-only with bel; no way to pass refs to elements; extra network requests with base64 urls) and mature, “new standard”, larger community
 - [x] refactoring: webcam plugin
 - [x] uppy-server: add uppy-server to main API service to scale it horizontally. for the standalone server, we could write the script to support multiple clusters. Not sure how required or neccessary this may be for Transloadit's API service.
 - [x] uppy-server: better error handling, general cleanup (remove unused code. etc)
 - [x] uppy-server: security audit
 - [x] uppy-server: storing tokens in user’s browser only (d040281cc9a63060e2f2685c16de0091aee5c7b4)
 
-# 0.26.0
-
-- [ ] dashboard: allow minimizing the Dashboard during upload (Uppy then becomes just a tiny progress indicator) (@arturi)
-
 # next
 
-## 0.25.0
+# 0.26.0
 
-To Be Released: 2018-05-31.
+To Be Released: 2018-06-28.
 
-- [ ] dashboard: cancel button for transloadit assemblies (@arturi, @goto-bus-stop)
+- [ ] dashboard: allow minimizing the Dashboard during upload (Uppy then becomes just a tiny progress indicator) (@arturi)
+- [ ] core: customizing metadata fields, boolean metadata; see #809, #454 and related (@arturi)
 - [ ] core: figure out per-plugin locales and i18n strings packs #491
-- [ ] goldenretriever: confirmation before restore #443
-- [ ] goldenretriever: add “ghost” files (@arturi)
+- [ ] goldenretriever: confirmation before restore, add “ghost” files #443 (@arturi)
 - [ ] test: add typescript with JSDoc (@arturi)
 - [ ] dragdrop: allow customizing arrow icon https://github.com/transloadit/uppy/pull/374#issuecomment-334116208 (@arturi)
-- [ ] dashboard: disallow removing files if `bundle: true` in XHRUpload (@arturi)
+- [ ] dashboard: cancel button for transloadit assemblies (@arturi, @goto-bus-stop)
 - [ ] dashboard: optional alert `onbeforeunload` while upload is in progress, safeguarding from accidentaly navigating away from a page with an ongoing upload
 - [ ] dashboard: add image cropping, study https://github.com/MattKetmo/darkroomjs/, https://github.com/fengyuanchen/cropperjs #151
 - [ ] docs: quick start guide: https://community.transloadit.com/t/quick-start-guide-would-be-really-helpful/14605 (@arturi)
 - [ ] transloadit: add error reporting (@goto-bus-stop)
-- [ ] uppy-server: benchmarks / stress test, large file, uppy-server / tus / S3 (10 GB)
-- [ ] uppy-server: document docker image setup for uppy-server (@ifedapoolarewaju)
-- [ ] xhrupload: emit a final `upload-progress` event in the XHRUpload plugin just before firing `upload-complete` (tus-js-client already handles this internally) (@arturi)
-- [ ] core: add more mime-to-extension mappings from https://github.com/micnic/mime.json/blob/master/index.json (which ones?) # (@arturi, @goto-bus-stop)
-- [ ] providers: select files only after “select” is pressed, don’t add them right away when they are checked (keep a list of fileIds in state?); better UI + solves issue with autoProceed uploading in background, which is weird; re-read https://github.com/transloadit/uppy/pull/419#issuecomment-345210519 (@arturi, @goto-bus-stop)
-- [x] tus: add `filename` and `filetype`, so that tus servers knows what headers to set  https://github.com/tus/tus-js-client/commit/ebc5189eac35956c9f975ead26de90c896dbe360 (#844 / @vith)
 - [ ] core: look into utilizing https://github.com/que-etc/resize-observer-polyfill for responsive components. See also https://github.com/transloadit/uppy/issues/750
-- [x] core: removed .run() (to solve issues like #756), update ddocs (#793 / goto-bus-stop)
 - [ ] core: use Browserslist config to share between PostCSS, Autoprefixer and Babel https://github.com/browserslist/browserslist, https://github.com/amilajack/eslint-plugin-compat (@arturi)
 - [ ] core: utilize https://github.com/jonathantneal/postcss-preset-env, maybe https://github.com/jonathantneal/postcss-normalize (@arturi)
-- [ ] docs: improve on React docs https://uppy.io/docs/react/, add small example for each component maybe? Dashboard, DragDrop, ProgressBar? No need to make separate pages for all of them, just headings on the same page. Right now docs are confusing, because they focus on DashboardModal. Also problems with syntax highlight on https://uppy.io/docs/react/dashboard-modal/ (@goto-bus-stop)
-- [ ] core: customizing metadata fields, boolean metadata; see #809, #454 and related (@arturi)
-- [x] providers: Add user/account names to Uppy provider views (61bf0a7 / @ifedapoolarewaju)
-- [x] s3: implement multipart uploads (#726 / @goto-bus-stop)
+- [x] thumbnailgenerator: Polyfill Math.log2 since IE11 doesn't support this method (#892 / @DJWassink)
+- [x] xhrupload: Add `withCredentials` option (#874 / @tuoxiansp)
+
+## 0.25.5
+
+Released: 2018-06-13.
+
+- build: exclude and ignore `node_modules` from `test/endtoend` (@arturi, @kvz / #a60c2f0c641f7db580937ebbc0884e25c8ef8583, #355f696a74d8ec56381578f1fb5ad9c913fe8200)
+
+## 0.25.4
+
+Released: 2018-06-13.
+
+- providers: hanging URL upload (#8e13f416f74e7a453e7bdc829e9618f3b7d68804 / @ifedapoolarewaju)
+- url: fix input focus (#3f9aa3bb7fc7ce5814fe50268a6f88f5965d9f16 / @arturi)
+
+## 0.25.3
+
+Released: 2018-06-12.
+
+- core: fix/refactor `uppy.close()` and `uppy.removePlugin(plugin)`: Remove plugins immutably when uppy.close() is called, not just uninstall; emit event `plugin-remove` before removing plugin; remove plugins from Dashboard when they are removed from Uppy; check if plugin exists in Uppy before re-rendering, since debounced re-render can happen after a plugin is removed, that’s been causing issues in #890 (#898 / @arturi)
+- tests: run integration tests with npm-installed uppy (#880 / @ifedapoolarewaju)
+- xhrupload: add withCredentials option (#874 / @tuoxiansp, @b1ncer)
+- xhrupload: Move .withCredentials assignment to after open(): IE 10 doesn't allow setting it before open() is called (#2698b599d716743bbf7ed3ac70c648fef0fd8976 / @goto-bus-stop)
+- thumbnailgenerator: Updated ThumbnailGenerator to work with IE (#4ddc9da47b13c9dfe49155d8c3bcd76b9fa494f2 / @DJWassink)
+- core: add eslint-plugin-compat (@goto-bus-stop, #894)
+- dashboard: remove Dashboard bottom margin, since “powered by” has been moved (#a561e4e7a2c18f5092ba03185e0836ffa6796d04 / @arturi)
+- dashboard: fix Dashboard open/close animation on small screen (#982d27f62693c0eb026e381d10157afffe1eeb64 / @arturi)
+- awss3: Don't set uploadURL when success_action_status was missing (#900 / @goto-bus-stop)
+- thumbnailgenerator: Add id option to ThumbnailGenerator (#8cded8160b19d3324d9e14be122c4038ed0b9403 / @arturi)
+- react: tiny improvement for Uppy React example (645e15166a6bd100351de131982df080bc71aac6 / @arturi)
+
+## 0.25.2
+
+Released: 2018-06-05.
+
+- transloadit: `file.remote` --> `file.remote.host`, since `remote` is an object (aa8247b6e2aeffc5aa237b983d88faae53819133 / @ifedapoolarewaju, @arturi)
+- dashboard: Move `poweredByUppy` inside the Dashboard (a5f23c7fd57a0a0a554580b5d5423f54b39c2444 / @arturi)
+
+## 0.25.1
+
+Released: 2018-06-05.
+
+- provider: fix — match origin pattern for non-static hosts, add `hostPattern` option — a regular expression, for Uppy Server running on `server1.example.com` and `server2.example.com`, you should set `hostPattern: '.example.com$'` (644da749dfb4ecc5c32c744f155fc4c1b07fce13 / @ifedapoolarewaju)
+- provider: fix — check for non protocol defined urls in provider requests (5af90f4fe5c10ee4f32cc4471458cea994ef519a / @ifedapoolarewaju)
+- provider: fix — strip protocol before comparing urls (a22c897013e3de5b324bb31683706e8390169978 / @ifedapoolarewaju)
+- provider: feature: display username in provider view by @ifedapoolarewaju, this is a fix, got lost in PR merge/rebase (1f3a2bb7ddce2b6f1eaa5476be28cebb4529a3bd / @ifedapoolarewaju)
+- provider: Tolerate trailing slashes in `host` options (having a trailing slash in a host option used to break providers) (#885 / @goto-bus-stop)
+- s3: Fix uploadURL for presigned PUT uploads — strips the query string from the URL used for a successful PUT upload to determine the `uploadURL` (#886 / @goto-bus-stop)
+- dashboard: fix line-height in Dashboard tabs (3a7ee860340afcf7abf61be38b0e1398fbe75923 / @arturi)
+- docs: typos and polish (@AJvanLoon)
+- website: improve syntax highlighting on the website — uses prismjs for syntax highlighting instead of highlight.js; the primary motivation is that highlight.js does not support JSX, while prism does (#884 / @goto-bus-stop)
+
+## 0.25.0
+
+Released: 2018-06-01.
+
+- core: ⚠️ **breaking** Removed `.run()` (to solve issues like #756), just `.use()` all the way (#793 / goto-bus-stop)
+- core: ⚠️ **breaking** Changed some of the strings that we were concatenating in Preact, now their interpolation is handled by the Translator instead. This is important for languages that have different word order than English. (#845 / @goto-bus-stop)
+Changed strings:
+  - core: `failedToUpload` needs to contain `%{file}`, substituted by the name of the file that failed
+  - dashboard: `dropPaste` and `dropPasteImport` need to contain `%{browse}`, substituted by the "browse" text button
+  - dashboard: `editing` needs to contain `%{file}`, substituted by the name of the file being edited
+  - dashboard: `fileSource` and `importFrom` need to contain `%{name}`, substituted by the name of the provider
+  - dragdrop: `dropHereOr` needs to contain `%{browse}`, substituted by the "browse" text button
+- providers: ⚠️ **breaking** select files only after “select” is pressed, don’t add them right away when they are checked — better UI + solves issue with autoProceed uploading in background, which is weird; re-read https://github.com/transloadit/uppy/pull/419#issuecomment-345210519(#826 / @goto-bus-stop, @arturi)
+- core: Add error if trying to setFileState() for a file that’s been removed; clear error on cancelAll (#864 / @goto-bus-stop, @arturi)
+- core: Debounce render calls again, fixes #669 (#796 / @goto-bus-stop)
+- core: add more mime-to-extension mappings from https://github.com/micnic/mime.json/blob/master/index.json (#806 /@arturi, @goto-bus-stop)
+- core: addFile not passing restrictions shouldn’t throw when called from UI (@arturi)
+- core: set `bytesUploaded = bytesTotal` when upload is complete (#f51ab0f / @arturi)
+- core: use uppy.getState() instead of uppy.state (#863 / @goto-bus-stop)
+- dashboard & statusbar: allow to hide cancel, pause-resume and retry buttons: hideUploadButton: false, hideRetryButton: false, hidePauseResumeCancelButtons: false (#821, #853 / @mrbatista, @arturi)
+- dashboard: Dashboard open/close animation; move ESC and TAB event listener, improve FOCUSABLE_ELEMENTS, update docs (#852 / @arturi)
+- dashboard: Don’t use h1-h6 tags (add role=heading), might solve some styling issues for embedded Uppy; fix weird artifacts instead of ellipsis issue (#868 / @arturi)
+- dashboard: Use i18n for save/cancel in Dashboard file card (#841 / @arturi)
+- dashboard: disallow removing files if bundle: true in XHRUpload (#853 / @arturi)
+- docs: improve on React docs https://uppy.io/docs/react/, add small example for each component: Dashboard, DragDrop, ProgressBar, etc; more plugin options, better group (#845 / @goto-bus-stop)
+- provider: Fix an issue where .focus() is scrolling the page, same as in UrlUI (#51df805 / @arturi)
+- provider: show message for empty provider files (#ff628b6 / @ifedapoolarewaju)
+- providers: Add user/account names to Uppy provider views (61bf0a7 / @ifedapoolarewaju)
+- providers: display username in provider view (61bf0a7 / @ifedapoolarewaju)
+- react: Added tests for mounting/unmounting React components (#854 / @goto-bus-stop)
+- react: Fixed plugin ID mismatch in React components, fixes #850 (#854 / @goto-bus-stop)
+- s3: implement multipart uploads (#726 / @goto-bus-stop)
+- tus: add `filename` and `filetype`, so that tusd servers knows what headers to set (#844 / @vith)
+- ui-plugins: Add try/catch to `addfile()` calls from UI plugins (@arturi / #867)
+- uppy-server: benchmarks / stress test, large file, uppy-server / tus / S3 (10 GB) (@ifedapoolarewaju)
+- uppy-server: document docker image setup for uppy-server (@ifedapoolarewaju)
+- url: Add support for drag-dropping urls, links or images from webpages (#836 / @arturi)
+- webcam: swap record/stop button icons, fixes #859 (#fdcca95 / @arturi)
+- xhrupload: fix bytesUploaded and bytesTotal for bundled progress (#864 / @arturi)
+- xhrupload: fix retry/timer issues, add timer.done() to `cancel-all` events; disable progress throttling in Core; Ignore progress events in timeout tracker after upload was aborted (#864 / @goto-bus-stop, @arturi)
 
 ## 0.24.4
 

+ 11 - 3
README.md

@@ -15,6 +15,8 @@ Uppy is a sleek, modular JavaScript file uploader that integrates seamlessly wit
 
 Uppy is being developed by the folks at [Transloadit](https://transloadit.com), a versatile file encoding service.
 
+⚠️**ATTENTION** ☢️Uppy [is transitioning](https://github.com/transloadit/uppy/issues/862) into a [Lerna repo](https://lernajs.io/) this/next week, please wait with new PRs to avoid conflicts 💛
+
 ## Example
 
 <img width="700" alt="Uppy UI Demo: modal dialog with a few selected files and an upload button" src="https://github.com/transloadit/uppy/raw/master/uppy-screenshot.jpg">
@@ -63,7 +65,7 @@ $ npm install uppy --save
 
 We recommend installing from npm and then using a module bundler such as [Webpack](http://webpack.github.io/), [Browserify](http://browserify.org/) or [Rollup.js](http://rollupjs.org/).
 
-Add CSS [uppy.min.css](https://transloadit.edgly.net/releases/uppy/v0.24.4/dist/uppy.min.css), either to `<head>` of your HTML page or include in JS, if your bundler of choice supports it — transforms and plugins are available for Browserify and Webpack.
+Add CSS [uppy.min.css](https://transloadit.edgly.net/releases/uppy/v0.25.5/dist/uppy.min.css), either to `<head>` of your HTML page or include in JS, if your bundler of choice supports it — transforms and plugins are available for Browserify and Webpack.
 
 Alternatively, you can also use a pre-built bundle from Transloadit's CDN: Edgly. In that case `Uppy` will attach itself to the global `window.Uppy` object.
 
@@ -72,12 +74,12 @@ Alternatively, you can also use a pre-built bundle from Transloadit's CDN: Edgly
 1\. Add a script to the bottom of `<body>`:
 
 ``` html
-<script src="https://transloadit.edgly.net/releases/uppy/v0.24.4/dist/uppy.min.js"></script>
+<script src="https://transloadit.edgly.net/releases/uppy/v0.25.5/dist/uppy.min.js"></script>
 ```
 
 2\. Add CSS to `<head>`:
 ``` html
-<link href="https://transloadit.edgly.net/releases/uppy/v0.24.4/dist/uppy.min.css" rel="stylesheet">
+<link href="https://transloadit.edgly.net/releases/uppy/v0.25.5/dist/uppy.min.css" rel="stylesheet">
 ```
 
 3\. Initialize:
@@ -170,6 +172,12 @@ And you’ll need [`uppy-server`](https://github.com/transloadit/uppy-server) if
 
  - Contributor’s guide in [`website/src/docs/contributing.md`](website/src/docs/contributing.md)
  - Changelog to track our release progress (we aim to roll out a release every month): [`CHANGELOG.md`](CHANGELOG.md)
+ 
+## Used by
+ 
+Uppy is used by: [Photobox](http://photobox.com), [Law Insider](https://lawinsider.com), [Cool Tabs](https://cool-tabs.com), [Soundoff](https://soundoff.io), [Scrumi](https://www.scrumi.io/) and others.
+
+Use Uppy in your project? [Let us know](https://github.com/transloadit/uppy/issues/769)!
 
 <!--contributors-->
 ## Contributors

+ 19 - 3
bin/endtoend-build

@@ -2,6 +2,22 @@
 
 echo "Preparing for end to end test: copying static HTML and CSS, building JS"
 rm -rf ./test/endtoend/dist && mkdir ./test/endtoend/dist
-cp ./dist/uppy.min.css ./test/endtoend/dist 
-cp ./test/endtoend/src/index.html ./test/endtoend/dist 
-browserify ./test/endtoend/src/main.js -o ./test/endtoend/dist/bundle.js -t babelify
+rm -rf ./test/endtoend/node_modules
+
+npm run prepublishOnly
+# archive the uppy package
+echo "Creating archive for Uppy package"
+npm pack
+
+UPPY_VERSION=$(node -e 'console.log(require("./package.json").version)')
+# install from the archived uppy package
+echo "Installing Uppy from archived file uppy-${UPPY_VERSION}.tgz"
+npm install --prefix ./test/endtoend uppy-${UPPY_VERSION}.tgz
+
+# removing package-lock.json because we do not need it.
+rm ./test/endtoend/package-lock.json
+
+cp ./test/endtoend/node_modules/uppy/dist/uppy.min.css ./test/endtoend/dist
+cp ./test/endtoend/src/index.html ./test/endtoend/dist
+browserify ./test/endtoend/src/main.js -o ./test/endtoend/dist/bundle.js -t babelify
+rm -rf ./test/endtoend/node_modules

+ 2 - 1
bin/upload-to-cdn.sh

@@ -8,7 +8,7 @@
 #  - Checks if a tag is being built (on Travis - otherwise opts to continue execution regardless)
 #  - Installs AWS CLI if needed
 #  - Assumed a fully built uppy is in root dir (unless a specific tag was specified, then it's fetched from npm)
-#  - Runs npm pack, and stores files to e.g. https://transloadit.edgly.net/releases/uppy/v0.24.4/dist/uppy.css
+#  - Runs npm pack, and stores files to e.g. https://transloadit.edgly.net/releases/uppy/v0.25.5/dist/uppy.css
 #  - Uses local package by default, if [version] argument was specified, takes package from npm
 #
 # Run as:
@@ -98,6 +98,7 @@ pushd "${__root}" > /dev/null 2>&1
       --region="us-east-1" \
       --exclude 'website/*' \
       --exclude 'node_modules/*' \
+      --exclude 'test/*/node_modules/*' \
       --exclude 'examples/*/node_modules/*' \
     ./ "s3://crates.edgly.net/756b8efaed084669b02cb99d4540d81f/default/releases/uppy/v${version}"
     echo "Saved https://transloadit.edgly.net/releases/uppy/v${version}/"

+ 2 - 2
examples/cdn-example/index.html

@@ -4,11 +4,11 @@
     <title></title>
     <meta charset="UTF-8">
     <meta name="viewport" content="width=device-width, initial-scale=1">
-    <link href="https://transloadit.edgly.net/releases/uppy/v0.24.4/dist/uppy.min.css" rel="stylesheet">
+    <link href="https://transloadit.edgly.net/releases/uppy/v0.25.5/dist/uppy.min.css" rel="stylesheet">
   </head>
   <body>
     <button id="uppyModalOpener">Open Modal</button>
-    <script src="https://transloadit.edgly.net/releases/uppy/v0.24.4/dist/uppy.min.js"></script>
+    <script src="https://transloadit.edgly.net/releases/uppy/v0.25.5/dist/uppy.min.js"></script>
     <script>
       const uppy = Uppy.Core({debug: true, autoProceed: false})
         .use(Uppy.Dashboard, { trigger: '#uppyModalOpener' })

+ 3 - 2
examples/react-example/App.js

@@ -18,11 +18,11 @@ module.exports = class App extends React.Component {
   }
 
   componentWillMount () {
-    this.uppy = new Uppy({ autoProceed: false })
+    this.uppy = new Uppy({ id: 'uppy1', autoProceed: true, debug: true })
       .use(Tus, { endpoint: 'https://master.tus.io/files/' })
       .use(GoogleDrive, { host: 'https://server.uppy.io' })
 
-    this.uppy2 = new Uppy({ autoProceed: false })
+    this.uppy2 = new Uppy({ id: 'uppy2', autoProceed: false, debug: true })
       .use(Tus, { endpoint: 'https://master.tus.io/files/' })
   }
 
@@ -93,6 +93,7 @@ module.exports = class App extends React.Component {
         <h2>Progress Bar</h2>
         <ProgressBar
           uppy={this.uppy}
+          hideAfterFinish={false}
         />
       </div>
     )

+ 2 - 2
examples/uppy-with-server/client/index.html

@@ -4,11 +4,11 @@
     <title></title>
     <meta charset="UTF-8">
     <meta name="viewport" content="width=device-width, initial-scale=1">
-    <link href="https://transloadit.edgly.net/releases/uppy/v0.24.4/dist/uppy.min.css" rel="stylesheet">
+    <link href="https://transloadit.edgly.net/releases/uppy/v0.25.5/dist/uppy.min.css" rel="stylesheet">
   </head>
   <body>
     <button id="uppyModalOpener">Open Modal</button>
-    <script src="https://transloadit.edgly.net/releases/uppy/v0.24.4/dist/uppy.min.js"></script>
+    <script src="https://transloadit.edgly.net/releases/uppy/v0.25.5/dist/uppy.min.js"></script>
     <script>
       const uppy = Uppy.Core({debug: true, autoProceed: false})
         .use(Uppy.Dashboard, { trigger: '#uppyModalOpener' })

+ 542 - 1
package-lock.json

@@ -1,6 +1,6 @@
 {
   "name": "uppy",
-  "version": "0.24.4",
+  "version": "0.25.5",
   "lockfileVersion": 1,
   "requires": true,
   "dependencies": {
@@ -101,6 +101,12 @@
       "integrity": "sha1-yRNQTT3CgQr61VW1ma6uwsxMZ2g=",
       "dev": true
     },
+    "@types/node": {
+      "version": "10.1.3",
+      "resolved": "https://registry.npmjs.org/@types/node/-/node-10.1.3.tgz",
+      "integrity": "sha512-GiCx7dRvta0hbxXoJFAUxz+CKX6bZSCKjM5slq2vPp/5zwK01T4ibYZkGr6EN4F2QmxDQR76/ZHg6q+7iFWCWw==",
+      "dev": true
+    },
     "JSONStream": {
       "version": "1.3.1",
       "resolved": "https://registry.npmjs.org/JSONStream/-/JSONStream-1.3.1.tgz",
@@ -2182,6 +2188,12 @@
         }
       }
     },
+    "boolbase": {
+      "version": "1.0.0",
+      "resolved": "https://registry.npmjs.org/boolbase/-/boolbase-1.0.0.tgz",
+      "integrity": "sha1-aN/1++YMUes3cl6p4+0xDcwed24=",
+      "dev": true
+    },
     "boom": {
       "version": "2.10.1",
       "resolved": "https://registry.npmjs.org/boom/-/boom-2.10.1.tgz",
@@ -3249,6 +3261,20 @@
       "integrity": "sha1-7tY7usnqSaDiagljFAWLA7CN1is=",
       "dev": true
     },
+    "cheerio": {
+      "version": "1.0.0-rc.2",
+      "resolved": "https://registry.npmjs.org/cheerio/-/cheerio-1.0.0-rc.2.tgz",
+      "integrity": "sha1-S59TqBsn5NXawxwP/Qz6A8xoMNs=",
+      "dev": true,
+      "requires": {
+        "css-select": "~1.2.0",
+        "dom-serializer": "~0.1.0",
+        "entities": "~1.1.1",
+        "htmlparser2": "^3.9.1",
+        "lodash": "^4.15.0",
+        "parse5": "^3.0.1"
+      }
+    },
     "chokidar": {
       "version": "1.7.0",
       "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-1.7.0.tgz",
@@ -3953,12 +3979,30 @@
         "css": "^2.0.0"
       }
     },
+    "css-select": {
+      "version": "1.2.0",
+      "resolved": "https://registry.npmjs.org/css-select/-/css-select-1.2.0.tgz",
+      "integrity": "sha1-KzoRBTnFNV8c2NMUYj6HCxIeyFg=",
+      "dev": true,
+      "requires": {
+        "boolbase": "~1.0.0",
+        "css-what": "2.1",
+        "domutils": "1.5.1",
+        "nth-check": "~1.0.1"
+      }
+    },
     "css-value": {
       "version": "0.0.1",
       "resolved": "https://registry.npmjs.org/css-value/-/css-value-0.0.1.tgz",
       "integrity": "sha1-Xv1sLupeof1rasV+wEJ7GEUkJOo=",
       "dev": true
     },
+    "css-what": {
+      "version": "2.1.0",
+      "resolved": "https://registry.npmjs.org/css-what/-/css-what-2.1.0.tgz",
+      "integrity": "sha1-lGfQMsOM+u+58teVASUwYvh/ob0=",
+      "dev": true
+    },
     "cssnano": {
       "version": "3.10.0",
       "resolved": "https://registry.npmjs.org/cssnano/-/cssnano-3.10.0.tgz",
@@ -4454,6 +4498,12 @@
         }
       }
     },
+    "discontinuous-range": {
+      "version": "1.0.0",
+      "resolved": "https://registry.npmjs.org/discontinuous-range/-/discontinuous-range-1.0.0.tgz",
+      "integrity": "sha1-44Mx8IRLukm5qctxx3FYWqsbxlo=",
+      "dev": true
+    },
     "dns-prefetch-control": {
       "version": "0.1.0",
       "resolved": "https://registry.npmjs.org/dns-prefetch-control/-/dns-prefetch-control-0.1.0.tgz",
@@ -4478,12 +4528,36 @@
         }
       }
     },
+    "dom-serializer": {
+      "version": "0.1.0",
+      "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-0.1.0.tgz",
+      "integrity": "sha1-BzxpdUbOB4DOI75KKOKT5AvDDII=",
+      "dev": true,
+      "requires": {
+        "domelementtype": "~1.1.1",
+        "entities": "~1.1.1"
+      },
+      "dependencies": {
+        "domelementtype": {
+          "version": "1.1.3",
+          "resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-1.1.3.tgz",
+          "integrity": "sha1-vSh3PiZCiBrsUVRJJCmcXNgiGFs=",
+          "dev": true
+        }
+      }
+    },
     "domain-browser": {
       "version": "1.1.7",
       "resolved": "https://registry.npmjs.org/domain-browser/-/domain-browser-1.1.7.tgz",
       "integrity": "sha1-hnqksJP6oF8d4IwG9NeyH9+GmLw=",
       "dev": true
     },
+    "domelementtype": {
+      "version": "1.3.0",
+      "resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-1.3.0.tgz",
+      "integrity": "sha1-sXrtguirWeUt2cGbF1bg/BhyBMI=",
+      "dev": true
+    },
     "domexception": {
       "version": "1.0.1",
       "resolved": "https://registry.npmjs.org/domexception/-/domexception-1.0.1.tgz",
@@ -4493,6 +4567,25 @@
         "webidl-conversions": "^4.0.2"
       }
     },
+    "domhandler": {
+      "version": "2.4.2",
+      "resolved": "https://registry.npmjs.org/domhandler/-/domhandler-2.4.2.tgz",
+      "integrity": "sha512-JiK04h0Ht5u/80fdLMCEmV4zkNh2BcoMFBmZ/91WtYZ8qVXSKjiw7fXMgFPnHcSZgOo3XdinHvmnDUeMf5R4wA==",
+      "dev": true,
+      "requires": {
+        "domelementtype": "1"
+      }
+    },
+    "domutils": {
+      "version": "1.5.1",
+      "resolved": "https://registry.npmjs.org/domutils/-/domutils-1.5.1.tgz",
+      "integrity": "sha1-3NhIiib1Y9YQeeSMn3t+Mjc2gs8=",
+      "dev": true,
+      "requires": {
+        "dom-serializer": "0",
+        "domelementtype": "1"
+      }
+    },
     "dont-sniff-mimetype": {
       "version": "1.0.0",
       "resolved": "https://registry.npmjs.org/dont-sniff-mimetype/-/dont-sniff-mimetype-1.0.0.tgz",
@@ -4742,6 +4835,12 @@
         "has-binary2": "~1.0.2"
       }
     },
+    "entities": {
+      "version": "1.1.1",
+      "resolved": "https://registry.npmjs.org/entities/-/entities-1.1.1.tgz",
+      "integrity": "sha1-blwtClYhtdra7O+AuQ7ftc13cvA=",
+      "dev": true
+    },
     "envify": {
       "version": "4.1.0",
       "resolved": "https://registry.npmjs.org/envify/-/envify-4.1.0.tgz",
@@ -4760,6 +4859,76 @@
         }
       }
     },
+    "enzyme": {
+      "version": "3.3.0",
+      "resolved": "https://registry.npmjs.org/enzyme/-/enzyme-3.3.0.tgz",
+      "integrity": "sha512-l8csyPyLmtxskTz6pX9W8eDOyH1ckEtDttXk/vlFWCjv00SkjTjtoUrogqp4yEvMyneU9dUJoOLnqFoiHb8IHA==",
+      "dev": true,
+      "requires": {
+        "cheerio": "^1.0.0-rc.2",
+        "function.prototype.name": "^1.0.3",
+        "has": "^1.0.1",
+        "is-boolean-object": "^1.0.0",
+        "is-callable": "^1.1.3",
+        "is-number-object": "^1.0.3",
+        "is-string": "^1.0.4",
+        "is-subset": "^0.1.1",
+        "lodash": "^4.17.4",
+        "object-inspect": "^1.5.0",
+        "object-is": "^1.0.1",
+        "object.assign": "^4.1.0",
+        "object.entries": "^1.0.4",
+        "object.values": "^1.0.4",
+        "raf": "^3.4.0",
+        "rst-selector-parser": "^2.2.3"
+      },
+      "dependencies": {
+        "function-bind": {
+          "version": "1.1.1",
+          "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.1.tgz",
+          "integrity": "sha512-yIovAzMX49sF8Yl58fSCWJ5svSLuaibPxXQJFLmBObTuCr0Mf1KiPopGM9NiFjiYBCbfaa2Fh6breQ6ANVTI0A==",
+          "dev": true
+        },
+        "object.assign": {
+          "version": "4.1.0",
+          "resolved": "https://registry.npmjs.org/object.assign/-/object.assign-4.1.0.tgz",
+          "integrity": "sha512-exHJeq6kBKj58mqGyTQ9DFvrZC/eR6OwxzoM9YRoGBqrXYonaFyGiFMuc9VZrXf7DarreEwMpurG3dd+CNyW5w==",
+          "dev": true,
+          "requires": {
+            "define-properties": "^1.1.2",
+            "function-bind": "^1.1.1",
+            "has-symbols": "^1.0.0",
+            "object-keys": "^1.0.11"
+          }
+        }
+      }
+    },
+    "enzyme-adapter-react-16": {
+      "version": "1.1.1",
+      "resolved": "https://registry.npmjs.org/enzyme-adapter-react-16/-/enzyme-adapter-react-16-1.1.1.tgz",
+      "integrity": "sha512-kC8pAtU2Jk3OJ0EG8Y2813dg9Ol0TXi7UNxHzHiWs30Jo/hj7alc//G1YpKUsPP1oKl9X+Lkx+WlGJpPYA+nvw==",
+      "dev": true,
+      "requires": {
+        "enzyme-adapter-utils": "^1.3.0",
+        "lodash": "^4.17.4",
+        "object.assign": "^4.0.4",
+        "object.values": "^1.0.4",
+        "prop-types": "^15.6.0",
+        "react-reconciler": "^0.7.0",
+        "react-test-renderer": "^16.0.0-0"
+      }
+    },
+    "enzyme-adapter-utils": {
+      "version": "1.3.0",
+      "resolved": "https://registry.npmjs.org/enzyme-adapter-utils/-/enzyme-adapter-utils-1.3.0.tgz",
+      "integrity": "sha512-vVXSt6uDv230DIv+ebCG66T1Pm36Kv+m74L1TrF4kaE7e1V7Q/LcxO0QRkajk5cA6R3uu9wJf5h13wOTezTbjA==",
+      "dev": true,
+      "requires": {
+        "lodash": "^4.17.4",
+        "object.assign": "^4.0.4",
+        "prop-types": "^15.6.0"
+      }
+    },
     "error-ex": {
       "version": "1.3.1",
       "resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.1.tgz",
@@ -5433,6 +5602,65 @@
         }
       }
     },
+    "eslint-plugin-compat": {
+      "version": "2.3.0",
+      "resolved": "https://registry.npmjs.org/eslint-plugin-compat/-/eslint-plugin-compat-2.3.0.tgz",
+      "integrity": "sha1-GNMVzGgymES9VNzBhL5T04pvkcg=",
+      "dev": true,
+      "requires": {
+        "babel-runtime": "^6.26.0",
+        "browserslist": "^3.2.7",
+        "caniuse-db": "^1.0.30000843",
+        "mdn-browser-compat-data": "^0.0.20",
+        "requireindex": "^1.1.0"
+      },
+      "dependencies": {
+        "babel-runtime": {
+          "version": "6.26.0",
+          "resolved": "https://registry.npmjs.org/babel-runtime/-/babel-runtime-6.26.0.tgz",
+          "integrity": "sha1-llxwWGaOgrVde/4E/yM3vItWR/4=",
+          "dev": true,
+          "requires": {
+            "core-js": "^2.4.0",
+            "regenerator-runtime": "^0.11.0"
+          }
+        },
+        "browserslist": {
+          "version": "3.2.8",
+          "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-3.2.8.tgz",
+          "integrity": "sha512-WHVocJYavUwVgVViC0ORikPHQquXwVh939TaelZ4WDqpWgTX/FsGhl/+P4qBUAGcRvtOgDgC+xftNWWp2RUTAQ==",
+          "dev": true,
+          "requires": {
+            "caniuse-lite": "^1.0.30000844",
+            "electron-to-chromium": "^1.3.47"
+          }
+        },
+        "caniuse-db": {
+          "version": "1.0.30000850",
+          "resolved": "https://registry.npmjs.org/caniuse-db/-/caniuse-db-1.0.30000850.tgz",
+          "integrity": "sha1-llyBZkFXbQhwm+4SJWoFUBZLldU=",
+          "dev": true
+        },
+        "caniuse-lite": {
+          "version": "1.0.30000850",
+          "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30000850.tgz",
+          "integrity": "sha512-iHK48UR/InydhpPAzgSmsJXRAR925T0kwJhZ1wk0xRatpGMvi2f06LABg6HXfV4WW4P2wChzlcFa/TEmbTyXQA==",
+          "dev": true
+        },
+        "electron-to-chromium": {
+          "version": "1.3.48",
+          "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.3.48.tgz",
+          "integrity": "sha1-07DYWTgUBE4JLs4hCPw6ya6kuQA=",
+          "dev": true
+        },
+        "regenerator-runtime": {
+          "version": "0.11.1",
+          "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.11.1.tgz",
+          "integrity": "sha512-MguG95oij0fC3QV3URf4V2SDYGJhJnJGqvIIgdECeODCT98wSWDAJ94SSuVpYQUoTcGUIL6L4yNB7j1DFFHSBg==",
+          "dev": true
+        }
+      }
+    },
     "eslint-plugin-import": {
       "version": "2.8.0",
       "resolved": "https://registry.npmjs.org/eslint-plugin-import/-/eslint-plugin-import-2.8.0.tgz",
@@ -7498,6 +7726,25 @@
       "integrity": "sha1-FhdnFMgBeY5Ojyz391KUZ7tKV3E=",
       "dev": true
     },
+    "function.prototype.name": {
+      "version": "1.1.0",
+      "resolved": "https://registry.npmjs.org/function.prototype.name/-/function.prototype.name-1.1.0.tgz",
+      "integrity": "sha512-Bs0VRrTz4ghD8pTmbJQD1mZ8A/mN0ur/jGz+A6FBxPDUPkm1tNfF6bhTYPA7i7aF4lZJVr+OXTNNrnnIl58Wfg==",
+      "dev": true,
+      "requires": {
+        "define-properties": "^1.1.2",
+        "function-bind": "^1.1.1",
+        "is-callable": "^1.1.3"
+      },
+      "dependencies": {
+        "function-bind": {
+          "version": "1.1.1",
+          "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.1.tgz",
+          "integrity": "sha512-yIovAzMX49sF8Yl58fSCWJ5svSLuaibPxXQJFLmBObTuCr0Mf1KiPopGM9NiFjiYBCbfaa2Fh6breQ6ANVTI0A==",
+          "dev": true
+        }
+      }
+    },
     "functional-red-black-tree": {
       "version": "1.0.1",
       "resolved": "https://registry.npmjs.org/functional-red-black-tree/-/functional-red-black-tree-1.0.1.tgz",
@@ -7917,6 +8164,12 @@
       "resolved": "https://registry.npmjs.org/has-symbol-support-x/-/has-symbol-support-x-1.4.1.tgz",
       "integrity": "sha512-JkaetveU7hFbqnAC1EV1sF4rlojU2D4Usc5CmS69l6NfmPDnpnFUegzFg33eDkkpNCxZ0mQp65HwUDrNFS/8MA=="
     },
+    "has-symbols": {
+      "version": "1.0.0",
+      "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.0.0.tgz",
+      "integrity": "sha1-uhqPGvKg/DllD1yFA2dwQSIGO0Q=",
+      "dev": true
+    },
     "has-to-string-tag-x": {
       "version": "1.4.1",
       "resolved": "https://registry.npmjs.org/has-to-string-tag-x/-/has-to-string-tag-x-1.4.1.tgz",
@@ -8110,6 +8363,20 @@
       "integrity": "sha1-OgPtwiFLyjtmQko+eVk0lQnLA1E=",
       "dev": true
     },
+    "htmlparser2": {
+      "version": "3.9.2",
+      "resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-3.9.2.tgz",
+      "integrity": "sha1-G9+HrMoPP55T+k/M6w9LTLsAszg=",
+      "dev": true,
+      "requires": {
+        "domelementtype": "^1.3.0",
+        "domhandler": "^2.3.0",
+        "domutils": "^1.5.1",
+        "entities": "^1.1.1",
+        "inherits": "^2.0.1",
+        "readable-stream": "^2.0.2"
+      }
+    },
     "http-errors": {
       "version": "1.5.1",
       "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-1.5.1.tgz",
@@ -8395,6 +8662,12 @@
         "binary-extensions": "^1.0.0"
       }
     },
+    "is-boolean-object": {
+      "version": "1.0.0",
+      "resolved": "https://registry.npmjs.org/is-boolean-object/-/is-boolean-object-1.0.0.tgz",
+      "integrity": "sha1-mPiygDBoQhmpXzdc+9iM40Bd/5M=",
+      "dev": true
+    },
     "is-buffer": {
       "version": "1.1.5",
       "resolved": "https://registry.npmjs.org/is-buffer/-/is-buffer-1.1.5.tgz",
@@ -8600,6 +8873,12 @@
         "lodash.isfinite": "^3.3.2"
       }
     },
+    "is-number-object": {
+      "version": "1.0.3",
+      "resolved": "https://registry.npmjs.org/is-number-object/-/is-number-object-1.0.3.tgz",
+      "integrity": "sha1-8mWrian0RQNO9q/xWo8AsA9VF5k=",
+      "dev": true
+    },
     "is-obj": {
       "version": "1.0.1",
       "resolved": "https://registry.npmjs.org/is-obj/-/is-obj-1.0.1.tgz",
@@ -8760,6 +9039,12 @@
       "resolved": "https://registry.npmjs.org/is-string/-/is-string-1.0.4.tgz",
       "integrity": "sha1-zDqbaYV9Yh6WNyWiTK7shzuCbmQ="
     },
+    "is-subset": {
+      "version": "0.1.1",
+      "resolved": "https://registry.npmjs.org/is-subset/-/is-subset-0.1.1.tgz",
+      "integrity": "sha1-ilkRfZMt4d4A8kX83TnOQ/HpOaY=",
+      "dev": true
+    },
     "is-svg": {
       "version": "2.1.0",
       "resolved": "https://registry.npmjs.org/is-svg/-/is-svg-2.1.0.tgz",
@@ -11353,6 +11638,12 @@
       "integrity": "sha1-9HGh2khr5g9quVXRcRVSPdHSVdU=",
       "dev": true
     },
+    "lodash.flattendeep": {
+      "version": "4.4.0",
+      "resolved": "https://registry.npmjs.org/lodash.flattendeep/-/lodash.flattendeep-4.4.0.tgz",
+      "integrity": "sha1-+wMJF/hqMTTlvJvsDWngAT3f7bI=",
+      "dev": true
+    },
     "lodash.includes": {
       "version": "4.3.0",
       "resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz",
@@ -11620,6 +11911,15 @@
       "resolved": "https://registry.npmjs.org/max-safe-integer/-/max-safe-integer-1.0.1.tgz",
       "integrity": "sha1-84BgvixWPYwC5tSK85Ei/YO29BA="
     },
+    "mdn-browser-compat-data": {
+      "version": "0.0.20",
+      "resolved": "https://registry.npmjs.org/mdn-browser-compat-data/-/mdn-browser-compat-data-0.0.20.tgz",
+      "integrity": "sha512-DImQhKtc7umi/LI0licM3GVnKTxYoYmFUKnMjomfIvW8dO4B6UeQLWYQhf1jDTfEV9WZGjFTz3DOfcsnZO6WdA==",
+      "dev": true,
+      "requires": {
+        "extend": "3.0.1"
+      }
+    },
     "media-typer": {
       "version": "0.3.0",
       "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz",
@@ -11994,6 +12294,26 @@
       "integrity": "sha1-Sr6/7tdUHywnrPspvbvRXI1bpPc=",
       "dev": true
     },
+    "nearley": {
+      "version": "2.13.0",
+      "resolved": "https://registry.npmjs.org/nearley/-/nearley-2.13.0.tgz",
+      "integrity": "sha512-ioYYogSaZhFlCpRizQgY3UT3G1qFXmHGY/5ozoFE3dMfiCRAeJfh+IPE3/eh9gCZvqLhPCWb4bLt7Bqzo+1mLQ==",
+      "dev": true,
+      "requires": {
+        "nomnom": "~1.6.2",
+        "railroad-diagrams": "^1.0.0",
+        "randexp": "0.4.6",
+        "semver": "^5.4.1"
+      },
+      "dependencies": {
+        "semver": {
+          "version": "5.5.0",
+          "resolved": "https://registry.npmjs.org/semver/-/semver-5.5.0.tgz",
+          "integrity": "sha512-4SJ3dm0WAwWy/NVeioZh5AntkdJoWKxHxcmyP622fOkgHa4z3R0TdBJICINyaSDE6uNwVc8gZr+ZinwZAH4xIA==",
+          "dev": true
+        }
+      }
+    },
     "negotiator": {
       "version": "0.6.1",
       "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.1.tgz",
@@ -12613,6 +12933,58 @@
       "integrity": "sha1-ICtIAhoMTL3i34DeFaF0Q8i0OYA=",
       "dev": true
     },
+    "nock": {
+      "version": "9.3.2",
+      "resolved": "https://registry.npmjs.org/nock/-/nock-9.3.2.tgz",
+      "integrity": "sha512-pulpsRVFneYGpgktmt99s10fFh10zSpYhydwkG28xLps/p19n39lBSq5kjb7UW2YOPyQtt7FLyXuP+xHyRRI0w==",
+      "dev": true,
+      "requires": {
+        "chai": "^4.1.2",
+        "debug": "^3.1.0",
+        "deep-equal": "^1.0.0",
+        "json-stringify-safe": "^5.0.1",
+        "lodash": "^4.17.5",
+        "mkdirp": "^0.5.0",
+        "propagate": "^1.0.0",
+        "qs": "^6.5.1",
+        "semver": "^5.5.0"
+      },
+      "dependencies": {
+        "debug": {
+          "version": "3.1.0",
+          "resolved": "https://registry.npmjs.org/debug/-/debug-3.1.0.tgz",
+          "integrity": "sha512-OX8XqP7/1a9cqkxYw2yXss15f26NKWBpDXQd0/uK/KPqdQhxbPa994hnzjcE2VqQpDslf55723cKPUOGSmMY3g==",
+          "dev": true,
+          "requires": {
+            "ms": "2.0.0"
+          }
+        },
+        "lodash": {
+          "version": "4.17.10",
+          "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.10.tgz",
+          "integrity": "sha512-UejweD1pDoXu+AD825lWwp4ZGtSwgnpZxb3JDViD7StjQz+Nb/6l093lx4OQ0foGWNRoc19mWy7BzL+UAK2iVg==",
+          "dev": true
+        },
+        "ms": {
+          "version": "2.0.0",
+          "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",
+          "integrity": "sha1-VgiurfwAvmwpAd9fmGF4jeDVl8g=",
+          "dev": true
+        },
+        "qs": {
+          "version": "6.5.2",
+          "resolved": "https://registry.npmjs.org/qs/-/qs-6.5.2.tgz",
+          "integrity": "sha512-N5ZAX4/LxJmF+7wN74pUD6qAh9/wnvdQcjq9TZjevvXzSUo7bfmw91saqMjzGS2xq91/odN2dW/WOl7qQHNDGA==",
+          "dev": true
+        },
+        "semver": {
+          "version": "5.5.0",
+          "resolved": "https://registry.npmjs.org/semver/-/semver-5.5.0.tgz",
+          "integrity": "sha512-4SJ3dm0WAwWy/NVeioZh5AntkdJoWKxHxcmyP622fOkgHa4z3R0TdBJICINyaSDE6uNwVc8gZr+ZinwZAH4xIA==",
+          "dev": true
+        }
+      }
+    },
     "node-fetch": {
       "version": "1.7.1",
       "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-1.7.1.tgz",
@@ -12767,6 +13139,24 @@
       "integrity": "sha1-WuVUHQJGRdMqWPzdyc7s6nrjrC8=",
       "dev": true
     },
+    "nomnom": {
+      "version": "1.6.2",
+      "resolved": "https://registry.npmjs.org/nomnom/-/nomnom-1.6.2.tgz",
+      "integrity": "sha1-hKZqJgF0QI/Ft3oY+IjszET7aXE=",
+      "dev": true,
+      "requires": {
+        "colors": "0.5.x",
+        "underscore": "~1.4.4"
+      },
+      "dependencies": {
+        "colors": {
+          "version": "0.5.1",
+          "resolved": "https://registry.npmjs.org/colors/-/colors-0.5.1.tgz",
+          "integrity": "sha1-fQAj6usVTo7p/Oddy5I9DtFmd3Q=",
+          "dev": true
+        }
+      }
+    },
     "nopt": {
       "version": "3.0.6",
       "resolved": "https://registry.npmjs.org/nopt/-/nopt-3.0.6.tgz",
@@ -12971,6 +13361,15 @@
         "set-blocking": "~2.0.0"
       }
     },
+    "nth-check": {
+      "version": "1.0.1",
+      "resolved": "https://registry.npmjs.org/nth-check/-/nth-check-1.0.1.tgz",
+      "integrity": "sha1-mSms32KPwsQQmN6rgqxYDPFJquQ=",
+      "dev": true,
+      "requires": {
+        "boolbase": "~1.0.0"
+      }
+    },
     "num2fraction": {
       "version": "1.2.2",
       "resolved": "https://registry.npmjs.org/num2fraction/-/num2fraction-1.2.2.tgz",
@@ -13016,6 +13415,18 @@
         "to-property-key-x": "^2.0.1"
       }
     },
+    "object-inspect": {
+      "version": "1.6.0",
+      "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.6.0.tgz",
+      "integrity": "sha512-GJzfBZ6DgDAmnuaM3104jR4s1Myxr3Y3zfIyN4z3UdqN69oSRacNK8UhnobDdC+7J2AHCjGwxQubNJfE70SXXQ==",
+      "dev": true
+    },
+    "object-is": {
+      "version": "1.0.1",
+      "resolved": "https://registry.npmjs.org/object-is/-/object-is-1.0.1.tgz",
+      "integrity": "sha1-CqYOyZiaCz7Xlc9NBvYs8a1lObY=",
+      "dev": true
+    },
     "object-keys": {
       "version": "1.0.11",
       "resolved": "https://registry.npmjs.org/object-keys/-/object-keys-1.0.11.tgz",
@@ -13350,6 +13761,15 @@
         "error-ex": "^1.2.0"
       }
     },
+    "parse5": {
+      "version": "3.0.3",
+      "resolved": "https://registry.npmjs.org/parse5/-/parse5-3.0.3.tgz",
+      "integrity": "sha512-rgO9Zg5LLLkfJF9E6CCmXlSE4UVceloys8JrFqCcHloC3usd/kJCyPDwH2SOlzix2j3xaP9sUX3e8+kvkuleAA==",
+      "dev": true,
+      "requires": {
+        "@types/node": "*"
+      }
+    },
     "parseqs": {
       "version": "0.0.5",
       "resolved": "https://registry.npmjs.org/parseqs/-/parseqs-0.0.5.tgz",
@@ -14368,6 +14788,12 @@
         "object-assign": "^4.1.1"
       }
     },
+    "propagate": {
+      "version": "1.0.0",
+      "resolved": "https://registry.npmjs.org/propagate/-/propagate-1.0.0.tgz",
+      "integrity": "sha1-AMLa7t2iDofjeCs0Stuhzd1q1wk=",
+      "dev": true
+    },
     "property-is-enumerable-x": {
       "version": "1.1.0",
       "resolved": "https://registry.npmjs.org/property-is-enumerable-x/-/property-is-enumerable-x-1.1.0.tgz",
@@ -14477,6 +14903,39 @@
       "integrity": "sha1-EIOSF/bBNiuJGUBE0psjP9fzLwE=",
       "dev": true
     },
+    "raf": {
+      "version": "3.4.0",
+      "resolved": "https://registry.npmjs.org/raf/-/raf-3.4.0.tgz",
+      "integrity": "sha512-pDP/NMRAXoTfrhCfyfSEwJAKLaxBU9eApMeBPB1TkDouZmvPerIClV8lTAd+uF8ZiTaVl69e1FCxQrAd/VTjGw==",
+      "dev": true,
+      "requires": {
+        "performance-now": "^2.1.0"
+      },
+      "dependencies": {
+        "performance-now": {
+          "version": "2.1.0",
+          "resolved": "https://registry.npmjs.org/performance-now/-/performance-now-2.1.0.tgz",
+          "integrity": "sha1-Ywn04OX6kT7BxpMHrjZLSzd8nns=",
+          "dev": true
+        }
+      }
+    },
+    "railroad-diagrams": {
+      "version": "1.0.0",
+      "resolved": "https://registry.npmjs.org/railroad-diagrams/-/railroad-diagrams-1.0.0.tgz",
+      "integrity": "sha1-635iZ1SN3t+4mcG5Dlc3RVnN234=",
+      "dev": true
+    },
+    "randexp": {
+      "version": "0.4.6",
+      "resolved": "https://registry.npmjs.org/randexp/-/randexp-0.4.6.tgz",
+      "integrity": "sha512-80WNmd9DA0tmZrw9qQa62GPPWfuXJknrmVmLcxvq4uZBdYqb1wYoKTmnlGUchvVWe0XiLupYkBoXVOxz3C8DYQ==",
+      "dev": true,
+      "requires": {
+        "discontinuous-range": "1.0.0",
+        "ret": "~0.1.10"
+      }
+    },
     "random-bytes": {
       "version": "1.0.0",
       "resolved": "https://registry.npmjs.org/random-bytes/-/random-bytes-1.0.0.tgz",
@@ -14617,6 +15076,60 @@
         }
       }
     },
+    "react": {
+      "version": "16.4.0",
+      "resolved": "https://registry.npmjs.org/react/-/react-16.4.0.tgz",
+      "integrity": "sha512-K0UrkLXSAekf5nJu89obKUM7o2vc6MMN9LYoKnCa+c+8MJRAT120xzPLENcWSRc7GYKIg0LlgJRDorrufdglQQ==",
+      "dev": true,
+      "requires": {
+        "fbjs": "^0.8.16",
+        "loose-envify": "^1.1.0",
+        "object-assign": "^4.1.1",
+        "prop-types": "^15.6.0"
+      }
+    },
+    "react-dom": {
+      "version": "16.4.0",
+      "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-16.4.0.tgz",
+      "integrity": "sha512-bbLd+HYpBEnYoNyxDe9XpSG2t9wypMohwQPvKw8Hov3nF7SJiJIgK56b46zHpBUpHb06a1iEuw7G3rbrsnNL6w==",
+      "dev": true,
+      "requires": {
+        "fbjs": "^0.8.16",
+        "loose-envify": "^1.1.0",
+        "object-assign": "^4.1.1",
+        "prop-types": "^15.6.0"
+      }
+    },
+    "react-is": {
+      "version": "16.4.0",
+      "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.4.0.tgz",
+      "integrity": "sha512-8ADZg/mBw+t2Fbr5Hm1K64v8q8Q6E+DprV5wQ5A8PSLW6XP0XJFMdUskVEW8efQ5oUgWHn8EYdHEPAMF0Co6hA==",
+      "dev": true
+    },
+    "react-reconciler": {
+      "version": "0.7.0",
+      "resolved": "https://registry.npmjs.org/react-reconciler/-/react-reconciler-0.7.0.tgz",
+      "integrity": "sha512-50JwZ3yNyMS8fchN+jjWEJOH3Oze7UmhxeoJLn2j6f3NjpfCRbcmih83XTWmzqtar/ivd5f7tvQhvvhism2fgg==",
+      "dev": true,
+      "requires": {
+        "fbjs": "^0.8.16",
+        "loose-envify": "^1.1.0",
+        "object-assign": "^4.1.1",
+        "prop-types": "^15.6.0"
+      }
+    },
+    "react-test-renderer": {
+      "version": "16.4.0",
+      "resolved": "https://registry.npmjs.org/react-test-renderer/-/react-test-renderer-16.4.0.tgz",
+      "integrity": "sha512-Seh1t9xFY6TKiV/hRlPzUkqX1xHOiKIMsctfU0cggo1ajsLjoIJFL520LlrxV+4/VIj+clrCeH6s/aVv/vTStg==",
+      "dev": true,
+      "requires": {
+        "fbjs": "^0.8.16",
+        "object-assign": "^4.1.1",
+        "prop-types": "^15.6.0",
+        "react-is": "^16.4.0"
+      }
+    },
     "read-all-stream": {
       "version": "3.1.0",
       "resolved": "https://registry.npmjs.org/read-all-stream/-/read-all-stream-3.1.0.tgz",
@@ -15096,6 +15609,12 @@
         "resolve-from": "^1.0.0"
       }
     },
+    "requireindex": {
+      "version": "1.2.0",
+      "resolved": "https://registry.npmjs.org/requireindex/-/requireindex-1.2.0.tgz",
+      "integrity": "sha512-L9jEkOi3ASd9PYit2cwRfyppc9NoABujTP8/5gFcbERmo5jUoAKovIC3fsF17pkTnGsrByysqX+Kxd2OTNI1ww==",
+      "dev": true
+    },
     "requires-port": {
       "version": "1.0.0",
       "resolved": "https://registry.npmjs.org/requires-port/-/requires-port-1.0.0.tgz",
@@ -15158,6 +15677,12 @@
         "onetime": "^1.0.0"
       }
     },
+    "ret": {
+      "version": "0.1.15",
+      "resolved": "https://registry.npmjs.org/ret/-/ret-0.1.15.tgz",
+      "integrity": "sha512-TTlYpa+OL+vMMNG24xSlQGEJ3B/RzEfUlLct7b5G/ytav+wPrplCpVMFuwzXbkecJrb6IYo1iFb0S9v37754mg==",
+      "dev": true
+    },
     "rgb2hex": {
       "version": "0.1.0",
       "resolved": "https://registry.npmjs.org/rgb2hex/-/rgb2hex-0.1.0.tgz",
@@ -15205,6 +15730,16 @@
       "integrity": "sha1-IPbld0Ih5RkZdjndmsBLe63sls0=",
       "dev": true
     },
+    "rst-selector-parser": {
+      "version": "2.2.3",
+      "resolved": "https://registry.npmjs.org/rst-selector-parser/-/rst-selector-parser-2.2.3.tgz",
+      "integrity": "sha1-gbIw6i/MYGbInjRy3nlChdmwPZE=",
+      "dev": true,
+      "requires": {
+        "lodash.flattendeep": "^4.4.0",
+        "nearley": "^2.7.10"
+      }
+    },
     "run-async": {
       "version": "0.1.0",
       "resolved": "https://registry.npmjs.org/run-async/-/run-async-0.1.0.tgz",
@@ -16889,6 +17424,12 @@
         }
       }
     },
+    "underscore": {
+      "version": "1.4.4",
+      "resolved": "https://registry.npmjs.org/underscore/-/underscore-1.4.4.tgz",
+      "integrity": "sha1-YaajIBBiKvoHljvzJSA88SI51gQ=",
+      "dev": true
+    },
     "uniq": {
       "version": "1.0.1",
       "resolved": "https://registry.npmjs.org/uniq/-/uniq-1.0.1.tgz",

+ 11 - 4
package.json

@@ -1,10 +1,11 @@
 {
   "name": "uppy",
-  "version": "0.24.4",
+  "version": "0.25.5",
   "description": "Extensible JavaScript file upload widget with support for drag&drop, resumable uploads, previews, restrictions, file processing/encoding, remote providers like Instagram, Dropbox, Google Drive, S3 and more :dog:",
   "main": "lib/index.js",
   "jsnext:main": "src/index.js",
   "unpkg": "dist/uppy.min.js",
+  "types": "types/index.d.ts",
   "files": [
     "src/",
     "lib/",
@@ -63,9 +64,12 @@
     "chalk": "1.1.3",
     "cssnano": "^3.10.0",
     "disc": "^1.3.3",
+    "enzyme": "^3.3.0",
+    "enzyme-adapter-react-16": "^1.1.1",
     "eslint": "^3.19.0",
     "eslint-config-standard": "^10.2.1",
     "eslint-config-standard-preact": "^1.1.6",
+    "eslint-plugin-compat": "^2.3.0",
     "eslint-plugin-import": "^2.8.0",
     "eslint-plugin-jest": "^21.6.2",
     "eslint-plugin-node": "^4.2.3",
@@ -83,12 +87,15 @@
     "mkdirp": "0.5.1",
     "multi-glob": "1.0.1",
     "next-update": "^3.6.0",
+    "nock": "^9.3.2",
     "node-sass": "^4.9.0",
     "npm-run-all": "^4.1.2",
     "onchange": "^3.3.0",
     "postcss": "^6.0.16",
     "postcss-safe-important": "^1.1.0",
     "pre-commit": "^1.2.2",
+    "react": "^16.4.0",
+    "react-dom": "^16.4.0",
     "redux": "^3.7.2",
     "replace-x": "^1.5.0",
     "sass": "0.5.0",
@@ -126,11 +133,11 @@
     "build:gzip": "node ./bin/gzip.js",
     "size": "echo 'JS Bundle mingz:' && cat ./dist/uppy.min.js | gzip | wc -c && echo 'CSS Bundle mingz:' && cat ./dist/uppy.min.css | gzip | wc -c",
     "build:js": "npm-run-all build:bundle build:lib",
-    "build:lib": "babel --version && babel src --source-maps -d lib",
+    "build:lib": "babel --version && babel src --source-maps -d lib --ignore '*.test.js'",
     "build": "npm-run-all --parallel build:js build:css --serial build:gzip size",
     "clean": "rm -rf lib && rm -rf dist",
-    "lint:fix": "eslint src test website/build-examples.js website/update.js website/themes/uppy/source/js/common.js --fix",
-    "lint": "eslint src test website/build-examples.js website/update.js website/themes/uppy/source/js/common.js",
+    "lint:fix": "eslint src test website/scripts website/build-examples.js website/update.js website/themes/uppy/source/js/common.js --fix",
+    "lint": "eslint src test website/scripts website/build-examples.js website/update.js website/themes/uppy/source/js/common.js",
     "lint-staged": "lint-staged",
     "release:major": "env SEMANTIC=major npm run release",
     "release:minor": "env SEMANTIC=minor npm run release",

+ 48 - 20
src/core/Core.js

@@ -1,11 +1,15 @@
-const Utils = require('../core/Utils')
 const Translator = require('../core/Translator')
 const ee = require('namespace-emitter')
 const cuid = require('cuid')
-const throttle = require('lodash.throttle')
+// const throttle = require('lodash.throttle')
 const prettyBytes = require('prettier-bytes')
 const match = require('mime-match')
 const DefaultStore = require('../store/DefaultStore')
+const getFileType = require('../utils/getFileType')
+const getFileNameAndExtension = require('../utils/getFileNameAndExtension')
+const generateFileID = require('../utils/generateFileID')
+const isObjectURL = require('../utils/isObjectURL')
+const getTimeStamp = require('../utils/getTimeStamp')
 
 /**
  * Uppy Core module.
@@ -34,7 +38,14 @@ class Uppy {
         failedToUpload: 'Failed to upload %{file}',
         noInternetConnection: 'No Internet connection',
         connectedToInternet: 'Connected to the Internet',
-        noFilesFound: 'You have no files or folders here'
+        // Strings for remote providers
+        noFilesFound: 'You have no files or folders here',
+        selectXFiles: {
+          0: 'Select %{smart_count} file',
+          1: 'Select %{smart_count} files'
+        },
+        cancel: 'Cancel',
+        logOut: 'Log out'
       }
     }
 
@@ -172,7 +183,7 @@ class Uppy {
   }
 
   /**
-  * Back compat for when this.state is used instead of this.getState().
+  * Back compat for when uppy.state is used instead of uppy.getState().
   */
   get state () {
     return this.getState()
@@ -182,6 +193,10 @@ class Uppy {
   * Shorthand to set state for a specific file.
   */
   setFileState (fileID, state) {
+    if (!this.getState().files[fileID]) {
+      throw new Error(`Can’t set state for ${fileID} (the file could have been removed)`)
+    }
+
     this.setState({
       files: Object.assign({}, this.getState().files, {
         [fileID]: Object.assign({}, this.getState().files[fileID], state)
@@ -386,7 +401,7 @@ class Uppy {
       file = onBeforeFileAddedResult
     }
 
-    const fileType = Utils.getFileType(file)
+    const fileType = getFileType(file)
     let fileName
     if (file.name) {
       fileName = file.name
@@ -395,10 +410,10 @@ class Uppy {
     } else {
       fileName = 'noname'
     }
-    const fileExtension = Utils.getFileNameAndExtension(fileName).extension
+    const fileExtension = getFileNameAndExtension(fileName).extension
     const isRemote = file.isRemote || false
 
-    const fileID = Utils.generateFileID(file)
+    const fileID = generateFileID(file)
 
     const meta = file.meta || {}
     meta.name = fileName
@@ -451,7 +466,7 @@ class Uppy {
   }
 
   removeFile (fileID) {
-    const { files, currentUploads } = this.state
+    const { files, currentUploads } = this.getState()
     const updatedFiles = Object.assign({}, files)
     const removedFile = updatedFiles[fileID]
     delete updatedFiles[fileID]
@@ -486,7 +501,7 @@ class Uppy {
     this.log(`File removed: ${removedFile.id}`)
 
     // Clean up object URLs.
-    if (removedFile.preview && Utils.isObjectURL(removedFile.preview)) {
+    if (removedFile.preview && isObjectURL(removedFile.preview)) {
       URL.revokeObjectURL(removedFile.preview)
     }
 
@@ -582,7 +597,8 @@ class Uppy {
 
     this.setState({
       files: {},
-      totalProgress: 0
+      totalProgress: 0,
+      error: null
     })
   }
 
@@ -688,15 +704,17 @@ class Uppy {
     // connection to the remote server. Therefore, we are throtteling them to
     // prevent accessive function calls.
     // see also: https://github.com/tus/tus-js-client/commit/9940f27b2361fd7e10ba58b09b60d82422183bbb
-    const _throttledCalculateProgress = throttle(this._calculateProgress, 100, { leading: true, trailing: true })
+    // const _throttledCalculateProgress = throttle(this._calculateProgress, 100, { leading: true, trailing: true })
 
-    this.on('upload-progress', _throttledCalculateProgress)
+    this.on('upload-progress', this._calculateProgress)
 
     this.on('upload-success', (file, uploadResp, uploadURL) => {
+      const currentProgress = this.getFile(file.id).progress
       this.setFileState(file.id, {
-        progress: Object.assign({}, this.getFile(file.id).progress, {
+        progress: Object.assign({}, currentProgress, {
           uploadComplete: true,
-          percentage: 100
+          percentage: 100,
+          bytesUploaded: currentProgress.bytesTotal
         }),
         uploadURL: uploadURL,
         isPaused: false
@@ -872,28 +890,37 @@ class Uppy {
    * @param {object} instance The plugin instance to remove.
    */
   removePlugin (instance) {
-    const list = this.plugins[instance.type]
+    this.log(`Removing plugin ${instance.id}`)
+    this.emit('plugin-remove', instance)
 
     if (instance.uninstall) {
       instance.uninstall()
     }
 
+    const list = this.plugins[instance.type].slice()
     const index = list.indexOf(instance)
     if (index !== -1) {
       list.splice(index, 1)
+      this.plugins[instance.type] = list
     }
+
+    const updatedState = this.getState()
+    delete updatedState.plugins[instance.id]
+    this.setState(updatedState)
   }
 
   /**
    * Uninstall all plugins and close down this Uppy instance.
    */
   close () {
+    this.log(`Closing Uppy instance ${this.opts.id}: removing all files and uninstalling plugins`)
+
     this.reset()
 
     this._storeUnsubscribe()
 
     this.iteratePlugins((plugin) => {
-      plugin.uninstall()
+      this.removePlugin(plugin)
     })
   }
 
@@ -951,7 +978,7 @@ class Uppy {
       return
     }
 
-    let message = `[Uppy] [${Utils.getTimeStamp()}] ${msg}`
+    let message = `[Uppy] [${getTimeStamp()}] ${msg}`
 
     window['uppyLog'] = window['uppyLog'] + '\n' + 'DEBUG LOG: ' + msg
 
@@ -968,7 +995,7 @@ class Uppy {
     if (msg === `${msg}`) {
       console.log(message)
     } else {
-      message = `[Uppy] [${Utils.getTimeStamp()}]`
+      message = `[Uppy] [${getTimeStamp()}]`
       console.log(message)
       console.dir(msg)
     }
@@ -1179,8 +1206,9 @@ class Uppy {
       })
       .catch((err) => {
         const message = typeof err === 'object' ? err.message : err
-        this.log(message)
-        this.info(message, 'error', 4000)
+        const details = typeof err === 'object' ? err.details : null
+        this.log(`${message} ${details}`)
+        this.info({ message: message, details: details }, 'error', 4000)
         return Promise.reject(typeof err === 'object' ? err : new Error(err))
       })
   }

+ 78 - 79
src/core/Core.test.js

@@ -1,7 +1,6 @@
 const fs = require('fs')
 const path = require('path')
 const Core = require('./Core')
-const utils = require('./Utils')
 const Plugin = require('./Plugin')
 const AcquirerPlugin1 = require('../../test/mocks/acquirerPlugin1')
 const AcquirerPlugin2 = require('../../test/mocks/acquirerPlugin2')
@@ -12,15 +11,15 @@ const InvalidPluginWithoutType = require('../../test/mocks/invalidPluginWithoutT
 jest.mock('cuid', () => {
   return () => 'cjd09qwxb000dlql4tp4doz8h'
 })
+jest.mock('../utils/findDOMElement', () => {
+  return () => null
+})
 
 const sampleImage = fs.readFileSync(path.join(__dirname, '../../test/resources/image.jpg'))
 
 describe('src/Core', () => {
   const RealCreateObjectUrl = global.URL.createObjectURL
   beforeEach(() => {
-    jest.spyOn(utils, 'findDOMElement').mockImplementation(path => {
-      return 'some config...'
-    })
     global.URL.createObjectURL = jest.fn().mockReturnValue('newUrl')
   })
 
@@ -160,7 +159,7 @@ describe('src/Core', () => {
         totalProgress: 0
       }
 
-      expect(core.state).toEqual(newState)
+      expect(core.getState()).toEqual(newState)
 
       expect(core.plugins.acquirer[0].mocks.update.mock.calls[1]).toEqual([
         newState
@@ -232,6 +231,7 @@ describe('src/Core', () => {
       capabilities: { resumableUploads: false },
       files: {},
       currentUploads: {},
+      error: null,
       foo: 'bar',
       info: { isHidden: true, message: '', type: 'info' },
       meta: {},
@@ -244,21 +244,21 @@ describe('src/Core', () => {
     const core = new Core()
     const id = core._createUpload([ 'a', 'b' ])
 
-    expect(core.state.currentUploads[id]).toBeDefined()
+    expect(core.getState().currentUploads[id]).toBeDefined()
 
     core.cancelAll()
 
-    expect(core.state.currentUploads[id]).toBeUndefined()
+    expect(core.getState().currentUploads[id]).toBeUndefined()
   })
 
   it('should close, reset and uninstall when the close method is called', () => {
     const core = new Core()
     core.use(AcquirerPlugin1)
 
-    // const corePauseEventMock = jest.fn()
     const coreCancelEventMock = jest.fn()
     const coreStateUpdateEventMock = jest.fn()
-    // core.on('pause-all', corePauseEventMock)
+    const plugin = core.plugins.acquirer[0]
+
     core.on('cancel-all', coreCancelEventMock)
     core.on('state-update', coreStateUpdateEventMock)
 
@@ -271,14 +271,14 @@ describe('src/Core', () => {
       capabilities: { resumableUploads: false },
       files: {},
       currentUploads: {},
+      error: null,
       info: { isHidden: true, message: '', type: 'info' },
       meta: {},
       plugins: {},
       totalProgress: 0
     })
-    expect(core.plugins.acquirer[0].mocks.uninstall.mock.calls.length).toEqual(
-      1
-    )
+    expect(plugin.mocks.uninstall.mock.calls.length).toEqual(1)
+    expect(core.plugins[Object.keys(core.plugins)[0]].length).toEqual(0)
   })
 
   describe('upload hooks', () => {
@@ -338,7 +338,7 @@ describe('src/Core', () => {
 
       return core.upload()
         .then(() => {
-          const fileId = Object.keys(core.state.files)[0]
+          const fileId = Object.keys(core.getState().files)[0]
           expect(preprocessor1.mock.calls.length).toEqual(1)
 
           expect(preprocessor1.mock.calls[0][0].length).toEqual(1)
@@ -358,14 +358,14 @@ describe('src/Core', () => {
         data: new File([sampleImage], { type: 'image/jpeg' })
       })
 
-      const fileId = Object.keys(core.state.files)[0]
+      const fileId = Object.keys(core.getState().files)[0]
       const file = core.getFile(fileId)
       core.emit('preprocess-progress', file, {
         mode: 'determinate',
         message: 'something',
         value: 0
       })
-      expect(core.state.files[fileId].progress).toEqual({
+      expect(core.getFile(fileId).progress).toEqual({
         percentage: 0,
         bytesUploaded: 0,
         bytesTotal: 17175,
@@ -385,14 +385,14 @@ describe('src/Core', () => {
         data: new File([sampleImage], { type: 'image/jpeg' })
       })
 
-      const fileID = Object.keys(core.state.files)[0]
-      const file = core.state.files[fileID]
+      const fileID = Object.keys(core.getState().files)[0]
+      const file = core.getFile(fileID)
       core.emit('preprocess-complete', file, {
         mode: 'determinate',
         message: 'something',
         value: 0
       })
-      expect(core.state.files[fileID].progress).toEqual({
+      expect(core.getFile(fileID).progress).toEqual({
         percentage: 0,
         bytesUploaded: 0,
         bytesTotal: 17175,
@@ -465,14 +465,14 @@ describe('src/Core', () => {
         data: new File([sampleImage], { type: 'image/jpeg' })
       })
 
-      const fileId = Object.keys(core.state.files)[0]
+      const fileId = Object.keys(core.getState().files)[0]
       const file = core.getFile(fileId)
       core.emit('postprocess-progress', file, {
         mode: 'determinate',
         message: 'something',
         value: 0
       })
-      expect(core.state.files[fileId].progress).toEqual({
+      expect(core.getFile(fileId).progress).toEqual({
         percentage: 0,
         bytesUploaded: 0,
         bytesTotal: 17175,
@@ -492,14 +492,14 @@ describe('src/Core', () => {
         data: new File([sampleImage], { type: 'image/jpeg' })
       })
 
-      const fileId = Object.keys(core.state.files)[0]
-      const file = core.state.files[fileId]
+      const fileId = Object.keys(core.getState().files)[0]
+      const file = core.getFile(fileId)
       core.emit('postprocess-complete', file, {
         mode: 'determinate',
         message: 'something',
         value: 0
       })
-      expect(core.state.files[fileId].progress).toEqual({
+      expect(core.getFile(fileId).progress).toEqual({
         percentage: 0,
         bytesUploaded: 0,
         bytesTotal: 17175,
@@ -563,7 +563,7 @@ describe('src/Core', () => {
         data: fileData
       })
 
-      const fileId = Object.keys(core.state.files)[0]
+      const fileId = Object.keys(core.getState().files)[0]
       const newFile = {
         extension: 'jpg',
         id: fileId,
@@ -584,7 +584,7 @@ describe('src/Core', () => {
         source: 'jest',
         type: 'image/jpeg'
       }
-      expect(core.state.files[fileId]).toEqual(newFile)
+      expect(core.getFile(fileId)).toEqual(newFile)
       expect(fileAddedEventMock.mock.calls[0][0]).toEqual(newFile)
     })
 
@@ -621,7 +621,7 @@ describe('src/Core', () => {
         type: 'image/jpeg',
         data: new File([sampleImage], { type: 'image/jpeg' })
       })
-      expect(Object.keys(core.state.files).length).toEqual(0)
+      expect(core.getFiles().length).toEqual(0)
     })
   })
 
@@ -735,8 +735,8 @@ describe('src/Core', () => {
         data: new File([sampleImage], { type: 'image/jpeg' })
       })
 
-      const fileId = Object.keys(core.state.files)[0]
-      expect(Object.keys(core.state.files).length).toEqual(1)
+      const fileId = Object.keys(core.getState().files)[0]
+      expect(core.getFiles().length).toEqual(1)
       core.setState({
         totalProgress: 50
       })
@@ -744,9 +744,9 @@ describe('src/Core', () => {
       const file = core.getFile(fileId)
       core.removeFile(fileId)
 
-      expect(Object.keys(core.state.files).length).toEqual(0)
+      expect(core.getFiles().length).toEqual(0)
       expect(fileRemovedEventMock.mock.calls[0][0]).toEqual(file)
-      expect(core.state.totalProgress).toEqual(0)
+      expect(core.getState().totalProgress).toEqual(0)
     })
   })
 
@@ -767,7 +767,7 @@ describe('src/Core', () => {
         data: new File([sampleImage], { type: 'image/jpeg' })
       })
 
-      const fileId = Object.keys(core.state.files)[0]
+      const fileId = Object.keys(core.getState().files)[0]
       expect(core.getFile(fileId).name).toEqual('foo.jpg')
 
       expect(core.getFile('non existant file')).toEqual(undefined)
@@ -809,7 +809,7 @@ describe('src/Core', () => {
       })
       core.setMeta({ foo: 'bar', bur: 'mur' })
       core.setMeta({ boo: 'moo', bur: 'fur' })
-      expect(core.state.meta).toEqual({
+      expect(core.getState().meta).toEqual({
         foo: 'bar',
         foo2: 'bar2',
         boo: 'moo',
@@ -827,10 +827,10 @@ describe('src/Core', () => {
         data: new File([sampleImage], { type: 'image/jpeg' })
       })
 
-      const fileId = Object.keys(core.state.files)[0]
+      const fileId = Object.keys(core.getState().files)[0]
       core.setFileMeta(fileId, { foo: 'bar', bur: 'mur' })
       core.setFileMeta(fileId, { boo: 'moo', bur: 'fur' })
-      expect(core.state.files[fileId].meta).toEqual({
+      expect(core.getFile(fileId).meta).toEqual({
         name: 'foo.jpg',
         type: 'image/jpeg',
         foo: 'bar',
@@ -852,8 +852,8 @@ describe('src/Core', () => {
         },
         data: new File([sampleImage], { type: 'image/jpeg' })
       })
-      const fileId = Object.keys(core.state.files)[0]
-      expect(core.state.files[fileId].meta).toEqual({
+      const fileId = Object.keys(core.getState().files)[0]
+      expect(core.getFile(fileId).meta).toEqual({
         name: 'foo.jpg',
         type: 'image/jpeg',
         foo2: 'bar2',
@@ -873,13 +873,13 @@ describe('src/Core', () => {
         data: new File([sampleImage], { type: 'image/jpeg' })
       })
 
-      const fileId = Object.keys(core.state.files)[0]
+      const fileId = Object.keys(core.getState().files)[0]
       const file = core.getFile(fileId)
       core._calculateProgress(file, {
         bytesUploaded: 12345,
         bytesTotal: 17175
       })
-      expect(core.state.files[fileId].progress).toEqual({
+      expect(core.getFile(fileId).progress).toEqual({
         percentage: 71,
         bytesUploaded: 12345,
         bytesTotal: 17175,
@@ -891,7 +891,7 @@ describe('src/Core', () => {
         bytesUploaded: 17175,
         bytesTotal: 17175
       })
-      expect(core.state.files[fileId].progress).toEqual({
+      expect(core.getFile(fileId).progress).toEqual({
         percentage: 100,
         bytesUploaded: 17175,
         bytesTotal: 17175,
@@ -916,25 +916,22 @@ describe('src/Core', () => {
         data: new File([sampleImage], { type: 'image/jpeg' })
       })
 
-      const fileId1 = Object.keys(core.state.files)[0]
-      const fileId2 = Object.keys(core.state.files)[1]
-      const file1 = core.state.files[fileId1]
-      const file2 = core.state.files[fileId2]
-      core.state.files[fileId1].progress.uploadStarted = new Date()
-      core.state.files[fileId2].progress.uploadStarted = new Date()
+      const [file1, file2] = core.getFiles()
+      core.setFileState(file1.id, { progress: Object.assign({}, file1.progress, { uploadStarted: new Date() }) })
+      core.setFileState(file2.id, { progress: Object.assign({}, file2.progress, { uploadStarted: new Date() }) })
 
-      core._calculateProgress(file1, {
+      core._calculateProgress(core.getFile(file1.id), {
         bytesUploaded: 12345,
         bytesTotal: 17175
       })
 
-      core._calculateProgress(file2, {
+      core._calculateProgress(core.getFile(file2.id), {
         bytesUploaded: 10201,
         bytesTotal: 17175
       })
 
       core._calculateTotalProgress()
-      expect(core.state.totalProgress).toEqual(65)
+      expect(core.getState().totalProgress).toEqual(65)
     })
 
     it('should reset the progress', () => {
@@ -955,44 +952,41 @@ describe('src/Core', () => {
         data: new File([sampleImage], { type: 'image/jpeg' })
       })
 
-      const fileId1 = Object.keys(core.state.files)[0]
-      const fileId2 = Object.keys(core.state.files)[1]
-      const file1 = core.state.files[fileId1]
-      const file2 = core.state.files[fileId2]
-      core.state.files[fileId1].progress.uploadStarted = new Date()
-      core.state.files[fileId2].progress.uploadStarted = new Date()
+      const [file1, file2] = core.getFiles()
+      core.setFileState(file1.id, { progress: Object.assign({}, file1.progress, { uploadStarted: new Date() }) })
+      core.setFileState(file2.id, { progress: Object.assign({}, file2.progress, { uploadStarted: new Date() }) })
 
-      core._calculateProgress(file1, {
+      core._calculateProgress(core.getFile(file1.id), {
         bytesUploaded: 12345,
         bytesTotal: 17175
       })
 
-      core._calculateProgress(file2, {
+      core._calculateProgress(core.getFile(file2.id), {
         bytesUploaded: 10201,
         bytesTotal: 17175
       })
 
       core._calculateTotalProgress()
 
-      expect(core.state.totalProgress).toEqual(65)
+      expect(core.getState().totalProgress).toEqual(65)
 
       core.resetProgress()
 
-      expect(core.state.files[fileId1].progress).toEqual({
+      expect(core.getFile(file1.id).progress).toEqual({
         percentage: 0,
         bytesUploaded: 0,
         bytesTotal: 17175,
         uploadComplete: false,
         uploadStarted: false
       })
-      expect(core.state.files[fileId2].progress).toEqual({
+      expect(core.getFile(file2.id).progress).toEqual({
         percentage: 0,
         bytesUploaded: 0,
         bytesTotal: 17175,
         uploadComplete: false,
         uploadStarted: false
       })
-      expect(core.state.totalProgress).toEqual(0)
+      expect(core.getState().totalProgress).toEqual(0)
       expect(resetProgressEvent.mock.calls.length).toEqual(1)
     })
   })
@@ -1023,7 +1017,7 @@ describe('src/Core', () => {
         throw new Error('should have thrown')
       } catch (err) {
         expect(err).toMatchObject(new Error('You can only upload 1 file'))
-        expect(core.state.info.message).toEqual('You can only upload 1 file')
+        expect(core.getState().info.message).toEqual('You can only upload 1 file')
       }
     })
 
@@ -1047,7 +1041,7 @@ describe('src/Core', () => {
         throw new Error('should have thrown')
       } catch (err) {
         expect(err).toMatchObject(new Error('You can only upload: image/gif, image/png'))
-        expect(core.state.info.message).toEqual('You can only upload: image/gif, image/png')
+        expect(core.getState().info.message).toEqual('You can only upload: image/gif, image/png')
       }
     })
 
@@ -1069,7 +1063,7 @@ describe('src/Core', () => {
         throw new Error('should have thrown')
       } catch (err) {
         expect(err).toMatchObject(new Error('You can only upload: .gif, .jpg, .jpeg'))
-        expect(core.state.info.message).toEqual('You can only upload: .gif, .jpg, .jpeg')
+        expect(core.getState().info.message).toEqual('You can only upload: .gif, .jpg, .jpeg')
       }
     })
 
@@ -1091,7 +1085,7 @@ describe('src/Core', () => {
         throw new Error('should have thrown')
       } catch (err) {
         expect(err).toMatchObject(new Error('This file exceeds maximum allowed size of 1.2 KB'))
-        expect(core.state.info.message).toEqual('This file exceeds maximum allowed size of 1.2 KB')
+        expect(core.getState().info.message).toEqual('This file exceeds maximum allowed size of 1.2 KB')
       }
     })
   })
@@ -1100,23 +1094,28 @@ describe('src/Core', () => {
     it('should update the state when receiving the error event', () => {
       const core = new Core()
       core.emit('error', new Error('foooooo'))
-      expect(core.state.error).toEqual('foooooo')
+      expect(core.getState().error).toEqual('foooooo')
     })
 
     it('should update the state when receiving the upload-error event', () => {
       const core = new Core()
-      core.state.files['fileId'] = {
-        name: 'filename'
-      }
-      core.emit('upload-error', core.state.files['fileId'], new Error('this is the error'))
-      expect(core.state.info).toEqual({'message': 'Failed to upload filename', 'details': 'this is the error', 'isHidden': false, 'type': 'error'})
+      core.setState({
+        files: {
+          fileId: {
+            id: 'fileId',
+            name: 'filename'
+          }
+        }
+      })
+      core.emit('upload-error', core.getFile('fileId'), new Error('this is the error'))
+      expect(core.getState().info).toEqual({'message': 'Failed to upload filename', 'details': 'this is the error', 'isHidden': false, 'type': 'error'})
     })
 
     it('should reset the error state when receiving the upload event', () => {
       const core = new Core()
       core.emit('error', { foo: 'bar' })
       core.emit('upload')
-      expect(core.state.error).toEqual(null)
+      expect(core.getState().error).toEqual(null)
     })
   })
 
@@ -1174,7 +1173,7 @@ describe('src/Core', () => {
       core.on('info-visible', infoVisibleEvent)
 
       core.info('This is the message', 'info', 0)
-      expect(core.state.info).toEqual({
+      expect(core.getState().info).toEqual({
         isHidden: false,
         type: 'info',
         message: 'This is the message',
@@ -1195,7 +1194,7 @@ describe('src/Core', () => {
           foo: 'bar'
         }
       }, 'warning', 0)
-      expect(core.state.info).toEqual({
+      expect(core.getState().info).toEqual({
         isHidden: false,
         type: 'warning',
         message: 'This is the message',
@@ -1219,7 +1218,7 @@ describe('src/Core', () => {
       expect(infoHiddenEvent.mock.calls.length).toEqual(0)
       setTimeout(() => {
         expect(infoHiddenEvent.mock.calls.length).toEqual(1)
-        expect(core.state.info).toEqual({
+        expect(core.getState().info).toEqual({
           isHidden: true,
           type: 'info',
           message: 'This is the message',
@@ -1241,7 +1240,7 @@ describe('src/Core', () => {
       expect(infoHiddenEvent.mock.calls.length).toEqual(0)
       core.hideInfo()
       expect(infoHiddenEvent.mock.calls.length).toEqual(1)
-      expect(core.state.info).toEqual({
+      expect(core.getState().info).toEqual({
         isHidden: true,
         type: 'info',
         message: 'This is the message',
@@ -1260,15 +1259,15 @@ describe('src/Core', () => {
         data: new File([sampleImage], { type: 'image/jpeg' })
       })
 
-      core._createUpload(Object.keys(core.state.files))
-      const uploadId = Object.keys(core.state.currentUploads)[0]
+      core._createUpload(Object.keys(core.getState().files))
+      const uploadId = Object.keys(core.getState().currentUploads)[0]
       const currentUploadsState = {}
       currentUploadsState[uploadId] = {
-        fileIDs: Object.keys(core.state.files),
+        fileIDs: Object.keys(core.getState().files),
         step: 0,
         result: {}
       }
-      expect(core.state.currentUploads).toEqual(currentUploadsState)
+      expect(core.getState().currentUploads).toEqual(currentUploadsState)
     })
   })
 

+ 9 - 4
src/core/Plugin.js

@@ -1,5 +1,5 @@
 const preact = require('preact')
-const { findDOMElement } = require('../core/Utils')
+const findDOMElement = require('../utils/findDOMElement')
 
 /**
  * Defer a frequent call to the microtask queue.
@@ -44,11 +44,12 @@ module.exports = class Plugin {
   }
 
   getPluginState () {
-    return this.uppy.state.plugins[this.id]
+    const { plugins } = this.uppy.getState()
+    return plugins[this.id]
   }
 
   setPluginState (update) {
-    const plugins = Object.assign({}, this.uppy.state.plugins)
+    const plugins = Object.assign({}, this.uppy.getState().plugins)
     plugins[this.id] = Object.assign({}, plugins[this.id], update)
 
     this.uppy.setState({
@@ -84,6 +85,10 @@ module.exports = class Plugin {
 
       // API for plugins that require a synchronous rerender.
       this.rerender = (state) => {
+        // plugin could be removed, but this.rerender is debounced below,
+        // so it could still be called even after uppy.removePlugin or uppy.close
+        // hence the check
+        if (!this.uppy.getPlugin(this.id)) return
         this.el = preact.render(this.render(state), targetElement, this.el)
       }
       this._updateUI = debounce(this.rerender)
@@ -136,7 +141,7 @@ module.exports = class Plugin {
   }
 
   unmount () {
-    if (this.el && this.el.parentNode) {
+    if (this.isTargetDOMEl && this.el && this.el.parentNode) {
       this.el.parentNode.removeChild(this.el)
     }
   }

+ 0 - 41
src/core/PromiseWaiter.js

@@ -1,41 +0,0 @@
-/**
- * Wait for multiple Promises to resolve.
- */
-module.exports = class PromiseWaiter {
-  constructor () {
-    this.promises = []
-  }
-
-  add (promise) {
-    this.promises.push(promise)
-
-    const remove = () => {
-      this.remove(promise)
-    }
-    promise.then(remove, remove)
-  }
-
-  remove (promise) {
-    const index = this.promises.indexOf(promise)
-    if (index !== -1) {
-      this.promises.splice(index, 1)
-    }
-  }
-
-  wait () {
-    const promises = this.promises
-    this.promises = []
-
-    function noop () {
-      // No result value
-    }
-
-    // Just wait for a Promise to conclude in some way, whether it's resolution
-    // or rejection. We don't care about the contents.
-    function concluded (promise) {
-      return promise.then(noop, noop)
-    }
-
-    return Promise.all(promises.map(concluded)).then(noop)
-  }
-}

+ 0 - 468
src/core/Utils.js

@@ -1,468 +0,0 @@
-const throttle = require('lodash.throttle')
-const mimeTypes = require('./mime-types.js')
-
-/**
- * A collection of small utility functions that help with dom manipulation, adding listeners,
- * promises and other good things.
- *
- * @module Utils
- */
-
-function isTouchDevice () {
-  return 'ontouchstart' in window || // works on most browsers
-          navigator.maxTouchPoints   // works on IE10/11 and Surface
-}
-
-function truncateString (str, length) {
-  if (str.length > length) {
-    return str.substr(0, length / 2) + '...' + str.substr(str.length - length / 4, str.length)
-  }
-  return str
-
-  // more precise version if needed
-  // http://stackoverflow.com/a/831583
-}
-
-function secondsToTime (rawSeconds) {
-  const hours = Math.floor(rawSeconds / 3600) % 24
-  const minutes = Math.floor(rawSeconds / 60) % 60
-  const seconds = Math.floor(rawSeconds % 60)
-
-  return { hours, minutes, seconds }
-}
-
-/**
- * Converts list into array
-*/
-function toArray (list) {
-  return Array.prototype.slice.call(list || [], 0)
-}
-
-/**
- * Returns a timestamp in the format of `hours:minutes:seconds`
-*/
-function getTimeStamp () {
-  var date = new Date()
-  var hours = pad(date.getHours().toString())
-  var minutes = pad(date.getMinutes().toString())
-  var seconds = pad(date.getSeconds().toString())
-  return hours + ':' + minutes + ':' + seconds
-}
-
-/**
- * Adds zero to strings shorter than two characters
-*/
-function pad (str) {
-  return str.length !== 2 ? 0 + str : str
-}
-
-/**
- * Takes a file object and turns it into fileID, by converting file.name to lowercase,
- * removing extra characters and adding type, size and lastModified
- *
- * @param {Object} file
- * @return {String} the fileID
- *
- */
-function generateFileID (file) {
-  // filter is needed to not join empty values with `-`
-  return [
-    'uppy',
-    file.name ? file.name.toLowerCase().replace(/[^A-Z0-9]/ig, '') : '',
-    file.type,
-    file.data.size,
-    file.data.lastModified
-  ].filter(val => val).join('-')
-}
-
-/**
- * Runs an array of promise-returning functions in sequence.
- */
-function runPromiseSequence (functions, ...args) {
-  let promise = Promise.resolve()
-  functions.forEach((func) => {
-    promise = promise.then(() => func(...args))
-  })
-  return promise
-}
-
-function isPreviewSupported (fileType) {
-  if (!fileType) return false
-  const fileTypeSpecific = fileType.split('/')[1]
-  // list of images that browsers can preview
-  if (/^(jpeg|gif|png|svg|svg\+xml|bmp)$/.test(fileTypeSpecific)) {
-    return true
-  }
-  return false
-}
-
-function getArrayBuffer (chunk) {
-  return new Promise(function (resolve, reject) {
-    var reader = new FileReader()
-    reader.addEventListener('load', function (e) {
-      // e.target.result is an ArrayBuffer
-      resolve(e.target.result)
-    })
-    reader.addEventListener('error', function (err) {
-      console.error('FileReader error' + err)
-      reject(err)
-    })
-    // file-type only needs the first 4100 bytes
-    reader.readAsArrayBuffer(chunk)
-  })
-}
-
-function getFileType (file) {
-  const fileExtension = file.name ? getFileNameAndExtension(file.name).extension : null
-
-  if (file.isRemote) {
-    // some remote providers do not support file types
-    return file.type ? file.type : mimeTypes[fileExtension]
-  }
-
-  // check if mime type is set in the file object
-  if (file.type) {
-    return file.type
-  }
-
-  // see if we can map extension to a mime type
-  if (fileExtension && mimeTypes[fileExtension]) {
-    return mimeTypes[fileExtension]
-  }
-
-  // if all fails, well, return empty
-  return null
-}
-
-// TODO Check which types are actually supported in browsers. Chrome likes webm
-// from my testing, but we may need more.
-// We could use a library but they tend to contain dozens of KBs of mappings,
-// most of which will go unused, so not sure if that's worth it.
-const mimeToExtensions = {
-  'video/ogg': 'ogv',
-  'audio/ogg': 'ogg',
-  'video/webm': 'webm',
-  'audio/webm': 'webm',
-  'video/mp4': 'mp4',
-  'audio/mp3': 'mp3'
-}
-
-function getFileTypeExtension (mimeType) {
-  return mimeToExtensions[mimeType] || null
-}
-
-/**
-* Takes a full filename string and returns an object {name, extension}
-*
-* @param {string} fullFileName
-* @return {object} {name, extension}
-*/
-function getFileNameAndExtension (fullFileName) {
-  var re = /(?:\.([^.]+))?$/
-  var fileExt = re.exec(fullFileName)[1]
-  var fileName = fullFileName.replace('.' + fileExt, '')
-  return {
-    name: fileName,
-    extension: fileExt
-  }
-}
-
-/**
- * Check if a URL string is an object URL from `URL.createObjectURL`.
- *
- * @param {string} url
- * @return {boolean}
- */
-function isObjectURL (url) {
-  return url.indexOf('blob:') === 0
-}
-
-/**
- * Save a <canvas> element's content to a Blob object.
- *
- * @param {HTMLCanvasElement} canvas
- * @return {Promise}
- */
-function canvasToBlob (canvas, type, quality) {
-  if (canvas.toBlob) {
-    return new Promise((resolve) => {
-      canvas.toBlob(resolve, type, quality)
-    })
-  }
-  return Promise.resolve().then(() => {
-    return dataURItoBlob(canvas.toDataURL(type, quality), {})
-  })
-}
-
-function dataURItoBlob (dataURI, opts, toFile) {
-  // get the base64 data
-  var data = dataURI.split(',')[1]
-
-  // user may provide mime type, if not get it from data URI
-  var mimeType = opts.mimeType || dataURI.split(',')[0].split(':')[1].split(';')[0]
-
-  // default to plain/text if data URI has no mimeType
-  if (mimeType == null) {
-    mimeType = 'plain/text'
-  }
-
-  var binary = atob(data)
-  var array = []
-  for (var i = 0; i < binary.length; i++) {
-    array.push(binary.charCodeAt(i))
-  }
-
-  // Convert to a File?
-  if (toFile) {
-    return new File([new Uint8Array(array)], opts.name || '', {type: mimeType})
-  }
-
-  return new Blob([new Uint8Array(array)], {type: mimeType})
-}
-
-function dataURItoFile (dataURI, opts) {
-  return dataURItoBlob(dataURI, opts, true)
-}
-
-/**
- * Copies text to clipboard by creating an almost invisible textarea,
- * adding text there, then running execCommand('copy').
- * Falls back to prompt() when the easy way fails (hello, Safari!)
- * From http://stackoverflow.com/a/30810322
- *
- * @param {String} textToCopy
- * @param {String} fallbackString
- * @return {Promise}
- */
-function copyToClipboard (textToCopy, fallbackString) {
-  fallbackString = fallbackString || 'Copy the URL below'
-
-  return new Promise((resolve) => {
-    const textArea = document.createElement('textarea')
-    textArea.setAttribute('style', {
-      position: 'fixed',
-      top: 0,
-      left: 0,
-      width: '2em',
-      height: '2em',
-      padding: 0,
-      border: 'none',
-      outline: 'none',
-      boxShadow: 'none',
-      background: 'transparent'
-    })
-
-    textArea.value = textToCopy
-    document.body.appendChild(textArea)
-    textArea.select()
-
-    const magicCopyFailed = () => {
-      document.body.removeChild(textArea)
-      window.prompt(fallbackString, textToCopy)
-      resolve()
-    }
-
-    try {
-      const successful = document.execCommand('copy')
-      if (!successful) {
-        return magicCopyFailed('copy command unavailable')
-      }
-      document.body.removeChild(textArea)
-      return resolve()
-    } catch (err) {
-      document.body.removeChild(textArea)
-      return magicCopyFailed(err)
-    }
-  })
-}
-
-function getSpeed (fileProgress) {
-  if (!fileProgress.bytesUploaded) return 0
-
-  const timeElapsed = (new Date()) - fileProgress.uploadStarted
-  const uploadSpeed = fileProgress.bytesUploaded / (timeElapsed / 1000)
-  return uploadSpeed
-}
-
-function getBytesRemaining (fileProgress) {
-  return fileProgress.bytesTotal - fileProgress.bytesUploaded
-}
-
-function getETA (fileProgress) {
-  if (!fileProgress.bytesUploaded) return 0
-
-  const uploadSpeed = getSpeed(fileProgress)
-  const bytesRemaining = getBytesRemaining(fileProgress)
-  const secondsRemaining = Math.round(bytesRemaining / uploadSpeed * 10) / 10
-
-  return secondsRemaining
-}
-
-function prettyETA (seconds) {
-  const time = secondsToTime(seconds)
-
-  // Only display hours and minutes if they are greater than 0 but always
-  // display minutes if hours is being displayed
-  // Display a leading zero if the there is a preceding unit: 1m 05s, but 5s
-  const hoursStr = time.hours ? time.hours + 'h ' : ''
-  const minutesVal = time.hours ? ('0' + time.minutes).substr(-2) : time.minutes
-  const minutesStr = minutesVal ? minutesVal + 'm ' : ''
-  const secondsVal = minutesVal ? ('0' + time.seconds).substr(-2) : time.seconds
-  const secondsStr = secondsVal + 's'
-
-  return `${hoursStr}${minutesStr}${secondsStr}`
-}
-
-/**
- * Check if an object is a DOM element. Duck-typing based on `nodeType`.
- *
- * @param {*} obj
- */
-function isDOMElement (obj) {
-  return obj && typeof obj === 'object' && obj.nodeType === Node.ELEMENT_NODE
-}
-
-/**
- * Find a DOM element.
- *
- * @param {Node|string} element
- * @return {Node|null}
- */
-function findDOMElement (element) {
-  if (typeof element === 'string') {
-    return document.querySelector(element)
-  }
-
-  if (typeof element === 'object' && isDOMElement(element)) {
-    return element
-  }
-}
-
-/**
- * Find one or more DOM elements.
- *
- * @param {string} element
- * @return {Array|null}
- */
-function findAllDOMElements (element) {
-  if (typeof element === 'string') {
-    const elements = [].slice.call(document.querySelectorAll(element))
-    return elements.length > 0 ? elements : null
-  }
-
-  if (typeof element === 'object' && isDOMElement(element)) {
-    return [element]
-  }
-}
-
-function getSocketHost (url) {
-  // get the host domain
-  var regex = /^(?:https?:\/\/|\/\/)?(?:[^@\n]+@)?(?:www\.)?([^\n]+)/
-  var host = regex.exec(url)[1]
-  var socketProtocol = location.protocol === 'https:' ? 'wss' : 'ws'
-
-  return `${socketProtocol}://${host}`
-}
-
-function _emitSocketProgress (uploader, progressData, file) {
-  const { progress, bytesUploaded, bytesTotal } = progressData
-  if (progress) {
-    uploader.uppy.log(`Upload progress: ${progress}`)
-    uploader.uppy.emit('upload-progress', file, {
-      uploader,
-      bytesUploaded: bytesUploaded,
-      bytesTotal: bytesTotal
-    })
-  }
-}
-
-const emitSocketProgress = throttle(_emitSocketProgress, 300, {leading: true, trailing: true})
-
-function settle (promises) {
-  const resolutions = []
-  const rejections = []
-  function resolved (value) {
-    resolutions.push(value)
-  }
-  function rejected (error) {
-    rejections.push(error)
-  }
-
-  const wait = Promise.all(
-    promises.map((promise) => promise.then(resolved, rejected))
-  )
-
-  return wait.then(() => {
-    return {
-      successful: resolutions,
-      failed: rejections
-    }
-  })
-}
-
-/**
- * Limit the amount of simultaneously pending Promises.
- * Returns a function that, when passed a function `fn`,
- * will make sure that at most `limit` calls to `fn` are pending.
- *
- * @param {number} limit
- * @return {function()}
- */
-function limitPromises (limit) {
-  let pending = 0
-  const queue = []
-  return (fn) => {
-    return (...args) => {
-      const call = () => {
-        pending++
-        const promise = fn(...args)
-        promise.then(onfinish, onfinish)
-        return promise
-      }
-
-      if (pending >= limit) {
-        return new Promise((resolve, reject) => {
-          queue.push(() => {
-            call().then(resolve, reject)
-          })
-        })
-      }
-      return call()
-    }
-  }
-  function onfinish () {
-    pending--
-    const next = queue.shift()
-    if (next) next()
-  }
-}
-
-module.exports = {
-  generateFileID,
-  toArray,
-  getTimeStamp,
-  runPromiseSequence,
-  isTouchDevice,
-  getFileNameAndExtension,
-  truncateString,
-  getFileTypeExtension,
-  getFileType,
-  getArrayBuffer,
-  isPreviewSupported,
-  isObjectURL,
-  secondsToTime,
-  dataURItoBlob,
-  dataURItoFile,
-  canvasToBlob,
-  getSpeed,
-  getBytesRemaining,
-  getETA,
-  copyToClipboard,
-  prettyETA,
-  findDOMElement,
-  findAllDOMElements,
-  getSocketHost,
-  emitSocketProgress,
-  settle,
-  limitPromises
-}

Filskillnaden har hållts tillbaka eftersom den är för stor
+ 0 - 3
src/core/Utils.test.js


+ 19 - 20
src/plugins/AwsS3/Multipart.js

@@ -1,11 +1,9 @@
 const Plugin = require('../../core/Plugin')
 const RequestClient = require('../../server/RequestClient')
 const UppySocket = require('../../core/UppySocket')
-const {
-  emitSocketProgress,
-  getSocketHost,
-  limitPromises
-} = require('../../core/Utils')
+const emitSocketProgress = require('../../utils/emitSocketProgress')
+const getSocketHost = require('../../utils/getSocketHost')
+const limitPromises = require('../../utils/limitPromises')
 const Uploader = require('./MultipartUploader')
 
 /**
@@ -42,7 +40,7 @@ module.exports = class AwsS3Multipart extends Plugin {
     this.type = 'uploader'
     this.id = 'AwsS3Multipart'
     this.title = 'AWS S3 Multipart'
-    this.server = new RequestClient(uppy, opts)
+    this.client = new RequestClient(uppy, opts)
 
     const defaultOptions = {
       timeout: 30 * 1000,
@@ -97,7 +95,7 @@ module.exports = class AwsS3Multipart extends Plugin {
   createMultipartUpload (file) {
     this.assertHost()
 
-    return this.server.post('s3/multipart', {
+    return this.client.post('s3/multipart', {
       filename: file.name,
       type: file.type
     }).then(assertServerError)
@@ -107,7 +105,7 @@ module.exports = class AwsS3Multipart extends Plugin {
     this.assertHost()
 
     const filename = encodeURIComponent(key)
-    return this.server.get(`s3/multipart/${uploadId}?key=${filename}`)
+    return this.client.get(`s3/multipart/${uploadId}?key=${filename}`)
       .then(assertServerError)
   }
 
@@ -115,7 +113,7 @@ module.exports = class AwsS3Multipart extends Plugin {
     this.assertHost()
 
     const filename = encodeURIComponent(key)
-    return this.server.get(`s3/multipart/${uploadId}/${number}?key=${filename}`)
+    return this.client.get(`s3/multipart/${uploadId}/${number}?key=${filename}`)
       .then(assertServerError)
   }
 
@@ -124,7 +122,7 @@ module.exports = class AwsS3Multipart extends Plugin {
 
     const filename = encodeURIComponent(key)
     const uploadIdEnc = encodeURIComponent(uploadId)
-    return this.server.post(`s3/multipart/${uploadIdEnc}/complete?key=${filename}`, { parts })
+    return this.client.post(`s3/multipart/${uploadIdEnc}/complete?key=${filename}`, { parts })
       .then(assertServerError)
   }
 
@@ -133,7 +131,7 @@ module.exports = class AwsS3Multipart extends Plugin {
 
     const filename = encodeURIComponent(key)
     const uploadIdEnc = encodeURIComponent(uploadId)
-    return this.server.delete(`s3/multipart/${uploadIdEnc}?key=${filename}`)
+    return this.client.delete(`s3/multipart/${uploadIdEnc}?key=${filename}`)
       .then(assertServerError)
   }
 
@@ -354,14 +352,6 @@ module.exports = class AwsS3Multipart extends Plugin {
     return Promise.all(promises)
   }
 
-  addResumableUploadsCapabilityFlag () {
-    this.uppy.setState({
-      capabilities: Object.assign({}, this.uppy.getState().capabilities, {
-        resumableUploads: true
-      })
-    })
-  }
-
   onFileRemove (fileID, cb) {
     this.uploaderEvents[fileID].on('file-removed', (file) => {
       if (fileID === file.id) cb(file.id)
@@ -414,11 +404,20 @@ module.exports = class AwsS3Multipart extends Plugin {
   }
 
   install () {
-    this.addResumableUploadsCapabilityFlag()
+    this.uppy.setState({
+      capabilities: Object.assign({}, this.uppy.getState().capabilities, {
+        resumableUploads: true
+      })
+    })
     this.uppy.addUploader(this.upload)
   }
 
   uninstall () {
+    this.uppy.setState({
+      capabilities: Object.assign({}, this.uppy.getState().capabilities, {
+        resumableUploads: false
+      })
+    })
     this.uppy.removeUploader(this.upload)
   }
 }

+ 25 - 2
src/plugins/AwsS3/index.js

@@ -1,7 +1,7 @@
 const resolveUrl = require('resolve-url')
 const Plugin = require('../../core/Plugin')
 const Translator = require('../../core/Translator')
-const { limitPromises } = require('../../core/Utils')
+const limitPromises = require('../../utils/limitPromises')
 const XHRUpload = require('../XHRUpload')
 
 function isXml (xhr) {
@@ -148,18 +148,37 @@ module.exports = class AwsS3 extends Plugin {
   }
 
   install () {
+    const { log } = this.uppy
     this.uppy.addPreProcessor(this.prepareUpload)
 
+    let warnedSuccessActionStatus = false
     this.uppy.use(XHRUpload, {
       fieldName: 'file',
       responseUrlFieldName: 'location',
       timeout: this.opts.timeout,
       limit: this.opts.limit,
+      // Get the response data from a successful XMLHttpRequest instance.
+      // `content` is the S3 response as a string.
+      // `xhr` is the XMLHttpRequest instance.
       getResponseData (content, xhr) {
+        const opts = this
+
         // If no response, we've hopefully done a PUT request to the file
         // in the bucket on its full URL.
         if (!isXml(xhr)) {
-          return { location: xhr.responseURL }
+          if (opts.method.toUpperCase() === 'POST') {
+            if (!warnedSuccessActionStatus) {
+              log('[AwsS3] No response data found, make sure to set the success_action_status AWS SDK option to 201. See https://uppy.io/docs/aws-s3/#POST-Uploads', 'warning')
+              warnedSuccessActionStatus = true
+            }
+            // The responseURL won't contain the object key. Give up.
+            return { location: null }
+          }
+
+          // Trim the query string because it's going to be a bunch of presign
+          // parameters for a PUT request—doing a GET request with those will
+          // always result in an error
+          return { location: xhr.responseURL.replace(/\?.*$/, '') }
         }
 
         let getValue = () => ''
@@ -189,6 +208,10 @@ module.exports = class AwsS3 extends Plugin {
           etag: getValue('ETag')
         }
       },
+
+      // Get the error data from a failed XMLHttpRequest instance.
+      // `content` is the S3 response as a string.
+      // `xhr` is the XMLHttpRequest instance.
       getResponseError (content, xhr) {
         // If no response, we don't have a specific error message, use the default.
         if (!isXml(xhr)) {

+ 4 - 11
src/plugins/Dashboard/Dashboard.js

@@ -2,7 +2,7 @@ const FileList = require('./FileList')
 const Tabs = require('./Tabs')
 const FileCard = require('./FileCard')
 const classNames = require('classnames')
-const { isTouchDevice } = require('../../core/Utils')
+const isTouchDevice = require('../../utils/isTouchDevice')
 const { h } = require('preact')
 
 // http://dev.edenspiekermann.com/2016/02/11/introducing-accessible-modal-dialog
@@ -11,7 +11,7 @@ const { h } = require('preact')
 const PanelContent = (props) => {
   return <div style={{ width: '100%', height: '100%' }}>
     <div class="uppy-DashboardContent-bar">
-      <div class="uppy-DashboardContent-title">
+      <div class="uppy-DashboardContent-title" role="heading" aria-level="h1">
         {props.i18n('importFrom', { name: props.activePanel.name })}
       </div>
       <button class="uppy-DashboardContent-back"
@@ -22,17 +22,13 @@ const PanelContent = (props) => {
   </div>
 }
 
-const poweredByUppy = (props) => {
-  return <a href="https://uppy.io" rel="noreferrer noopener" target="_blank" class="uppy-Dashboard-poweredBy">Powered by <svg aria-hidden="true" class="UppyIcon uppy-Dashboard-poweredByIcon" width="11" height="11" viewBox="0 0 11 11" xmlns="http://www.w3.org/2000/svg">
-    <path d="M7.365 10.5l-.01-4.045h2.612L5.5.806l-4.467 5.65h2.604l.01 4.044h3.718z" fill-rule="evenodd" />
-  </svg><span class="uppy-Dashboard-poweredByUppy">Uppy</span></a>
-}
-
 module.exports = function Dashboard (props) {
   const dashboardClassName = classNames(
     { 'uppy-Root': props.isTargetDOMEl },
     'uppy-Dashboard',
     { 'Uppy--isTouchDevice': isTouchDevice() },
+    { 'uppy-Dashboard--animateOpenClose': props.animateOpenClose },
+    { 'uppy-Dashboard--isClosing': props.isClosing },
     { 'uppy-Dashboard--modal': !props.inline },
     { 'uppy-Dashboard--wide': props.isWide }
   )
@@ -82,9 +78,6 @@ module.exports = function Dashboard (props) {
             })}
           </div>
         </div>
-
-        { props.proudlyDisplayPoweredByUppy && poweredByUppy(props) }
-
       </div>
     </div>
   )

+ 2 - 2
src/plugins/Dashboard/FileCard.js

@@ -66,11 +66,11 @@ module.exports = class FileCard extends Component {
       <div class="uppy-DashboardFileCard" aria-hidden={!this.props.fileCardFor}>
         <div style={{ width: '100%', height: '100%' }}>
           <div class="uppy-DashboardContent-bar">
-            <h2 class="uppy-DashboardContent-title">
+            <div class="uppy-DashboardContent-title" role="heading" aria-level="h1">
               {this.props.i18nArray('editing', {
                 file: <span class="uppy-DashboardContent-titleFile">{file.meta ? file.meta.name : file.name}</span>
               })}
-            </h2>
+            </div>
             <button class="uppy-DashboardContent-back" type="button" title={this.props.i18n('finishEditingFile')}
               onclick={this.handleSave}>{this.props.i18n('done')}</button>
           </div>

+ 55 - 29
src/plugins/Dashboard/FileItem.js

@@ -1,6 +1,6 @@
-const { getFileNameAndExtension,
-         truncateString,
-         copyToClipboard } = require('../../core/Utils')
+const getFileNameAndExtension = require('../../utils/getFileNameAndExtension')
+const truncateString = require('../../utils/truncateString')
+const copyToClipboard = require('../../utils/copyToClipboard')
 const prettyBytes = require('prettier-bytes')
 const FileItemProgress = require('./FileItemProgress')
 const getFileTypeIcon = require('./getFileTypeIcon')
@@ -9,6 +9,41 @@ const { iconEdit, iconCopy, iconRetry } = require('./icons')
 const classNames = require('classnames')
 const { h } = require('preact')
 
+const FileItemProgressWrapper = (props) => {
+  if (props.hideRetryButton && props.error) {
+    return
+  }
+
+  if (props.isUploaded ||
+      props.bundled ||
+      (props.hidePauseResumeCancelButtons && !props.error)) {
+    return <div class="uppy-DashboardItem-progressIndicator">
+      <FileItemProgress
+        progress={props.file.progress.percentage}
+        fileID={props.file.id}
+        hidePauseResumeCancelButtons={props.hidePauseResumeCancelButtons}
+        bundled={props.bundled}
+      />
+    </div>
+  }
+
+  return <button
+    class="uppy-DashboardItem-progressIndicator"
+    type="button"
+    aria-label={props.progressIndicatorTitle}
+    title={props.progressIndicatorTitle}
+    onclick={props.onPauseResumeCancelRetry}>
+    {props.error
+      ? props.hideRetryButton ? null : iconRetry()
+      : <FileItemProgress
+        progress={props.file.progress.percentage}
+        fileID={props.file.id}
+        hidePauseResumeCancelButtons={props.hidePauseResumeCancelButtons}
+      />
+    }
+  </button>
+}
+
 module.exports = function fileItem (props) {
   const file = props.file
   const acquirers = props.acquirers
@@ -25,13 +60,19 @@ module.exports = function fileItem (props) {
 
   const onPauseResumeCancelRetry = (ev) => {
     if (isUploaded) return
+
     if (error && !props.hideRetryButton) {
       props.retryUpload(file.id)
       return
     }
+
+    if (props.hidePauseResumeCancelButtons) {
+      return
+    }
+
     if (props.resumableUploads) {
       props.pauseUpload(file.id)
-    } else if (!props.hideCancelButton) {
+    } else {
       props.cancelUpload(file.id)
     }
   }
@@ -43,7 +84,8 @@ module.exports = function fileItem (props) {
     { 'is-complete': isUploaded },
     { 'is-paused': isPaused },
     { 'is-error': error },
-    { 'is-resumable': props.resumableUploads }
+    { 'is-resumable': props.resumableUploads },
+    { 'is-bundled': props.bundledUpload }
   )
 
   const progressIndicatorTitle = isUploaded
@@ -66,39 +108,23 @@ module.exports = function fileItem (props) {
         <FilePreview file={file} />
       </div>
       <div class="uppy-DashboardItem-progress">
-        {isUploaded
-          ? <div class="uppy-DashboardItem-progressIndicator">
-            {FileItemProgress({
-              progress: file.progress.percentage,
-              fileID: file.id
-            })}
-          </div>
-          : <button class="uppy-DashboardItem-progressIndicator"
-            type="button"
-            aria-label={progressIndicatorTitle}
-            title={progressIndicatorTitle}
-            onclick={onPauseResumeCancelRetry}>
-            {error
-              ? props.hideCancelButton ? null : iconRetry()
-              : FileItemProgress({
-                progress: file.progress.percentage,
-                fileID: file.id,
-                hideCancelButton: props.hideCancelButton
-              })
-            }
-          </button>
-        }
+        <FileItemProgressWrapper
+          progressIndicatorTitle={progressIndicatorTitle}
+          onPauseResumeCancelRetry={onPauseResumeCancelRetry}
+          file={file}
+          error={error}
+          {...props} />
       </div>
     </div>
     <div class="uppy-DashboardItem-info">
-      <h4 class="uppy-DashboardItem-name" title={fileName}>
+      <div class="uppy-DashboardItem-name" title={fileName}>
         {props.showLinkToFileUploadResult && file.uploadURL
           ? <a href={file.uploadURL} rel="noreferrer noopener" target="_blank">
             {file.extension ? truncatedFileName + '.' + file.extension : truncatedFileName}
           </a>
           : file.extension ? truncatedFileName + '.' + file.extension : truncatedFileName
         }
-      </h4>
+      </div>
       <div class="uppy-DashboardItem-status">
         {file.data.size ? <div class="uppy-DashboardItem-statusSize">{prettyBytes(file.data.size)}</div> : null}
         {file.source && <div class="uppy-DashboardItem-sourceIcon">

+ 13 - 10
src/plugins/Dashboard/FileItemProgress.js

@@ -13,21 +13,24 @@ module.exports = (props) => {
   return (
     <svg width="70" height="70" viewBox="0 0 36 36" class="UppyIcon UppyIcon-progressCircle">
       <g class="progress-group">
-        <circle r="15" cx="18" cy="18" stroke-width="2" fill="none" class="bg" />
-        <circle r="15" cx="18" cy="18" transform="rotate(-90, 18, 18)" stroke-width="2" fill="none" class="progress"
+        <circle class="bg" r="15" cx="18" cy="18" stroke-width="2" fill="none" />
+        <circle class="progress" r="15" cx="18" cy="18" transform="rotate(-90, 18, 18)" stroke-width="2" fill="none"
           stroke-dasharray={circleLength}
           stroke-dashoffset={circleLength - (circleLength / 100 * props.progress)}
         />
       </g>
-      <polygon transform="translate(3, 3)" points="12 20 12 10 20 15" class="play" />
-      <g transform="translate(14.5, 13)" class="pause">
-        <rect x="0" y="0" width="2" height="10" rx="0" />
-        <rect x="5" y="0" width="2" height="10" rx="0" />
-      </g>
-      <polygon transform="translate(2, 3)" points="14 22.5 7 15.2457065 8.99985857 13.1732815 14 18.3547104 22.9729883 9 25 11.1005634" class="check" />
-      {props.hideCancelButton ? null
-        : <polygon class="cancel" transform="translate(2, 2)" points="19.8856516 11.0625 16 14.9481516 12.1019737 11.0625 11.0625 12.1143484 14.9481516 16 11.0625 19.8980263 12.1019737 20.9375 16 17.0518484 19.8856516 20.9375 20.9375 19.8980263 17.0518484 16 20.9375 12" />
+      {!props.hidePauseResumeCancelButtons && !props.bundled ? (
+        <g>
+          <polygon class="play" transform="translate(3, 3)" points="12 20 12 10 20 15" />
+          <g class="pause" transform="translate(14.5, 13)">
+            <rect x="0" y="0" width="2" height="10" rx="0" />
+            <rect x="5" y="0" width="2" height="10" rx="0" />
+          </g>
+          <polygon class="cancel" transform="translate(2, 2)" points="19.8856516 11.0625 16 14.9481516 12.1019737 11.0625 11.0625 12.1143484 14.9481516 16 11.0625 19.8980263 12.1019737 20.9375 16 17.0518484 19.8856516 20.9375 20.9375 19.8980263 17.0518484 16 20.9375 12" />
+        </g>
+        ) : null
       }
+      <polygon class="check" transform="translate(2, 3)" points="14 22.5 7 15.2457065 8.99985857 13.1732815 14 18.3547104 22.9729883 9 25 11.1005634" />
     </svg>
   )
 }

+ 9 - 1
src/plugins/Dashboard/FileList.js

@@ -4,6 +4,12 @@ const ActionBrowseTagline = require('./ActionBrowseTagline')
 const classNames = require('classnames')
 const { h } = require('preact')
 
+const poweredByUppy = (props) => {
+  return <a tabindex="-1" href="https://uppy.io" rel="noreferrer noopener" target="_blank" class="uppy-Dashboard-poweredBy">Powered by <svg aria-hidden="true" class="UppyIcon uppy-Dashboard-poweredByIcon" width="11" height="11" viewBox="0 0 11 11" xmlns="http://www.w3.org/2000/svg">
+    <path d="M7.365 10.5l-.01-4.045h2.612L5.5.806l-4.467 5.65h2.604l.01 4.044h3.718z" fill-rule="evenodd" />
+  </svg><span class="uppy-Dashboard-poweredByUppy">Uppy</span></a>
+}
+
 module.exports = (props) => {
   const noFiles = props.totalFileCount === 0
   const dashboardFilesClass = classNames(
@@ -25,6 +31,7 @@ module.exports = (props) => {
           />
         </div>
         { props.note && <div class="uppy-Dashboard-note">{props.note}</div> }
+        { props.proudlyDisplayPoweredByUppy && poweredByUppy(props) }
       </div>
     }
     {Object.keys(props.files).map((fileID) => (
@@ -40,9 +47,10 @@ module.exports = (props) => {
         pauseUpload={props.pauseUpload}
         cancelUpload={props.cancelUpload}
         retryUpload={props.retryUpload}
-        hideCancelButton={props.hideCancelButton}
+        hidePauseResumeCancelButtons={props.hidePauseResumeCancelButtons}
         hideRetryButton={props.hideRetryButton}
         resumableUploads={props.resumableUploads}
+        bundled={props.bundled}
         isWide={props.isWide}
         showLinkToFileUploadResult={props.showLinkToFileUploadResult}
         metaFields={props.metaFields}

+ 1 - 1
src/plugins/Dashboard/Tabs.js

@@ -66,7 +66,7 @@ class Tabs extends Component {
               aria-selected={this.props.activePanel.id === target.id}
               onclick={() => this.props.showPanel(target.id)}>
               {target.icon()}
-              <h5 class="uppy-DashboardTab-name">{target.name}</h5>
+              <div class="uppy-DashboardTab-name">{target.name}</div>
             </button>
           </li>
         })}

+ 100 - 54
src/plugins/Dashboard/index.js

@@ -5,7 +5,8 @@ const DashboardUI = require('./Dashboard')
 const StatusBar = require('../StatusBar')
 const Informer = require('../Informer')
 const ThumbnailGenerator = require('../ThumbnailGenerator')
-const { findAllDOMElements, toArray } = require('../../core/Utils')
+const findAllDOMElements = require('../../utils/findAllDOMElements')
+const toArray = require('../../utils/toArray')
 const prettyBytes = require('prettier-bytes')
 const { defaultTabIcon } = require('./icons')
 
@@ -13,19 +14,22 @@ const { defaultTabIcon } = require('./icons')
 // MIT licence, https://github.com/ghosh/micromodal/blob/master/LICENSE.md
 // Copyright (c) 2017 Indrashish Ghosh
 const FOCUSABLE_ELEMENTS = [
-  'a[href]',
-  'area[href]',
-  'input:not([disabled]):not([type="hidden"]):not([aria-hidden])',
-  'select:not([disabled]):not([aria-hidden])',
-  'textarea:not([disabled]):not([aria-hidden])',
-  'button:not([disabled]):not([aria-hidden])',
-  'iframe',
-  'object',
-  'embed',
-  '[contenteditable]',
-  '[tabindex]:not([tabindex^="-"])'
+  'a[href]:not([tabindex^="-"]):not([inert]):not([aria-hidden])',
+  'area[href]:not([tabindex^="-"]):not([inert]):not([aria-hidden])',
+  'input:not([disabled]):not([inert]):not([aria-hidden])',
+  'select:not([disabled]):not([inert]):not([aria-hidden])',
+  'textarea:not([disabled]):not([inert]):not([aria-hidden])',
+  'button:not([disabled]):not([inert]):not([aria-hidden])',
+  'iframe:not([tabindex^="-"]):not([inert]):not([aria-hidden])',
+  'object:not([tabindex^="-"]):not([inert]):not([aria-hidden])',
+  'embed:not([tabindex^="-"]):not([inert]):not([aria-hidden])',
+  '[contenteditable]:not([tabindex^="-"]):not([inert]):not([aria-hidden])',
+  '[tabindex]:not([tabindex^="-"]):not([inert]):not([aria-hidden])'
 ]
 
+const TAB_KEY = 9
+const ESC_KEY = 27
+
 /**
  * Dashboard UI with previews, metadata editing, tabs for various services and more
  */
@@ -99,7 +103,7 @@ module.exports = class Dashboard extends Plugin {
       showProgressDetails: false,
       hideUploadButton: false,
       hideRetryButton: false,
-      hideCancelButton: false,
+      hidePauseResumeCancelButtons: false,
       hideProgressAfterFinish: false,
       note: null,
       closeModalOnClickOutside: false,
@@ -107,6 +111,7 @@ module.exports = class Dashboard extends Plugin {
       disableInformer: false,
       disableThumbnailGenerator: false,
       disablePageScrollWhenModalOpen: true,
+      animateOpenClose: true,
       proudlyDisplayPoweredByUppy: true,
       onRequestCloseModal: () => this.closeModal(),
       locale: defaultLocale,
@@ -129,6 +134,7 @@ module.exports = class Dashboard extends Plugin {
     this.isModalOpen = this.isModalOpen.bind(this)
 
     this.addTarget = this.addTarget.bind(this)
+    this.removeTarget = this.removeTarget.bind(this)
     this.hideAllPanels = this.hideAllPanels.bind(this)
     this.showPanel = this.showPanel.bind(this)
     this.getFocusableNodes = this.getFocusableNodes.bind(this)
@@ -148,6 +154,16 @@ module.exports = class Dashboard extends Plugin {
     this.install = this.install.bind(this)
   }
 
+  removeTarget (plugin) {
+    const pluginState = this.getPluginState()
+    // filter out the one we want to remove
+    const newTargets = pluginState.targets.filter(target => target.id !== plugin.id)
+
+    this.setPluginState({
+      targets: newTargets
+    })
+  }
+
   addTarget (plugin) {
     const callerPluginId = plugin.id || plugin.constructor.name
     const callerPluginName = plugin.title || callerPluginId
@@ -270,13 +286,16 @@ module.exports = class Dashboard extends Plugin {
     this.savedActiveElement = document.activeElement
 
     if (this.opts.disablePageScrollWhenModalOpen) {
-      document.body.classList.add('uppy-Dashboard-isOpen')
+      document.body.classList.add('uppy-Dashboard-isFixed')
     }
 
     if (this.opts.browserBackButtonClose) {
       this.updateBrowserHistory()
     }
 
+    // handle ESC and TAB keys in modal dialog
+    document.addEventListener('keydown', this.onKeydown)
+
     this.rerender(this.uppy.getState())
     this.updateDashboardElWidth()
     this.setFocusToBrowse()
@@ -287,14 +306,31 @@ module.exports = class Dashboard extends Plugin {
       manualClose = true // Whether the modal is being closed by the user (`true`) or by other means (e.g. browser back button)
     } = opts
 
-    this.setPluginState({
-      isHidden: true
-    })
-
     if (this.opts.disablePageScrollWhenModalOpen) {
-      document.body.classList.remove('uppy-Dashboard-isOpen')
+      document.body.classList.remove('uppy-Dashboard-isFixed')
+    }
+
+    if (this.opts.animateOpenClose) {
+      this.setPluginState({
+        isClosing: true
+      })
+      const handler = () => {
+        this.setPluginState({
+          isHidden: true,
+          isClosing: false
+        })
+        this.el.removeEventListener('animationend', handler, false)
+      }
+      this.el.addEventListener('animationend', handler, false)
+    } else {
+      this.setPluginState({
+        isHidden: true
+      })
     }
 
+    // handle ESC and TAB keys in modal dialog
+    document.removeEventListener('keydown', this.onKeydown)
+
     this.savedActiveElement.focus()
 
     if (manualClose) {
@@ -314,9 +350,9 @@ module.exports = class Dashboard extends Plugin {
 
   onKeydown (event) {
     // close modal on esc key press
-    if (event.keyCode === 27) this.requestCloseModal(event)
+    if (event.keyCode === ESC_KEY) this.requestCloseModal(event)
     // maintainFocus on tab key press
-    if (event.keyCode === 9) this.maintainFocus(event)
+    if (event.keyCode === TAB_KEY) this.maintainFocus(event)
   }
 
   handleClickOutside () {
@@ -335,12 +371,16 @@ module.exports = class Dashboard extends Plugin {
         return
       }
       this.uppy.log('[Dashboard] File pasted')
-      this.uppy.addFile({
-        source: this.id,
-        name: file.name,
-        type: file.type,
-        data: blob
-      })
+      try {
+        this.uppy.addFile({
+          source: this.id,
+          name: file.name,
+          type: file.type,
+          data: blob
+        })
+      } catch (err) {
+        // Nothing, restriction errors handled in Core
+      }
     })
   }
 
@@ -349,12 +389,16 @@ module.exports = class Dashboard extends Plugin {
     const files = toArray(ev.target.files)
 
     files.forEach((file) => {
-      this.uppy.addFile({
-        source: this.id,
-        name: file.name,
-        type: file.type,
-        data: file
-      })
+      try {
+        this.uppy.addFile({
+          source: this.id,
+          name: file.name,
+          type: file.type,
+          data: file
+        })
+      } catch (err) {
+        // Nothing, restriction errors handled in Core
+      }
     })
   }
 
@@ -369,10 +413,6 @@ module.exports = class Dashboard extends Plugin {
       this.uppy.log('Dashboard modal trigger not found. Make sure `trigger` is set in Dashboard options unless you are planning to call openModal() method yourself')
     }
 
-    if (!this.opts.inline) {
-      document.addEventListener('keydown', this.onKeydown)
-    }
-
     // Drag Drop
     this.removeDragDropListener = dragDrop(this.el, (files) => {
       this.handleDrop(files)
@@ -380,6 +420,8 @@ module.exports = class Dashboard extends Plugin {
 
     this.updateDashboardElWidth()
     window.addEventListener('resize', this.updateDashboardElWidth)
+
+    this.uppy.on('plugin-remove', this.removeTarget)
   }
 
   removeEvents () {
@@ -388,13 +430,10 @@ module.exports = class Dashboard extends Plugin {
       showModalTrigger.forEach(trigger => trigger.removeEventListener('click', this.openModal))
     }
 
-    if (!this.opts.inline) {
-      document.removeEventListener('keydown', this.onKeydown)
-    }
-
     this.removeDragDropListener()
     window.removeEventListener('resize', this.updateDashboardElWidth)
     window.removeEventListener('popstate', this.handlePopState, false)
+    this.uppy.off('plugin-remove', this.removeTarget)
   }
 
   updateDashboardElWidth () {
@@ -416,18 +455,22 @@ module.exports = class Dashboard extends Plugin {
     this.uppy.log('[Dashboard] Files were dropped')
 
     files.forEach((file) => {
-      this.uppy.addFile({
-        source: this.id,
-        name: file.name,
-        type: file.type,
-        data: file
-      })
+      try {
+        this.uppy.addFile({
+          source: this.id,
+          name: file.name,
+          type: file.type,
+          data: file
+        })
+      } catch (err) {
+        // Nothing, restriction errors handled in Core
+      }
     })
   }
 
   render (state) {
     const pluginState = this.getPluginState()
-    const files = state.files
+    const { files, capabilities } = state
 
     const newFiles = Object.keys(files).filter((file) => {
       return !files[file].progress.uploadStarted
@@ -503,12 +546,14 @@ module.exports = class Dashboard extends Plugin {
       totalProgress: state.totalProgress,
       acquirers: acquirers,
       activePanel: pluginState.activePanel,
+      animateOpenClose: this.opts.animateOpenClose,
+      isClosing: pluginState.isClosing,
       getPlugin: this.uppy.getPlugin,
       progressindicators: progressindicators,
       autoProceed: this.uppy.opts.autoProceed,
       hideUploadButton: this.opts.hideUploadButton,
       hideRetryButton: this.opts.hideRetryButton,
-      hideCancelButton: this.opts.hideCancelButton,
+      hidePauseResumeCancelButtons: this.opts.hidePauseResumeCancelButtons,
       id: this.id,
       closeModal: this.requestCloseModal,
       handleClickOutside: this.handleClickOutside,
@@ -525,7 +570,8 @@ module.exports = class Dashboard extends Plugin {
       info: this.uppy.info,
       note: this.opts.note,
       metaFields: pluginState.metaFields,
-      resumableUploads: this.uppy.state.capabilities.resumableUploads || false,
+      resumableUploads: capabilities.resumableUploads || false,
+      bundled: capabilities.bundled || false,
       startUpload: startUpload,
       pauseUpload: this.uppy.pauseResume,
       retryUpload: this.uppy.retryUpload,
@@ -581,7 +627,7 @@ module.exports = class Dashboard extends Plugin {
         target: this,
         hideUploadButton: this.opts.hideUploadButton,
         hideRetryButton: this.opts.hideRetryButton,
-        hideCancelButton: this.opts.hideCancelButton,
+        hidePauseResumeCancelButtons: this.opts.hidePauseResumeCancelButtons,
         showProgressDetails: this.opts.showProgressDetails,
         hideAfterFinish: this.opts.hideProgressAfterFinish,
         locale: this.opts.locale
@@ -609,19 +655,19 @@ module.exports = class Dashboard extends Plugin {
 
   uninstall () {
     if (!this.opts.disableInformer) {
-      const informer = this.uppy.getPlugin('Informer')
+      const informer = this.uppy.getPlugin(`${this.id}:Informer`)
       // Checking if this plugin exists, in case it was removed by uppy-core
       // before the Dashboard was.
       if (informer) this.uppy.removePlugin(informer)
     }
 
     if (!this.opts.disableStatusBar) {
-      const statusBar = this.uppy.getPlugin('StatusBar')
+      const statusBar = this.uppy.getPlugin(`${this.id}:StatusBar`)
       if (statusBar) this.uppy.removePlugin(statusBar)
     }
 
     if (!this.opts.disableThumbnailGenerator) {
-      const thumbnail = this.uppy.getPlugin('ThumbnailGenerator')
+      const thumbnail = this.uppy.getPlugin(`${this.id}:ThumbnailGenerator`)
       if (thumbnail) this.uppy.removePlugin(thumbnail)
     }
 

+ 21 - 13
src/plugins/DragDrop/index.js

@@ -1,6 +1,6 @@
 const Plugin = require('../../core/Plugin')
 const Translator = require('../../core/Translator')
-const { toArray } = require('../../core/Utils')
+const toArray = require('../../utils/toArray')
 const dragDrop = require('drag-drop')
 const { h } = require('preact')
 
@@ -79,12 +79,16 @@ module.exports = class DragDrop extends Plugin {
     this.uppy.log('[DragDrop] Files dropped')
 
     files.forEach((file) => {
-      this.uppy.addFile({
-        source: this.id,
-        name: file.name,
-        type: file.type,
-        data: file
-      })
+      try {
+        this.uppy.addFile({
+          source: this.id,
+          name: file.name,
+          type: file.type,
+          data: file
+        })
+      } catch (err) {
+        // Nothing, restriction errors handled in Core
+      }
     })
   }
 
@@ -94,12 +98,16 @@ module.exports = class DragDrop extends Plugin {
     const files = toArray(ev.target.files)
 
     files.forEach((file) => {
-      this.uppy.addFile({
-        source: this.id,
-        name: file.name,
-        type: file.type,
-        data: file
-      })
+      try {
+        this.uppy.addFile({
+          source: this.id,
+          name: file.name,
+          type: file.type,
+          data: file
+        })
+      } catch (err) {
+        // Nothing, restriction errors handled in Core
+      }
     })
   }
 

+ 1 - 0
src/plugins/Dropbox/index.js

@@ -35,6 +35,7 @@ module.exports = class Dropbox extends Plugin {
 
     // merge default options with the ones set by user
     this.opts = Object.assign({}, defaultOptions, opts)
+    this.opts.hostPattern = opts.hostPattern || opts.host
   }
 
   install () {

+ 5 - 1
src/plugins/Dummy.js

@@ -35,7 +35,11 @@ module.exports = class Dummy extends Plugin {
       data: blob
     }
     this.props.log('Adding fake file blob')
-    this.props.addFile(file)
+    try {
+      this.props.addFile(file)
+    } catch (err) {
+      // Nothing, restriction errors handled in Core
+    }
   }
 
   render (state) {

+ 11 - 7
src/plugins/FileInput.js

@@ -1,5 +1,5 @@
 const Plugin = require('../core/Plugin')
-const { toArray } = require('../core/Utils')
+const toArray = require('../utils/toArray')
 const Translator = require('../core/Translator')
 const { h } = require('preact')
 
@@ -45,12 +45,16 @@ module.exports = class FileInput extends Plugin {
     const files = toArray(ev.target.files)
 
     files.forEach((file) => {
-      this.uppy.addFile({
-        source: this.id,
-        name: file.name,
-        type: file.type,
-        data: file
-      })
+      try {
+        this.uppy.addFile({
+          source: this.id,
+          name: file.name,
+          type: file.type,
+          data: file
+        })
+      } catch (err) {
+        // Nothing, restriction errors handled in Core
+      }
     })
   }
 

+ 1 - 1
src/plugins/Form.js

@@ -1,5 +1,5 @@
 const Plugin = require('../core/Plugin')
-const { findDOMElement } = require('../core/Utils')
+const findDOMElement = require('../utils/findDOMElement')
 // Rollup uses get-form-data's ES modules build, and rollup-plugin-commonjs automatically resolves `.default`.
 // So, if we are being built using rollup, this require() won't have a `.default` property.
 const getFormData = require('get-form-data').default || require('get-form-data')

+ 6 - 0
src/plugins/GoldenRetriever/.eslintrc.json

@@ -0,0 +1,6 @@
+{
+  "extends": "../../../.eslintrc.json",
+  "settings": {
+    "polyfills": ["serviceworkers"]
+  }
+}

+ 8 - 9
src/plugins/GoldenRetriever/index.js

@@ -65,11 +65,9 @@ module.exports = class GoldenRetriever extends Plugin {
   getWaitingFiles () {
     const waitingFiles = {}
 
-    const allFiles = this.uppy.state.files
-    Object.keys(allFiles).forEach((fileID) => {
-      const file = this.uppy.getFile(fileID)
+    this.uppy.getFiles().forEach((file) => {
       if (!file.progress || !file.progress.uploadStarted) {
-        waitingFiles[fileID] = file
+        waitingFiles[file.id] = file
       }
     })
 
@@ -84,7 +82,7 @@ module.exports = class GoldenRetriever extends Plugin {
   getUploadingFiles () {
     const uploadingFiles = {}
 
-    const { currentUploads } = this.uppy.state
+    const { currentUploads } = this.uppy.getState()
     if (currentUploads) {
       const uploadIDs = Object.keys(currentUploads)
       uploadIDs.forEach((uploadID) => {
@@ -112,8 +110,9 @@ module.exports = class GoldenRetriever extends Plugin {
       Object.assign(pluginData, data)
     })
 
+    const { currentUploads } = this.uppy.getState()
     this.MetaDataStore.save({
-      currentUploads: this.uppy.state.currentUploads,
+      currentUploads: currentUploads,
       files: filesToSave,
       pluginData: pluginData
     })
@@ -122,7 +121,7 @@ module.exports = class GoldenRetriever extends Plugin {
   loadFileBlobsFromServiceWorker () {
     this.ServiceWorkerStore.list().then((blobs) => {
       const numberOfFilesRecovered = Object.keys(blobs).length
-      const numberOfFilesTryingToRecover = Object.keys(this.uppy.state.files).length
+      const numberOfFilesTryingToRecover = this.uppy.getFiles().length
       if (numberOfFilesRecovered === numberOfFilesTryingToRecover) {
         this.uppy.log(`[GoldenRetriever] Successfully recovered ${numberOfFilesRecovered} blobs from Service Worker!`)
         this.uppy.info(`Successfully recovered ${numberOfFilesRecovered} files`, 'success', 3000)
@@ -154,7 +153,7 @@ module.exports = class GoldenRetriever extends Plugin {
 
   onBlobsLoaded (blobs) {
     const obsoleteBlobs = []
-    const updatedFiles = Object.assign({}, this.uppy.state.files)
+    const updatedFiles = Object.assign({}, this.uppy.getState().files)
     Object.keys(blobs).forEach((fileID) => {
       const originalFile = this.uppy.getFile(fileID)
       if (!originalFile) {
@@ -204,7 +203,7 @@ module.exports = class GoldenRetriever extends Plugin {
   install () {
     this.loadFilesStateFromLocalStorage()
 
-    if (Object.keys(this.uppy.state.files).length > 0) {
+    if (this.uppy.getFiles().length > 0) {
       if (this.ServiceWorkerStore) {
         this.uppy.log('[GoldenRetriever] Attempting to load files from Service Worker...')
         this.loadFileBlobsFromServiceWorker()

+ 1 - 0
src/plugins/GoogleDrive/index.js

@@ -30,6 +30,7 @@ module.exports = class GoogleDrive extends Plugin {
 
     // merge default options with the ones set by user
     this.opts = Object.assign({}, defaultOptions, opts)
+    this.opts.hostPattern = opts.hostPattern || opts.host
   }
 
   install () {

+ 7 - 1
src/plugins/Instagram/index.js

@@ -33,6 +33,7 @@ module.exports = class Instagram extends Plugin {
 
     // merge default options with the ones set by user
     this.opts = Object.assign({}, defaultOptions, opts)
+    this.opts.hostPattern = opts.hostPattern || opts.host
   }
 
   install () {
@@ -84,7 +85,12 @@ module.exports = class Instagram extends Plugin {
   }
 
   getItemIcon (item) {
-    return <img src={item.images.thumbnail.url} />
+    if (!item.images) {
+      return <svg viewBox="0 0 58 58" opacity="0.6">
+        <path d="M36.537 28.156l-11-7a1.005 1.005 0 0 0-1.02-.033C24.2 21.3 24 21.635 24 22v14a1 1 0 0 0 1.537.844l11-7a1.002 1.002 0 0 0 0-1.688zM26 34.18V23.82L34.137 29 26 34.18z" /><path d="M57 6H1a1 1 0 0 0-1 1v44a1 1 0 0 0 1 1h56a1 1 0 0 0 1-1V7a1 1 0 0 0-1-1zM10 28H2v-9h8v9zm-8 2h8v9H2v-9zm10 10V8h34v42H12V40zm44-12h-8v-9h8v9zm-8 2h8v9h-8v-9zm8-22v9h-8V8h8zM2 8h8v9H2V8zm0 42v-9h8v9H2zm54 0h-8v-9h8v9z" />
+      </svg>
+    }
+    return <img src={item.images.low_resolution.url} />
   }
 
   getItemSubList (item) {

+ 4 - 4
src/plugins/ReduxDevTools.js

@@ -40,13 +40,13 @@ module.exports = class ReduxDevTools extends Plugin {
             return
           case 'IMPORT_STATE':
             const computedStates = message.payload.nextLiftedState.computedStates
-            this.uppy.state = Object.assign({}, this.uppy.state, computedStates[computedStates.length - 1].state)
-            this.uppy.updateAll(this.uppy.state)
+            this.uppy.store.state = Object.assign({}, this.uppy.getState(), computedStates[computedStates.length - 1].state)
+            this.uppy.updateAll(this.uppy.getState())
             return
           case 'JUMP_TO_STATE':
           case 'JUMP_TO_ACTION':
-            this.uppy.store.state = Object.assign({}, this.uppy.state, JSON.parse(message.state))
-            this.uppy.updateAll(this.uppy.state)
+            this.uppy.store.state = Object.assign({}, this.uppy.getState(), JSON.parse(message.state))
+            this.uppy.updateAll(this.uppy.getState())
         }
       }
     })

+ 9 - 8
src/plugins/StatusBar/StatusBar.js

@@ -100,7 +100,7 @@ module.exports = (props) => {
       <div class="uppy-StatusBar-actions">
         { props.newFiles && !props.hideUploadButton ? <UploadBtn {...props} uploadState={uploadState} /> : null }
         { props.error && !props.hideRetryButton ? <RetryBtn {...props} /> : null }
-        { !props.hideCancelButton && uploadState !== statusBarStates.STATE_WAITING && uploadState !== statusBarStates.STATE_COMPLETE
+        { !props.hidePauseResumeCancelButtons && uploadState !== statusBarStates.STATE_WAITING && uploadState !== statusBarStates.STATE_COMPLETE
           ? <CancelBtn {...props} />
           : null
         }
@@ -116,7 +116,6 @@ const UploadBtn = (props) => {
     'uppy-StatusBar-actionBtn',
     'uppy-StatusBar-actionBtn--upload',
     { 'uppy-c-btn-primary': props.uploadState === statusBarStates.STATE_WAITING }
-    // { 'uppy-StatusBar-actionBtn uppy-StatusBar-actionBtn--upload': props.uploadState !== statusBarStates.STATE_WAITING }
   )
 
   return <button type="button"
@@ -161,7 +160,7 @@ const PauseResumeButtons = (props) => {
         : <svg aria-hidden="true" class="UppyIcon" width="16" height="17" viewBox="0 0 12 13">
           <path d="M4.888.81v11.38c0 .446-.324.81-.722.81H2.722C2.324 13 2 12.636 2 12.19V.81c0-.446.324-.81.722-.81h1.444c.398 0 .722.364.722.81zM9.888.81v11.38c0 .446-.324.81-.722.81H7.722C7.324 13 7 12.636 7 12.19V.81c0-.446.324-.81.722-.81h1.444c.398 0 .722.364.722.81z" />
         </svg>
-      : props.hideCancelButton ? null : <svg aria-hidden="true" class="UppyIcon" width="16px" height="16px" viewBox="0 0 19 19">
+      : <svg aria-hidden="true" class="UppyIcon" width="16px" height="16px" viewBox="0 0 19 19">
         <path d="M17.318 17.232L9.94 9.854 9.586 9.5l-.354.354-7.378 7.378h.707l-.62-.62v.706L9.318 9.94l.354-.354-.354-.354L1.94 1.854v.707l.62-.62h-.706l7.378 7.378.354.354.354-.354 7.378-7.378h-.707l.622.62v-.706L9.854 9.232l-.354.354.354.354 7.378 7.378.708-.707-7.38-7.378v.708l7.38-7.38.353-.353-.353-.353-.622-.622-.353-.353-.354.352-7.378 7.38h.708L2.56 1.23 2.208.88l-.353.353-.622.62-.353.355.352.353 7.38 7.38v-.708l-7.38 7.38-.353.353.352.353.622.622.353.353.354-.353 7.38-7.38h-.708l7.38 7.38z" />
       </svg>
     }
@@ -192,11 +191,13 @@ const ProgressBarUploading = (props) => {
     return null
   }
 
+  const title = props.isAllPaused ? props.i18n('paused') : props.i18n('uploading')
+
   return (
-    <div class="uppy-StatusBar-content" title={props.isAllPaused ? props.i18n('paused') : props.i18n('uploading')}>
-      { <PauseResumeButtons {...props} /> }
+    <div class="uppy-StatusBar-content" aria-label={title} title={title}>
+      { !props.hidePauseResumeCancelButtons && <PauseResumeButtons {...props} /> }
       <div class="uppy-StatusBar-status">
-        <span class="uppy-StatusBar-statusPrimary">{ props.isAllPaused ? props.i18n('paused') : props.i18n('uploading') }: {props.totalProgress}%</span>
+        <span class="uppy-StatusBar-statusPrimary">{title}: {props.totalProgress}%</span>
         <br />
         { !props.isAllPaused && <ThrottledProgressDetails {...props} /> }
       </div>
@@ -215,11 +216,11 @@ const ProgressBarComplete = ({ totalProgress, i18n }) => {
   )
 }
 
-const ProgressBarError = ({ error, retryAll, i18n }) => {
+const ProgressBarError = ({ error, retryAll, hideRetryButton, i18n }) => {
   return (
     <div class="uppy-StatusBar-content" role="alert">
       <strong class="uppy-StatusBar-contentPadding">{i18n('uploadFailed')}.</strong>
-      <span class="uppy-StatusBar-contentPadding">{i18n('pleasePressRetry')}</span>
+      { !hideRetryButton && <span class="uppy-StatusBar-contentPadding">{i18n('pleasePressRetry')}</span> }
       <span class="uppy-StatusBar-details"
         aria-label={error}
         data-microtip-position="top"

+ 5 - 5
src/plugins/StatusBar/index.js

@@ -2,9 +2,9 @@ const Plugin = require('../../core/Plugin')
 const Translator = require('../../core/Translator')
 const StatusBarUI = require('./StatusBar')
 const statusBarStates = require('./StatusBarStates')
-const { getSpeed } = require('../../core/Utils')
-const { getBytesRemaining } = require('../../core/Utils')
-const { prettyETA } = require('../../core/Utils')
+const getSpeed = require('../../utils/getSpeed')
+const getBytesRemaining = require('../../utils/getBytesRemaining')
+const prettyETA = require('../../utils/prettyETA')
 const prettyBytes = require('prettier-bytes')
 
 /**
@@ -55,7 +55,7 @@ module.exports = class StatusBar extends Plugin {
       target: 'body',
       hideUploadButton: false,
       hideRetryButton: false,
-      hideCancelButton: false,
+      hidePauseResumeCancelButtons: false,
       showProgressDetails: false,
       locale: defaultLocale,
       hideAfterFinish: true
@@ -220,7 +220,7 @@ module.exports = class StatusBar extends Plugin {
       showProgressDetails: this.opts.showProgressDetails,
       hideUploadButton: this.opts.hideUploadButton,
       hideRetryButton: this.opts.hideRetryButton,
-      hideCancelButton: this.opts.hideCancelButton,
+      hidePauseResumeCancelButtons: this.opts.hidePauseResumeCancelButtons,
       hideAfterFinish: this.opts.hideAfterFinish
     })
   }

+ 11 - 12
src/plugins/ThumbnailGenerator/index.js

@@ -1,5 +1,7 @@
 const Plugin = require('../../core/Plugin')
-const Utils = require('../../core/Utils')
+const dataURItoBlob = require('../../utils/dataURItoBlob')
+const isPreviewSupported = require('../../utils/isPreviewSupported')
+
 /**
  * The Thumbnail Generator plugin
  *
@@ -9,7 +11,7 @@ module.exports = class ThumbnailGenerator extends Plugin {
   constructor (uppy, opts) {
     super(uppy, opts)
     this.type = 'thumbnail'
-    this.id = 'ThumbnailGenerator'
+    this.id = this.opts.id || 'ThumbnailGenerator'
     this.title = 'Thumbnail Generator'
     this.queue = []
     this.queueProcessing = false
@@ -105,7 +107,9 @@ module.exports = class ThumbnailGenerator extends Plugin {
 
     image = this.protect(image)
 
-    var steps = Math.ceil(Math.log2(image.width / targetWidth))
+    // Use the Polyfill for Math.log2() since IE doesn't support log2
+    // https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Math/log2#Polyfill
+    var steps = Math.ceil(Math.log(image.width / targetWidth) * Math.LOG2E)
     if (steps < 1) {
       steps = 1
     }
@@ -140,7 +144,7 @@ module.exports = class ThumbnailGenerator extends Plugin {
       })
     }
     return Promise.resolve().then(() => {
-      return Utils.dataURItoBlob(canvas.toDataURL(type, quality), {})
+      return dataURItoBlob(canvas.toDataURL(type, quality), {})
     })
   }
 
@@ -153,13 +157,8 @@ module.exports = class ThumbnailGenerator extends Plugin {
    * Set the preview URL for a file.
    */
   setPreviewURL (fileID, preview) {
-    const { files } = this.uppy.state
-    this.uppy.setState({
-      files: Object.assign({}, files, {
-        [fileID]: Object.assign({}, files[fileID], {
-          preview: preview
-        })
-      })
+    this.uppy.setFileState(fileID, {
+      preview: preview
     })
   }
 
@@ -183,7 +182,7 @@ module.exports = class ThumbnailGenerator extends Plugin {
   }
 
   requestThumbnail (file) {
-    if (Utils.isPreviewSupported(file.type) && !file.isRemote) {
+    if (isPreviewSupported(file.type) && !file.isRemote) {
       return this.createThumbnail(file, this.opts.thumbnailWidth)
         .then(preview => {
           this.setPreviewURL(file.id, preview)

+ 4 - 4
src/plugins/ThumbnailGenerator/index.test.js

@@ -186,13 +186,13 @@ describe('uploader/ThumbnailGeneratorPlugin', () => {
             }
           }
         },
-        setState: jest.fn()
+        setFileState: jest.fn()
       }
       const plugin = new ThumbnailGeneratorPlugin(core)
       plugin.setPreviewURL('file1', 'moo')
-      expect(core.setState).toHaveBeenCalledTimes(1)
-      expect(core.setState).toHaveBeenCalledWith({
-        files: { file1: { preview: 'moo' }, file2: { preview: 'boo' } }
+      expect(core.setFileState).toHaveBeenCalledTimes(1)
+      expect(core.setFileState).toHaveBeenCalledWith('file1', {
+        preview: 'moo'
       })
     })
   })

+ 3 - 3
src/plugins/Transloadit/Client.js

@@ -36,9 +36,9 @@ module.exports = class Client {
       body: data
     }).then((response) => response.json()).then((assembly) => {
       if (assembly.error) {
-        const error = new Error(assembly.message)
-        error.code = assembly.error
-        error.status = assembly
+        const error = new Error(assembly.error)
+        error.message = assembly.error
+        error.details = assembly.reason
         throw error
       }
 

+ 18 - 18
src/plugins/Transloadit/index.js

@@ -190,7 +190,7 @@ module.exports = class Transloadit extends Plugin {
         // We only replace the hostname for Transloadit's uppy-servers, so that
         // people can self-host them while still using Transloadit for encoding.
         let remote = file.remote
-        if (file.remote && TL_UPPY_SERVER.test(file.remote)) {
+        if (file.remote && TL_UPPY_SERVER.test(file.remote.host)) {
           let newHost = assembly.uppyserver_url
           let path = file.remote.url.replace(file.remote.host, '')
           // remove tailing slash
@@ -216,7 +216,7 @@ module.exports = class Transloadit extends Plugin {
         return newFile
       }
 
-      const files = Object.assign({}, this.uppy.state.files)
+      const files = Object.assign({}, this.uppy.getState().files)
       fileIDs.forEach((id) => {
         files[id] = attachAssemblyMetadata(files[id], assembly)
       })
@@ -231,7 +231,8 @@ module.exports = class Transloadit extends Plugin {
       this.uppy.log(`[Transloadit] Created Assembly ${assembly.assembly_id}`)
       return assembly
     }).catch((err) => {
-      this.uppy.info(this.i18n('creatingAssemblyFailed'), 'error', 0)
+      // this.uppy.info(this.i18n('creatingAssemblyFailed'), 'error', 0)
+      err.message = `${this.i18n('creatingAssemblyFailed')}: ${err.message}`
 
       // Reject the promise.
       throw err
@@ -272,23 +273,21 @@ module.exports = class Transloadit extends Plugin {
   }
 
   findFile (uploadedFile) {
-    const files = this.uppy.state.files
-    for (const id in files) {
-      if (!files.hasOwnProperty(id)) {
-        continue
-      }
+    const files = this.uppy.getFiles()
+    for (let i = 0; i < files.length; i++) {
+      const file = files[i]
       // Completed file upload.
-      if (files[id].uploadURL === uploadedFile.tus_upload_url) {
-        return files[id]
+      if (file.uploadURL === uploadedFile.tus_upload_url) {
+        return file
       }
       // In-progress file upload.
-      if (files[id].tus && files[id].tus.uploadUrl === uploadedFile.tus_upload_url) {
-        return files[id]
+      if (file.tus && file.tus.uploadUrl === uploadedFile.tus_upload_url) {
+        return file
       }
       if (!uploadedFile.is_tus_file) {
         // Fingers-crossed check for non-tus uploads, eg imported from S3.
-        if (files[id].name === uploadedFile.name && files[id].size === uploadedFile.size) {
-          return files[id]
+        if (file.name === uploadedFile.name && file.size === uploadedFile.size) {
+          return file
         }
       }
     }
@@ -727,6 +726,10 @@ module.exports = class Transloadit extends Plugin {
         }
         this.uppy.log(`[Transloadit] afterUpload(): Got Assembly error ${assembly.assembly_id}`)
         this.uppy.log(error)
+        // this.uppy.info({
+        //   message: error.code,
+        //   details: error.status.reason
+        // }, 'error', 5000)
 
         // Clear postprocessing state for all our files.
         const files = this.getAssemblyFiles(assembly.assembly_id)
@@ -852,10 +855,7 @@ module.exports = class Transloadit extends Plugin {
   }
 
   getAssemblyFiles (assemblyID) {
-    const fileIDs = Object.keys(this.uppy.state.files)
-    return fileIDs.map((fileID) => {
-      return this.uppy.getFile(fileID)
-    }).filter((file) => {
+    return this.uppy.getFiles().filter((file) => {
       return file && file.transloadit && file.transloadit.assembly === assemblyID
     })
   }

+ 2 - 2
src/plugins/Transloadit/index.test.js

@@ -199,7 +199,7 @@ describe('Transloadit', () => {
     })
 
     uppy.getPlugin('Transloadit').client.createAssembly = () =>
-      Promise.reject(new Error('Could not create assembly!'))
+      Promise.reject(new Error('VIDEO_ENCODE_VALIDATION'))
 
     uppy.addFile({
       source: 'jest',
@@ -212,7 +212,7 @@ describe('Transloadit', () => {
     }, (err) => {
       const fileID = Object.keys(uppy.getState().files)[0]
 
-      expect(err.message).toBe('Could not create assembly!')
+      expect(err.message).toBe('Transloadit: Could not create Assembly: VIDEO_ENCODE_VALIDATION')
       expect(uppy.getFile(fileID).progress.uploadStarted).toBe(false)
     })
   })

+ 26 - 43
src/plugins/Tus.js

@@ -1,12 +1,11 @@
 const Plugin = require('../core/Plugin')
 const tus = require('tus-js-client')
 const UppySocket = require('../core/UppySocket')
-const {
-  emitSocketProgress,
-  getSocketHost,
-  settle,
-  limitPromises
-} = require('../core/Utils')
+const { Provider, RequestClient } = require('../server')
+const emitSocketProgress = require('../utils/emitSocketProgress')
+const getSocketHost = require('../utils/getSocketHost')
+const settle = require('../utils/settle')
+const limitPromises = require('../utils/limitPromises')
 require('whatwg-fetch')
 
 // Extracted from https://github.com/tus/tus-js-client/blob/master/lib/upload.js#L13
@@ -85,7 +84,7 @@ module.exports = class Tus extends Plugin {
   }
 
   handleResetProgress () {
-    const files = Object.assign({}, this.uppy.state.files)
+    const files = Object.assign({}, this.uppy.getState().files)
     Object.keys(files).forEach((fileID) => {
       // Only clone the file object if it has a Tus `uploadUrl` attached.
       if (files[fileID].tus && files[fileID].tus.uploadUrl) {
@@ -238,31 +237,22 @@ module.exports = class Tus extends Plugin {
           .catch(reject)
       }
 
-      fetch(file.remote.url, {
-        method: 'post',
-        credentials: 'include',
-        headers: {
-          'Accept': 'application/json',
-          'Content-Type': 'application/json'
-        },
-        body: JSON.stringify(Object.assign({}, file.remote.body, {
+      this.uppy.emit('upload-started', file)
+      const Client = file.remote.providerOptions.provider ? Provider : RequestClient
+      const client = new Client(this.uppy, file.remote.providerOptions)
+      client.post(
+        file.remote.url,
+        Object.assign({}, file.remote.body, {
           endpoint: opts.endpoint,
           uploadUrl: opts.uploadUrl,
           protocol: 'tus',
           size: file.data.size,
           metadata: file.meta
-        }))
-      })
-      .then((res) => {
-        if (res.status < 200 || res.status > 300) {
-          return reject(res.statusText)
-        }
-
-        return res.json().then((data) => {
-          this.uppy.setFileState(file.id, { serverToken: data.token })
-          file = this.uppy.getFile(file.id)
-          return file
         })
+      ).then((res) => {
+        this.uppy.setFileState(file.id, { serverToken: res.token })
+        file = this.uppy.getFile(file.id)
+        return file
       })
       .then((file) => {
         return this.connectToServerSocket(file)
@@ -346,13 +336,6 @@ module.exports = class Tus extends Plugin {
     })
   }
 
-  updateFile (file) {
-    const files = Object.assign({}, this.uppy.state.files, {
-      [file.id]: file
-    })
-    this.uppy.setState({ files })
-  }
-
   onReceiveUploadUrl (file, uploadURL) {
     const currentFile = this.uppy.getFile(file.id)
     if (!currentFile) return
@@ -360,12 +343,11 @@ module.exports = class Tus extends Plugin {
     // or resume: false in options
     if ((!currentFile.tus || currentFile.tus.uploadUrl !== uploadURL) && this.opts.resume) {
       this.uppy.log('[Tus] Storing upload url')
-      const newFile = Object.assign({}, currentFile, {
+      this.uppy.setFileState(currentFile.id, {
         tus: Object.assign({}, currentFile.tus, {
           uploadUrl: uploadURL
         })
       })
-      this.updateFile(newFile)
     }
   }
 
@@ -459,16 +441,12 @@ module.exports = class Tus extends Plugin {
       .then(() => null)
   }
 
-  addResumableUploadsCapabilityFlag () {
-    const newCapabilities = Object.assign({}, this.uppy.getState().capabilities)
-    newCapabilities.resumableUploads = true
+  install () {
     this.uppy.setState({
-      capabilities: newCapabilities
+      capabilities: Object.assign({}, this.uppy.getState().capabilities, {
+        resumableUploads: true
+      })
     })
-  }
-
-  install () {
-    this.addResumableUploadsCapabilityFlag()
     this.uppy.addUploader(this.handleUpload)
 
     this.uppy.on('reset-progress', this.handleResetProgress)
@@ -479,6 +457,11 @@ module.exports = class Tus extends Plugin {
   }
 
   uninstall () {
+    this.uppy.setState({
+      capabilities: Object.assign({}, this.uppy.getState().capabilities, {
+        resumableUploads: false
+      })
+    })
     this.uppy.removeUploader(this.handleUpload)
 
     if (this.opts.autoRetry) {

+ 1 - 0
src/plugins/Url/UrlUI.js

@@ -13,6 +13,7 @@ class UrlUI extends Component {
     // Component is mounted right away, but the tab panel might be animating
     // still, so input element is positioned outside viewport. This fixes it.
     setTimeout(() => {
+      if (!this.input) return
       this.input.focus({ preventScroll: true })
     }, 150)
   }

+ 10 - 6
src/plugins/Url/index.js

@@ -3,8 +3,7 @@ const Translator = require('../../core/Translator')
 const { h } = require('preact')
 const { RequestClient } = require('../../server')
 const UrlUI = require('./UrlUI.js')
-const { toArray } = require('../../core/Utils')
-require('whatwg-fetch')
+const toArray = require('../../utils/toArray')
 
 /**
  * Url
@@ -61,7 +60,7 @@ module.exports = class Url extends Plugin {
 
     this.handlePaste = this.handlePaste.bind(this)
 
-    this.server = new RequestClient(uppy, {host: this.opts.host})
+    this.client = new RequestClient(uppy, {host: this.opts.host})
   }
 
   getFileNameFromUrl (url) {
@@ -90,7 +89,7 @@ module.exports = class Url extends Plugin {
   }
 
   getMeta (url) {
-    return this.server.post('url/meta', { url })
+    return this.client.post('url/meta', { url })
       .then((res) => {
         if (res.error) {
           this.uppy.log('[URL] Error:')
@@ -128,14 +127,19 @@ module.exports = class Url extends Plugin {
             body: {
               fileId: url,
               url: url
-            }
+            },
+            providerOptions: this.client.opts
           }
         }
         return tagFile
       })
       .then((tagFile) => {
         this.uppy.log('[Url] Adding remote file')
-        return this.uppy.addFile(tagFile)
+        try {
+          this.uppy.addFile(tagFile)
+        } catch (err) {
+          // Nothing, restriction errors handled in Core
+        }
       })
       .then(() => {
         const dashboard = this.uppy.getPlugin('Dashboard')

+ 3 - 2
src/plugins/Webcam/RecordButton.js

@@ -1,6 +1,7 @@
 const { h } = require('preact')
 
 module.exports = function RecordButton ({ recording, onStartRecording, onStopRecording, i18n }) {
+  console.log('is recording', recording)
   if (recording) {
     return (
       <button class="uppy-u-reset uppy-c-btn uppy-Webcam-button uppy-Webcam-button--video"
@@ -9,7 +10,7 @@ module.exports = function RecordButton ({ recording, onStartRecording, onStopRec
         aria-label={i18n('stopRecording')}
         onclick={onStopRecording}>
         <svg aria-hidden="true" class="UppyIcon" width="100" height="100" viewBox="0 0 100 100">
-          <circle cx="50" cy="50" r="40" />
+          <rect x="15" y="15" width="70" height="70" />
         </svg>
       </button>
     )
@@ -22,7 +23,7 @@ module.exports = function RecordButton ({ recording, onStartRecording, onStopRec
       aria-label={i18n('startRecording')}
       onclick={onStartRecording}>
       <svg aria-hidden="true" class="UppyIcon" width="100" height="100" viewBox="0 0 100 100">
-        <rect x="15" y="15" width="70" height="70" />
+        <circle cx="50" cy="50" r="40" />
       </svg>
     </button>
   )

+ 14 - 6
src/plugins/Webcam/index.js

@@ -1,10 +1,8 @@
 const { h } = require('preact')
 const Plugin = require('../../core/Plugin')
 const Translator = require('../../core/Translator')
-const {
-  getFileTypeExtension,
-  canvasToBlob
-} = require('../../core/Utils')
+const getFileTypeExtension = require('../../utils/getFileTypeExtension')
+const canvasToBlob = require('../../utils/canvasToBlob')
 const supportsMediaRecorder = require('./supportsMediaRecorder')
 const CameraIcon = require('./CameraIcon')
 const CameraScreen = require('./CameraScreen')
@@ -173,7 +171,13 @@ module.exports = class Webcam extends Plugin {
       })
       return this.getVideo()
     })
-    .then((file) => this.uppy.addFile(file))
+    .then((file) => {
+      try {
+        this.uppy.addFile(file)
+      } catch (err) {
+        // Nothing, restriction errors handled in Core
+      }
+    })
     .then(() => {
       this.recordingChunks = null
       this.recorder = null
@@ -238,7 +242,11 @@ module.exports = class Webcam extends Plugin {
       this.captureInProgress = false
       const dashboard = this.uppy.getPlugin('Dashboard')
       if (dashboard) dashboard.hideAllPanels()
-      return this.uppy.addFile(tagFile)
+      try {
+        this.uppy.addFile(tagFile)
+      } catch (err) {
+        // Nothing, restriction errors handled in Core
+      }
     }, (error) => {
       this.captureInProgress = false
       throw error

+ 70 - 48
src/plugins/XHRUpload.js

@@ -2,12 +2,11 @@ const Plugin = require('../core/Plugin')
 const cuid = require('cuid')
 const Translator = require('../core/Translator')
 const UppySocket = require('../core/UppySocket')
-const {
-  emitSocketProgress,
-  getSocketHost,
-  settle,
-  limitPromises
-} = require('../core/Utils')
+const Provider = require('../server/Provider')
+const emitSocketProgress = require('../utils/emitSocketProgress')
+const getSocketHost = require('../utils/getSocketHost')
+const settle = require('../utils/settle')
+const limitPromises = require('../utils/limitPromises')
 
 function buildResponseError (xhr, error) {
   // No error message
@@ -48,6 +47,7 @@ module.exports = class XHRUpload extends Plugin {
       locale: defaultLocale,
       timeout: 30 * 1000,
       limit: 0,
+      withCredentials: false,
       /**
        * @typedef respObj
        * @property {string} responseText
@@ -102,15 +102,16 @@ module.exports = class XHRUpload extends Plugin {
   }
 
   getOptions (file) {
+    const overrides = this.uppy.getState().xhrUpload
     const opts = Object.assign({},
       this.opts,
-      this.uppy.state.xhrUpload || {},
+      overrides || {},
       file.xhrUpload || {}
     )
     opts.headers = {}
     Object.assign(opts.headers, this.opts.headers)
-    if (this.uppy.state.xhrUpload) {
-      Object.assign(opts.headers, this.uppy.state.xhrUpload.headers)
+    if (overrides) {
+      Object.assign(opts.headers, overrides.headers)
     }
     if (file.xhrUpload) {
       Object.assign(opts.headers, file.xhrUpload.headers)
@@ -126,6 +127,8 @@ module.exports = class XHRUpload extends Plugin {
   createProgressTimeout (timeout, timeoutHandler) {
     const uppy = this.uppy
     const self = this
+    let isDone = false
+
     function onTimedOut () {
       uppy.log(`[XHRUpload] timed out`)
       const error = new Error(self.i18n('timedOut', { seconds: Math.ceil(timeout / 1000) }))
@@ -134,17 +137,24 @@ module.exports = class XHRUpload extends Plugin {
 
     let aliveTimer = null
     function progress () {
+      // Some browsers fire another progress event when the upload is
+      // cancelled, so we have to ignore progress after the timer was
+      // told to stop.
+      if (isDone) return
+
       if (timeout > 0) {
-        done()
+        if (aliveTimer) clearTimeout(aliveTimer)
         aliveTimer = setTimeout(onTimedOut, timeout)
       }
     }
 
     function done () {
+      uppy.log(`[XHRUpload] timer done`)
       if (aliveTimer) {
         clearTimeout(aliveTimer)
         aliveTimer = null
       }
+      isDone = true
     }
 
     return {
@@ -260,6 +270,8 @@ module.exports = class XHRUpload extends Plugin {
 
       xhr.open(opts.method.toUpperCase(), opts.endpoint, true)
 
+      xhr.withCredentials = opts.withCredentials
+
       Object.keys(opts.headers).forEach((header) => {
         xhr.setRequestHeader(header, opts.headers[header])
       })
@@ -281,8 +293,7 @@ module.exports = class XHRUpload extends Plugin {
       })
 
       this.uppy.on('cancel-all', () => {
-        // const files = this.uppy.getState().files
-        // if (!files[file.id]) return
+        timer.done()
         xhr.abort()
       })
     })
@@ -301,49 +312,39 @@ module.exports = class XHRUpload extends Plugin {
         fields[name] = file.meta[name]
       })
 
-      fetch(file.remote.url, {
-        method: 'post',
-        credentials: 'include',
-        headers: {
-          'Accept': 'application/json',
-          'Content-Type': 'application/json'
-        },
-        body: JSON.stringify(Object.assign({}, file.remote.body, {
+      const provider = new Provider(this.uppy, file.remote.providerOptions)
+      provider.post(
+        file.remote.url,
+        Object.assign({}, file.remote.body, {
           endpoint: opts.endpoint,
           size: file.data.size,
           fieldname: opts.fieldName,
           metadata: fields,
           headers: opts.headers
-        }))
-      })
+        })
+      )
       .then((res) => {
-        if (res.status < 200 && res.status > 300) {
-          return reject(res.statusText)
-        }
+        const token = res.token
+        const host = getSocketHost(file.remote.host)
+        const socket = new UppySocket({ target: `${host}/api/${token}` })
 
-        res.json().then((data) => {
-          const token = data.token
-          const host = getSocketHost(file.remote.host)
-          const socket = new UppySocket({ target: `${host}/api/${token}` })
+        socket.on('progress', (progressData) => emitSocketProgress(this, progressData, file))
 
-          socket.on('progress', (progressData) => emitSocketProgress(this, progressData, file))
-
-          socket.on('success', (data) => {
-            const resp = opts.getResponseData(data.response.responseText, data.response)
-            const uploadURL = resp[opts.responseUrlFieldName]
-            this.uppy.emit('upload-success', file, resp, uploadURL)
-            socket.close()
-            return resolve()
-          })
+        socket.on('success', (data) => {
+          const resp = opts.getResponseData(data.response.responseText, data.response)
+          const uploadURL = resp[opts.responseUrlFieldName]
+          this.uppy.emit('upload-success', file, resp, uploadURL)
+          socket.close()
+          return resolve()
+        })
 
-          socket.on('error', (errData) => {
-            const resp = errData.response
-            const error = resp
-              ? opts.getResponseError(resp.responseText, resp)
-              : Object.assign(new Error(errData.error.message), { cause: errData.error })
-            this.uppy.emit('upload-error', file, error)
-            reject(error)
-          })
+        socket.on('error', (errData) => {
+          const resp = errData.response
+          const error = resp
+            ? opts.getResponseError(resp.responseText, resp)
+            : Object.assign(new Error(errData.error.message), { cause: errData.error })
+          this.uppy.emit('upload-error', file, error)
+          reject(error)
         })
       })
     })
@@ -362,6 +363,8 @@ module.exports = class XHRUpload extends Plugin {
 
       const xhr = new XMLHttpRequest()
 
+      xhr.withCredentials = this.opts.withCredentials
+
       const timer = this.createProgressTimeout(this.opts.timeout, (error) => {
         xhr.abort()
         emitError(error)
@@ -387,8 +390,8 @@ module.exports = class XHRUpload extends Plugin {
         files.forEach((file) => {
           this.uppy.emit('upload-progress', file, {
             uploader: this,
-            bytesUploaded: ev.loaded,
-            bytesTotal: ev.total
+            bytesUploaded: ev.loaded / ev.total * file.size,
+            bytesTotal: file.size
           })
         })
       })
@@ -419,11 +422,14 @@ module.exports = class XHRUpload extends Plugin {
       })
 
       this.uppy.on('cancel-all', () => {
+        timer.done()
         xhr.abort()
       })
 
       xhr.open(method.toUpperCase(), endpoint, true)
 
+      xhr.withCredentials = this.opts.withCredentials
+
       Object.keys(this.opts.headers).forEach((header) => {
         xhr.setRequestHeader(header, this.opts.headers[header])
       })
@@ -479,10 +485,26 @@ module.exports = class XHRUpload extends Plugin {
   }
 
   install () {
+    if (this.opts.bundle) {
+      this.uppy.setState({
+        capabilities: Object.assign({}, this.uppy.getState().capabilities, {
+          bundled: true
+        })
+      })
+    }
+
     this.uppy.addUploader(this.handleUpload)
   }
 
   uninstall () {
+    if (this.opts.bundle) {
+      this.uppy.setState({
+        capabilities: Object.assign({}, this.uppy.getState().capabilities, {
+          bundled: true
+        })
+      })
+    }
+
     this.uppy.removeUploader(this.handleUpload)
   }
 }

+ 37 - 0
src/plugins/XHRUpload.test.js

@@ -0,0 +1,37 @@
+const nock = require('nock')
+const Core = require('../core')
+const XHRUpload = require('./XHRUpload')
+
+describe('XHRUpload', () => {
+  describe('getResponseData', () => {
+    it('has the XHRUpload options as its `this`', () => {
+      nock('https://fake-endpoint.uppy.io')
+        .defaultReplyHeaders({
+          'access-control-allow-method': 'POST',
+          'access-control-allow-origin': '*'
+        })
+        .options('/').reply(200, {})
+        .post('/').reply(200, {})
+
+      const core = new Core({ autoProceed: false })
+      const getResponseData = jest.fn(function () {
+        expect(this.some).toEqual('option')
+        return {}
+      })
+      core.use(XHRUpload, {
+        id: 'XHRUpload',
+        endpoint: 'https://fake-endpoint.uppy.io',
+        some: 'option',
+        getResponseData
+      })
+      core.addFile({
+        name: 'test.jpg',
+        data: new Blob([Buffer.alloc(8192)])
+      })
+
+      return core.upload().then(() => {
+        expect(getResponseData).toHaveBeenCalled()
+      })
+    })
+  })
+})

+ 1 - 1
src/react/Dashboard.js

@@ -20,7 +20,7 @@ class Dashboard extends React.Component {
     delete options.uppy
     uppy.use(DashboardPlugin, options)
 
-    this.plugin = uppy.getPlugin('Dashboard')
+    this.plugin = uppy.getPlugin(options.id)
   }
 
   componentWillUnmount () {

+ 35 - 0
src/react/Dashboard.test.js

@@ -0,0 +1,35 @@
+const h = require('react').createElement
+const { mount, configure } = require('enzyme')
+const ReactAdapter = require('enzyme-adapter-react-16')
+const Uppy = require('../core')
+
+beforeAll(() => {
+  configure({ adapter: new ReactAdapter() })
+})
+
+jest.mock('../plugins/Dashboard', () => require('./__mocks__/DashboardPlugin'))
+
+const Dashboard = require('./Dashboard')
+
+describe('react <Dashboard />', () => {
+  it('can be mounted and unmounted', () => {
+    const oninstall = jest.fn()
+    const onuninstall = jest.fn()
+    const uppy = Uppy()
+    const dash = mount((
+      <Dashboard
+        uppy={uppy}
+        onInstall={oninstall}
+        onUninstall={onuninstall}
+      />
+    ))
+
+    expect(oninstall).toHaveBeenCalled()
+    expect(onuninstall).not.toHaveBeenCalled()
+
+    dash.unmount()
+
+    expect(oninstall).toHaveBeenCalled()
+    expect(onuninstall).toHaveBeenCalled()
+  })
+})

+ 1 - 1
src/react/DashboardModal.js

@@ -28,7 +28,7 @@ class DashboardModal extends React.Component {
     delete options.uppy
     uppy.use(DashboardPlugin, options)
 
-    this.plugin = uppy.getPlugin('Dashboard')
+    this.plugin = uppy.getPlugin(options.id)
     if (this.props.open) {
       this.plugin.openModal()
     }

+ 81 - 0
src/react/DashboardModal.test.js

@@ -0,0 +1,81 @@
+const h = require('react').createElement
+const { mount, configure } = require('enzyme')
+const ReactAdapter = require('enzyme-adapter-react-16')
+const Uppy = require('../core')
+
+jest.mock('../plugins/Dashboard', () => require('./__mocks__/DashboardPlugin'))
+
+const DashboardModal = require('./DashboardModal')
+
+beforeAll(() => {
+  configure({ adapter: new ReactAdapter() })
+})
+
+beforeEach(() => {
+  Object.assign(require('../plugins/Dashboard').prototype, {
+    openModal: jest.fn(),
+    closeModal: jest.fn()
+  })
+})
+
+describe('react <DashboardModal />', () => {
+  it('can be mounted and unmounted', () => {
+    const oninstall = jest.fn()
+    const onuninstall = jest.fn()
+    const uppy = Uppy()
+    const dash = mount((
+      <DashboardModal
+        uppy={uppy}
+        onInstall={oninstall}
+        onUninstall={onuninstall}
+      />
+    ))
+
+    expect(oninstall).toHaveBeenCalled()
+    expect(onuninstall).not.toHaveBeenCalled()
+
+    dash.unmount()
+
+    expect(oninstall).toHaveBeenCalled()
+    expect(onuninstall).toHaveBeenCalled()
+  })
+
+  it('opens the modal using the `open={true}` prop', () => {
+    const uppy = Uppy()
+    const dash = mount((
+      <DashboardModal
+        uppy={uppy}
+        open={false}
+      />
+    ))
+    const { plugin } = dash.instance()
+
+    expect(plugin.openModal).not.toHaveBeenCalled()
+
+    dash.setProps({ open: true })
+
+    expect(plugin.openModal).toHaveBeenCalled()
+
+    dash.unmount()
+  })
+
+  it('closes the modal using the `open={false}` prop', () => {
+    const uppy = Uppy()
+    const dash = mount((
+      <DashboardModal
+        uppy={uppy}
+        open
+      />
+    ))
+    const { plugin } = dash.instance()
+
+    expect(plugin.openModal).toHaveBeenCalled()
+    expect(plugin.closeModal).not.toHaveBeenCalled()
+
+    dash.setProps({ open: false })
+
+    expect(plugin.closeModal).toHaveBeenCalled()
+
+    dash.unmount()
+  })
+})

+ 1 - 1
src/react/DragDrop.js

@@ -21,7 +21,7 @@ class DragDrop extends React.Component {
 
     uppy.use(DragDropPlugin, options)
 
-    this.plugin = uppy.getPlugin('DragDrop')
+    this.plugin = uppy.getPlugin(options.id)
   }
 
   componentWillUnmount () {

+ 35 - 0
src/react/DragDrop.test.js

@@ -0,0 +1,35 @@
+const h = require('react').createElement
+const { mount, configure } = require('enzyme')
+const ReactAdapter = require('enzyme-adapter-react-16')
+const Uppy = require('../core')
+
+beforeAll(() => {
+  configure({ adapter: new ReactAdapter() })
+})
+
+jest.mock('../plugins/DragDrop', () => require('./__mocks__/DragDropPlugin'))
+
+const DragDrop = require('./DragDrop')
+
+describe('react <DragDrop />', () => {
+  it('can be mounted and unmounted', () => {
+    const oninstall = jest.fn()
+    const onuninstall = jest.fn()
+    const uppy = Uppy()
+    const dash = mount((
+      <DragDrop
+        uppy={uppy}
+        onInstall={oninstall}
+        onUninstall={onuninstall}
+      />
+    ))
+
+    expect(oninstall).toHaveBeenCalled()
+    expect(onuninstall).not.toHaveBeenCalled()
+
+    dash.unmount()
+
+    expect(oninstall).toHaveBeenCalled()
+    expect(onuninstall).toHaveBeenCalled()
+  })
+})

+ 1 - 1
src/react/ProgressBar.js

@@ -21,7 +21,7 @@ class ProgressBar extends React.Component {
 
     uppy.use(ProgressBarPlugin, options)
 
-    this.plugin = uppy.getPlugin('ProgressBar')
+    this.plugin = uppy.getPlugin(options.id)
   }
 
   componentWillUnmount () {

+ 35 - 0
src/react/ProgressBar.test.js

@@ -0,0 +1,35 @@
+const h = require('react').createElement
+const { mount, configure } = require('enzyme')
+const ReactAdapter = require('enzyme-adapter-react-16')
+const Uppy = require('../core')
+
+beforeAll(() => {
+  configure({ adapter: new ReactAdapter() })
+})
+
+jest.mock('../plugins/ProgressBar', () => require('./__mocks__/ProgressBarPlugin'))
+
+const ProgressBar = require('./ProgressBar')
+
+describe('react <ProgressBar />', () => {
+  it('can be mounted and unmounted', () => {
+    const oninstall = jest.fn()
+    const onuninstall = jest.fn()
+    const uppy = Uppy()
+    const dash = mount((
+      <ProgressBar
+        uppy={uppy}
+        onInstall={oninstall}
+        onUninstall={onuninstall}
+      />
+    ))
+
+    expect(oninstall).toHaveBeenCalled()
+    expect(onuninstall).not.toHaveBeenCalled()
+
+    dash.unmount()
+
+    expect(oninstall).toHaveBeenCalled()
+    expect(onuninstall).toHaveBeenCalled()
+  })
+})

+ 1 - 1
src/react/StatusBar.js

@@ -22,7 +22,7 @@ class StatusBar extends React.Component {
 
     uppy.use(StatusBarPlugin, options)
 
-    this.plugin = uppy.getPlugin('StatusBar')
+    this.plugin = uppy.getPlugin(options.id)
   }
 
   componentWillUnmount () {

+ 35 - 0
src/react/StatusBar.test.js

@@ -0,0 +1,35 @@
+const h = require('react').createElement
+const { mount, configure } = require('enzyme')
+const ReactAdapter = require('enzyme-adapter-react-16')
+const Uppy = require('../core')
+
+beforeAll(() => {
+  configure({ adapter: new ReactAdapter() })
+})
+
+jest.mock('../plugins/StatusBar', () => require('./__mocks__/StatusBarPlugin'))
+
+const StatusBar = require('./StatusBar')
+
+describe('react <StatusBar />', () => {
+  it('can be mounted and unmounted', () => {
+    const oninstall = jest.fn()
+    const onuninstall = jest.fn()
+    const uppy = Uppy()
+    const dash = mount((
+      <StatusBar
+        uppy={uppy}
+        onInstall={oninstall}
+        onUninstall={onuninstall}
+      />
+    ))
+
+    expect(oninstall).toHaveBeenCalled()
+    expect(onuninstall).not.toHaveBeenCalled()
+
+    dash.unmount()
+
+    expect(oninstall).toHaveBeenCalled()
+    expect(onuninstall).toHaveBeenCalled()
+  })
+})

+ 18 - 0
src/react/__mocks__/DashboardPlugin.js

@@ -0,0 +1,18 @@
+const Plugin = require('../../core/Plugin')
+
+module.exports = class Dashboard extends Plugin {
+  constructor (uppy, opts) {
+    super(uppy, opts)
+
+    this.id = opts.id
+    this.type = 'orchestrator'
+  }
+
+  install () {
+    if (this.opts.onInstall) this.opts.onInstall()
+  }
+
+  uninstall () {
+    if (this.opts.onUninstall) this.opts.onUninstall()
+  }
+}

+ 18 - 0
src/react/__mocks__/DragDropPlugin.js

@@ -0,0 +1,18 @@
+const Plugin = require('../../core/Plugin')
+
+module.exports = class DragDrop extends Plugin {
+  constructor (uppy, opts) {
+    super(uppy, opts)
+
+    this.id = opts.id
+    this.type = 'acquirer'
+  }
+
+  install () {
+    if (this.opts.onInstall) this.opts.onInstall()
+  }
+
+  uninstall () {
+    if (this.opts.onUninstall) this.opts.onUninstall()
+  }
+}

+ 18 - 0
src/react/__mocks__/ProgressBarPlugin.js

@@ -0,0 +1,18 @@
+const Plugin = require('../../core/Plugin')
+
+module.exports = class ProgressBar extends Plugin {
+  constructor (uppy, opts) {
+    super(uppy, opts)
+
+    this.id = opts.id
+    this.type = 'progressindicator'
+  }
+
+  install () {
+    this.opts.onInstall()
+  }
+
+  uninstall () {
+    this.opts.onUninstall()
+  }
+}

+ 18 - 0
src/react/__mocks__/StatusBarPlugin.js

@@ -0,0 +1,18 @@
+const Plugin = require('../../core/Plugin')
+
+module.exports = class StatusBar extends Plugin {
+  constructor (uppy, opts) {
+    super(uppy, opts)
+
+    this.id = opts.id
+    this.type = 'progressindicator'
+  }
+
+  install () {
+    this.opts.onInstall()
+  }
+
+  uninstall () {
+    this.opts.onUninstall()
+  }
+}

+ 0 - 23
src/scss/_animation.scss

@@ -1,23 +0,0 @@
-// Animation
-
-@keyframes zoomOutLeft {
-  40% {
-    opacity: 1;
-    -webkit-transform: scale3d(.475, .475, .475) translate3d(42px, 0, 0);
-    transform: scale3d(.475, .475, .475) translate3d(42px, 0, 0);
-  }
-
-  to {
-    opacity: 0;
-    -webkit-transform: scale(.1) translate3d(-2000px, 0, 0);
-    transform: scale(.1) translate3d(-2000px, 0, 0);
-    -webkit-transform-origin: left center;
-    transform-origin: left center;
-  }
-}
-
-.UppyAnimation-zoomOutLeft {
-  animation-name: zoomOutLeft;
-  animation-duration: 1s;
-  animation-fill-mode: both;
-}

+ 122 - 104
src/scss/_dashboard.scss

@@ -1,17 +1,69 @@
 .uppy-Dashboard--modal {
   z-index: $zIndex-2;
-  // transition: transform 0.2s ease-in-out;
-  // transform: none;
-  // -webkit-overflow-scrolling: touch;
 }
 
-.uppy-Dashboard--modal[aria-hidden=true] {
-  display: none;
-  // transform: translateY(-50%);
-}
+  .uppy-Dashboard--modal[aria-hidden=true] {
+    display: none;
+  }
+
+  // Modal open/close animations
+
+  @keyframes uppy-Dashboard-fadeIn {
+    from { opacity: 0;  }
+    to { opacity: 1;  }
+  }
+
+  @keyframes uppy-Dashboard-fadeOut {
+    from { opacity: 1;  }
+    to { opacity: 0;  }
+  }
+
+  @keyframes uppy-Dashboard-slideDownAndFadeIn {
+    from { transform: translate3d(-50%, -70%, 0); opacity: 0; }
+    to { transform: translate3d(-50%, -50%, 0); opacity: 1; }
+  }
+
+    @keyframes uppy-Dashboard-slideDownAndFadeIn--small {
+      from { transform: translate3d(0, -20%, 0); opacity: 0; }
+      to { transform: translate3d(0, 0, 0); opacity: 1; }
+    }
+
+  @keyframes uppy-Dashboard-slideUpFadeOut {
+    from { transform: translate3d(-50%, -50%, 0); opacity: 1; }
+    to { transform: translate3d(-50%, -70%, 0); opacity: 0; }
+  }
+
+    @keyframes uppy-Dashboard-slideUpFadeOut--small {
+      from { transform: translate3d(0, 0, 0); opacity: 1; }
+      to { transform: translate3d(0, -20%, 0); opacity: 0; }
+    }
+  
+  .uppy-Dashboard--modal.uppy-Dashboard--animateOpenClose > .uppy-Dashboard-inner {
+    animation: uppy-Dashboard-slideDownAndFadeIn--small 0.3s cubic-bezier(0, 0, .2, 1);
+
+    @media #{$screen-medium} {
+      animation: uppy-Dashboard-slideDownAndFadeIn 0.3s cubic-bezier(0, 0, .2, 1);
+    }
+  }
+
+  .uppy-Dashboard--modal.uppy-Dashboard--animateOpenClose > .uppy-Dashboard-overlay {
+    animation: uppy-Dashboard-fadeIn 0.3s cubic-bezier(0, 0, .2, 1);
+  }
+
+  .uppy-Dashboard--modal.uppy-Dashboard--animateOpenClose.uppy-Dashboard--isClosing > .uppy-Dashboard-inner {
+    animation: uppy-Dashboard-slideUpFadeOut--small 0.3s cubic-bezier(0, 0, .2, 1);
+
+    @media #{$screen-medium} {
+      animation: uppy-Dashboard-slideUpFadeOut 0.3s cubic-bezier(0, 0, .2, 1);
+    }
+  }
+
+  .uppy-Dashboard--modal.uppy-Dashboard--animateOpenClose.uppy-Dashboard--isClosing > .uppy-Dashboard-overlay {
+    animation: uppy-Dashboard-fadeOut 0.3s cubic-bezier(0, 0, .2, 1);
+  }
 
 // Added to body to prevent the page from scrolling when Modal is open
-.uppy-Dashboard-isOpen {
+.uppy-Dashboard-isFixed {
   overflow: hidden;
   height: 100vh;
 }
@@ -22,7 +74,6 @@
   left: 0;
   right: 0;
   bottom: 0;
-  // background-color: rgba($color-white, 0.8);
   background-color: rgba($color-black, 0.5);
   z-index: $zIndex-2;
 }
@@ -38,7 +89,6 @@
   min-height: 400px;
   outline: none;
   border: 1px solid rgba($color-gray, 0.2);
-  margin-bottom: 30px;
 
   .uppy-Dashboard--modal & {
     z-index: $zIndex-3;
@@ -51,43 +101,6 @@
   }
 }
 
-.uppy-Dashboard-poweredBy {
-  display: block;
-  font-size: 11px;
-  color: rgba($color-gray, 0.8);
-  text-align: right;
-  text-decoration: none;
-  padding-top: 8px;
-  padding-right: 2px;
-}
-
-  .uppy-Dashboard--modal .uppy-Dashboard-poweredBy {
-    color: rgba($color-white, 0.7);
-  }
-
-.uppy-Dashboard-poweredByUppy {
-  color: $color-gray;
-}
-
-  .uppy-Dashboard--modal .uppy-Dashboard-poweredByUppy {
-    color: $color-white;
-  }
-
-.uppy-Dashboard-poweredByIcon {
-  stroke: $color-gray;
-  fill: none;
-  margin-left: 1px;
-  margin-right: 1px;
-  position: relative;
-  top: 1px;
-  opacity: 0.9;
-}
-
-  .uppy-Dashboard--modal .uppy-Dashboard-poweredByIcon {
-    stroke: transparent;
-    fill: $color-uppy-pink;
-  }
-
 .uppy-Dashboard-innerWrap {
   display: flex;
   flex-direction: column;
@@ -206,6 +219,7 @@
   border: 0;
   background-color: transparent;
   -webkit-appearance: none;
+  appearance: none;
   // outline: none;
   transition: all 0.3s;
   color: darken($color-gray, 25%);
@@ -219,10 +233,11 @@
 
 .uppy-DashboardTab-name {
   font-size: 8px;
+  line-height: 11px;
   margin-top: 5px;
   margin-bottom: 0;
   font-weight: 500;
-  overflow-x: hidden;
+  overflow: hidden;
   text-overflow: ellipsis;
   white-space: nowrap;
 
@@ -289,48 +304,31 @@
     font-size: 16px;
     line-height: 50px;
     max-width: 300px;
-    // top: 15px;
   }
 }
 
-.uppy-DashboardContent-titleFile {
-  // text-decoration: underline;
-}
-
 .uppy-DashboardContent-back {
   @include reset-button;
-  // position: absolute;
-  // top: 0;
-  // left: 15px;
   font-size: 14px;
-  // line-height: 40px;
   font-weight: 500;
   cursor: pointer;
   color: $color-cornflower-blue;
 
   .uppy-Dashboard--wide & {
     font-size: 15px;
-    // line-height: 50px;
   }
 }
 
-// .uppy-DashboardContent-back .UppyIcon {
-//   position: relative;
-//   margin-right: 3px;
-// }
-
 .uppy-DashboardContent-panel {
   position: absolute;
   top: 0;
   bottom: 0;
   left: 0;
   right: 0;
-  transform: translateY(-105%);
+  transform: translate3d(0, -105%, 0);
   transition: transform 0.2s ease-in-out;
-  will-change: transform;
   background-color: darken($color-white, 4%);
   box-shadow: 0 0 10px 5px rgba($color-black, 0.15);
-  // padding: 15px;
   padding-top: 40px;
   overflow: hidden;
   z-index: $zIndex-4;
@@ -338,14 +336,10 @@
   .uppy-Dashboard--wide & {
     padding-top: 50px;
   }
-
-  // .uppy-Dashboard--modal & {
-  //   z-index: $zIndex-4;
-  // }
 }
 
 .uppy-DashboardContent-panel[aria-hidden=false] {
-  transform: none;
+  transform: translate3d(0, 0, 0);
 }
 
 // Progress bar placeholder
@@ -376,6 +370,7 @@
   background: none;
   background-color: rgba($color-gray, 0.7);
   -webkit-appearance: none;
+  appearance: none;
   border: 0;
   z-index: $zIndex-3;
   transition: background-color 0.5s;
@@ -429,29 +424,6 @@
   border-color: darken($color-white, 20%);
 }
 
-// .uppy-Dashboard-bgIcon {
-  // width: 100%;
-  // max-width: 460px;
-  // position: absolute;
-  // top: 50%;
-  // left: 50%;
-  // transform: translate(-50%, -50%);
-  // opacity: 0.7;
-  // transition: all 0.3s;
-  // padding: 0 20px;
-// }
-
-// .uppy-Dashboard-bgIcon .UppyIcon {
-//   width: 100%;
-//   height: 80px;
-//   fill: none;
-//   stroke: $color-asphalt-gray;
-
-//   .uppy-Dashboard--wide & {
-//     height: 110px;
-//   }
-// }
-
 .uppy-Dashboard-bgIcon {
   height: 100%;
   display: flex;
@@ -483,18 +455,56 @@
   font-size: 13px;
   line-height: 1.2;
   text-align: center;
-  // margin-top: 20px;
-  color: rgba($color-asphalt-gray, 0.7);
+  color: rgba($color-asphalt-gray, 0.8);
   position: absolute;
-  bottom: 20px;
+  bottom: 45px;
   left: 0;
   width: 100%;
 
   .uppy-Dashboard--wide & {
-    font-size: 14px;
+    font-size: 16px;
   }
 }
 
+.uppy-Dashboard-poweredBy {
+  width: 100%;
+  text-align: center;
+  position: absolute;
+  bottom: 23px;
+  font-size: 11px;
+  color: $color-gray;
+  text-decoration: none;
+  padding-top: 8px;
+  padding-right: 2px;
+}
+
+  // .uppy-Dashboard--modal .uppy-Dashboard-poweredBy {
+  //   color: rgba($color-white, 0.7);
+  // }
+
+.uppy-Dashboard-poweredByUppy {
+  color: $color-gray;
+}
+
+  // .uppy-Dashboard--modal .uppy-Dashboard-poweredByUppy {
+  //   color: $color-white;
+  // }
+
+.uppy-Dashboard-poweredByIcon {
+  stroke: $color-gray;
+  fill: none;
+  margin-left: 1px;
+  margin-right: 1px;
+  position: relative;
+  top: 1px;
+  opacity: 0.9;
+}
+
+  // .uppy-Dashboard--modal .uppy-Dashboard-poweredByIcon {
+  //   stroke: transparent;
+  //   fill: $color-uppy-pink;
+  // }
+
 .uppy-DashboardItem {
   list-style: none;
   margin: 10px 0;
@@ -792,10 +802,10 @@
 
 .uppy-DashboardItem-progressIndicator {
   @include reset-button;
+  display: inline-block;
   width: 38px;
   height: 38px;
   opacity: 0.9;
-  cursor: pointer;
   transition: all .35s ease;
 
   .uppy-Dashboard--wide & {
@@ -804,6 +814,10 @@
   }
 }
 
+  button.uppy-DashboardItem-progressIndicator {
+    cursor: pointer;
+  }
+
   .uppy-DashboardItem.is-error .uppy-DashboardItem-progressIndicator {
     width: 18px;
     height: 18px;
@@ -1012,8 +1026,9 @@
 //
 
 .uppy-DashboardFileCard {
-  transform: translateY(0);
-  transition: all 0.25s ease-in-out;
+  transform: translate3d(0, 0, 0);
+  transition: transform 0.2s ease-in-out;
+  
   width: 100%;
   height: 100%;
   position: absolute;
@@ -1026,9 +1041,9 @@
   background-color: $color-white;
 }
 
-.uppy-DashboardFileCard[aria-hidden=true] {
-  transform: translateY(-105%);
-}
+  .uppy-DashboardFileCard[aria-hidden=true] {
+    transform: translate3d(0, -105%, 0);
+  }
 
 .uppy-DashboardFileCard-inner {
   display: flex;
@@ -1093,3 +1108,6 @@
   vertical-align: middle;
   width: 78%;
 }
+
+
+

+ 8 - 4
src/scss/_informer.scss

@@ -56,7 +56,11 @@
   margin-left: -1px;
 }
 
-.uppy-Informer span:after {
-  line-height: 1.3;
-  word-wrap: break-word;
-}
+  .uppy-Informer span:hover {
+    cursor: help;
+  }
+
+  .uppy-Informer span:after {
+    line-height: 1.3;
+    word-wrap: break-word;
+  }

+ 9 - 15
src/scss/_provider.scss

@@ -392,21 +392,15 @@
   outline: rgb(59, 153, 252) auto 5px;
 }
 
-.uppy-ProviderBrowser-doneBtn {
-  position: absolute;
-  bottom: 16px;
-  right: 16px;
-  z-index: $zIndex-3;
-  width: 50px;
-  height: 50px;
+.uppy-ProviderBrowser-footer {
+  display: flex;
+  align-items: center;
+  background: $color-white;
+  height: 65px;
+  border-top: 1px solid rgba($color-gray, 0.3);
+  padding: 0 15px;
 
-  .uppy-Dashboard--wide & {
-    width: 60px;
-    height: 60px;
+  & button {
+    margin-right: 10px;
   }
 }
-
-.uppy-ProviderBrowser-doneBtn .UppyIcon {
-  width: 45%;
-  height: 45%;
-}

+ 5 - 1
src/scss/_statusbar.scss

@@ -189,8 +189,12 @@
   top: -1px;
   left: 6px;
   font-size: 10px;
-  // margin-left: -1px;
   text-align: center;
+  cursor: help;
+
+  &:hover {
+    cursor: help;
+  }
 }
 
 .uppy-StatusBar-details:after {

+ 15 - 0
src/server/Provider.js

@@ -14,6 +14,17 @@ module.exports = class Provider extends RequestClient {
     this.id = this.provider
     this.authProvider = opts.authProvider || this.provider
     this.name = this.opts.name || _getName(this.id)
+    this.tokenKey = `uppy-server-${this.id}-auth-token`
+  }
+
+  get defaultHeaders () {
+    return Object.assign({}, super.defaultHeaders, {'uppy-auth-token': localStorage.getItem(this.tokenKey)})
+  }
+
+  // @todo(i.olarewaju) consider whether or not this method should be exposed
+  setAuthToken (token) {
+    // @todo(i.olarewaju) add fallback for OOM storage
+    localStorage.setItem(this.tokenKey, token)
   }
 
   checkAuth () {
@@ -37,5 +48,9 @@ module.exports = class Provider extends RequestClient {
 
   logout (redirect = location.href) {
     return this.get(`${this.id}/logout?redirect=${redirect}`)
+      .then((res) => {
+        localStorage.removeItem(this.tokenKey)
+        return res
+      })
   }
 }

+ 42 - 17
src/server/RequestClient.js

@@ -2,6 +2,11 @@
 
 require('whatwg-fetch')
 
+// Remove the trailing slash so we can always safely append /xyz.
+function stripSlash (url) {
+  return url.replace(/\/$/, '')
+}
+
 module.exports = class RequestClient {
   constructor (uppy, opts) {
     this.uppy = uppy
@@ -10,13 +15,21 @@ module.exports = class RequestClient {
   }
 
   get hostname () {
-    const uppyServer = this.uppy.state.uppyServer || {}
+    const { uppyServer } = this.uppy.getState()
     const host = this.opts.host
-    return uppyServer[host] || host
+    return stripSlash(uppyServer && uppyServer[host] ? uppyServer[host] : host)
+  }
+
+  get defaultHeaders () {
+    return {
+      'Accept': 'application/json',
+      'Content-Type': 'application/json'
+    }
   }
 
   onReceiveResponse (response) {
-    const uppyServer = this.uppy.state.uppyServer || {}
+    const state = this.uppy.getState()
+    const uppyServer = state.uppyServer || {}
     const host = this.opts.host
     const headers = response.headers
     // Store the self-identified domain name for the uppy-server we just hit.
@@ -30,33 +43,42 @@ module.exports = class RequestClient {
     return response
   }
 
+  _getUrl (url) {
+    if (/^(https?:|)\/\//.test(url)) {
+      return url
+    }
+    return `${this.hostname}/${url}`
+  }
+
   get (path) {
-    return fetch(`${this.hostname}/${path}`, {
+    return fetch(this._getUrl(path), {
       method: 'get',
-      credentials: 'include',
-      headers: {
-        'Accept': 'application/json',
-        'Content-Type': 'application/json'
-      }
+      headers: this.defaultHeaders
     })
       // @todo validate response status before calling json
       .then(this.onReceiveResponse)
       .then((res) => res.json())
+      .catch((err) => {
+        throw new Error(`Could not get ${this._getUrl(path)}. ${err}`)
+      })
   }
 
   post (path, data) {
-    return fetch(`${this.hostname}/${path}`, {
+    return fetch(this._getUrl(path), {
       method: 'post',
-      credentials: 'include',
-      headers: {
-        'Accept': 'application/json',
-        'Content-Type': 'application/json'
-      },
+      headers: this.defaultHeaders,
       body: JSON.stringify(data)
     })
       .then(this.onReceiveResponse)
-      // @todo validate response status before calling json
-      .then((res) => res.json())
+      .then((res) => {
+        if (res.status < 200 || res.status > 300) {
+          throw new Error(`Could not post ${this._getUrl(path)}. ${res.statusText}`)
+        }
+        return res.json()
+      })
+      .catch((err) => {
+        throw new Error(`Could not post ${this._getUrl(path)}. ${err}`)
+      })
   }
 
   delete (path, data) {
@@ -72,5 +94,8 @@ module.exports = class RequestClient {
       .then(this.onReceiveResponse)
       // @todo validate response status before calling json
       .then((res) => res.json())
+      .catch((err) => {
+        throw new Error(`Could not delete ${this._getUrl(path)}. ${err}`)
+      })
   }
 }

+ 12 - 0
src/server/RequestClient.test.js

@@ -0,0 +1,12 @@
+const RequestClient = require('./RequestClient')
+
+describe('RequestClient', () => {
+  it('has a hostname without trailing slash', () => {
+    const mockCore = { getState: () => ({}) }
+    const a = new RequestClient(mockCore, { host: 'http://server.uppy.io' })
+    const b = new RequestClient(mockCore, { host: 'http://server.uppy.io/' })
+
+    expect(a.hostname).toBe('http://server.uppy.io')
+    expect(b.hostname).toBe('http://server.uppy.io')
+  })
+})

+ 18 - 0
src/utils/canvasToBlob.js

@@ -0,0 +1,18 @@
+const dataURItoBlob = require('./dataURItoBlob')
+
+/**
+ * Save a <canvas> element's content to a Blob object.
+ *
+ * @param {HTMLCanvasElement} canvas
+ * @return {Promise}
+ */
+module.exports = function canvasToBlob (canvas, type, quality) {
+  if (canvas.toBlob) {
+    return new Promise((resolve) => {
+      canvas.toBlob(resolve, type, quality)
+    })
+  }
+  return Promise.resolve().then(() => {
+    return dataURItoBlob(canvas.toDataURL(type, quality), {})
+  })
+}

+ 51 - 0
src/utils/copyToClipboard.js

@@ -0,0 +1,51 @@
+/**
+ * Copies text to clipboard by creating an almost invisible textarea,
+ * adding text there, then running execCommand('copy').
+ * Falls back to prompt() when the easy way fails (hello, Safari!)
+ * From http://stackoverflow.com/a/30810322
+ *
+ * @param {String} textToCopy
+ * @param {String} fallbackString
+ * @return {Promise}
+ */
+module.exports = function copyToClipboard (textToCopy, fallbackString) {
+  fallbackString = fallbackString || 'Copy the URL below'
+
+  return new Promise((resolve) => {
+    const textArea = document.createElement('textarea')
+    textArea.setAttribute('style', {
+      position: 'fixed',
+      top: 0,
+      left: 0,
+      width: '2em',
+      height: '2em',
+      padding: 0,
+      border: 'none',
+      outline: 'none',
+      boxShadow: 'none',
+      background: 'transparent'
+    })
+
+    textArea.value = textToCopy
+    document.body.appendChild(textArea)
+    textArea.select()
+
+    const magicCopyFailed = () => {
+      document.body.removeChild(textArea)
+      window.prompt(fallbackString, textToCopy)
+      resolve()
+    }
+
+    try {
+      const successful = document.execCommand('copy')
+      if (!successful) {
+        return magicCopyFailed('copy command unavailable')
+      }
+      document.body.removeChild(textArea)
+      return resolve()
+    } catch (err) {
+      document.body.removeChild(textArea)
+      return magicCopyFailed(err)
+    }
+  })
+}

+ 7 - 0
src/utils/copyToClipboard.test.js

@@ -0,0 +1,7 @@
+const copyToClipboard = require('./copyToClipboard')
+
+describe('copyToClipboard', () => {
+  xit('should copy the specified text to the clipboard', () => {
+    expect(typeof copyToClipboard).toBe('function')
+  })
+})

+ 25 - 0
src/utils/dataURItoBlob.js

@@ -0,0 +1,25 @@
+module.exports = function dataURItoBlob (dataURI, opts, toFile) {
+  // get the base64 data
+  var data = dataURI.split(',')[1]
+
+  // user may provide mime type, if not get it from data URI
+  var mimeType = opts.mimeType || dataURI.split(',')[0].split(':')[1].split(';')[0]
+
+  // default to plain/text if data URI has no mimeType
+  if (mimeType == null) {
+    mimeType = 'plain/text'
+  }
+
+  var binary = atob(data)
+  var array = []
+  for (var i = 0; i < binary.length; i++) {
+    array.push(binary.charCodeAt(i))
+  }
+
+  // Convert to a File?
+  if (toFile) {
+    return new File([new Uint8Array(array)], opts.name || '', {type: mimeType})
+  }
+
+  return new Blob([new Uint8Array(array)], {type: mimeType})
+}

+ 11 - 0
src/utils/dataURItoBlob.test.js

@@ -0,0 +1,11 @@
+const dataURItoBlob = require('./dataURItoBlob')
+const sampleImageDataURI = require('./sampleImageDataURI')
+
+describe('dataURItoBlob', () => {
+  it('should convert a data uri to a blob', () => {
+    const blob = dataURItoBlob(sampleImageDataURI, {})
+    expect(blob instanceof Blob).toEqual(true)
+    expect(blob.size).toEqual(9348)
+    expect(blob.type).toEqual('image/jpeg')
+  })
+})

+ 5 - 0
src/utils/dataURItoFile.js

@@ -0,0 +1,5 @@
+const dataURItoBlob = require('./dataURItoBlob')
+
+module.exports = function dataURItoFile (dataURI, opts) {
+  return dataURItoBlob(dataURI, opts, true)
+}

+ 12 - 0
src/utils/dataURItoFile.test.js

@@ -0,0 +1,12 @@
+const dataURItoFile = require('./dataURItoFile')
+const sampleImageDataURI = require('./sampleImageDataURI')
+
+describe('dataURItoFile', () => {
+  it('should convert a data uri to a file', () => {
+    const file = dataURItoFile(sampleImageDataURI, { name: 'foo.jpg' })
+    expect(file instanceof File).toEqual(true)
+    expect(file.size).toEqual(9348)
+    expect(file.type).toEqual('image/jpeg')
+    expect(file.name).toEqual('foo.jpg')
+  })
+})

+ 15 - 0
src/utils/emitSocketProgress.js

@@ -0,0 +1,15 @@
+const throttle = require('lodash.throttle')
+
+function _emitSocketProgress (uploader, progressData, file) {
+  const { progress, bytesUploaded, bytesTotal } = progressData
+  if (progress) {
+    uploader.uppy.log(`Upload progress: ${progress}`)
+    uploader.uppy.emit('upload-progress', file, {
+      uploader,
+      bytesUploaded: bytesUploaded,
+      bytesTotal: bytesTotal
+    })
+  }
+}
+
+module.exports = throttle(_emitSocketProgress, 300, {leading: true, trailing: true})

+ 18 - 0
src/utils/findAllDOMElements.js

@@ -0,0 +1,18 @@
+const isDOMElement = require('./isDOMElement')
+
+/**
+ * Find one or more DOM elements.
+ *
+ * @param {string} element
+ * @return {Array|null}
+ */
+module.exports = function findAllDOMElements (element) {
+  if (typeof element === 'string') {
+    const elements = [].slice.call(document.querySelectorAll(element))
+    return elements.length > 0 ? elements : null
+  }
+
+  if (typeof element === 'object' && isDOMElement(element)) {
+    return [element]
+  }
+}

+ 17 - 0
src/utils/findDOMElement.js

@@ -0,0 +1,17 @@
+const isDOMElement = require('./isDOMElement')
+
+/**
+ * Find a DOM element.
+ *
+ * @param {Node|string} element
+ * @return {Node|null}
+ */
+module.exports = function findDOMElement (element) {
+  if (typeof element === 'string') {
+    return document.querySelector(element)
+  }
+
+  if (typeof element === 'object' && isDOMElement(element)) {
+    return element
+  }
+}

+ 18 - 0
src/utils/generateFileID.js

@@ -0,0 +1,18 @@
+/**
+ * Takes a file object and turns it into fileID, by converting file.name to lowercase,
+ * removing extra characters and adding type, size and lastModified
+ *
+ * @param {Object} file
+ * @return {String} the fileID
+ *
+ */
+module.exports = function generateFileID (file) {
+  // filter is needed to not join empty values with `-`
+  return [
+    'uppy',
+    file.name ? file.name.toLowerCase().replace(/[^A-Z0-9]/ig, '') : '',
+    file.type,
+    file.data.size,
+    file.data.lastModified
+  ].filter(val => val).join('-')
+}

+ 18 - 0
src/utils/generateFileID.test.js

@@ -0,0 +1,18 @@
+const generateFileID = require('./generateFileID')
+
+describe('generateFileID', () => {
+  it('should take the filename object and produce a lowercase file id made up of uppy- prefix, file name (cleaned up to be lowercase, letters and numbers only), type, size and lastModified date', () => {
+    const fileObj = {
+      name: 'fOo0Fi@£$.jpg',
+      type: 'image/jpeg',
+      data: {
+        lastModified: 1498510508000,
+        size: 2271173
+      }
+    }
+
+    expect(generateFileID(fileObj)).toEqual(
+      'uppy-foo0fijpg-image/jpeg-2271173-1498510508000'
+    )
+  })
+})

+ 15 - 0
src/utils/getArrayBuffer.js

@@ -0,0 +1,15 @@
+module.exports = function getArrayBuffer (chunk) {
+  return new Promise(function (resolve, reject) {
+    var reader = new FileReader()
+    reader.addEventListener('load', function (e) {
+      // e.target.result is an ArrayBuffer
+      resolve(e.target.result)
+    })
+    reader.addEventListener('error', function (err) {
+      console.error('FileReader error' + err)
+      reject(err)
+    })
+    // file-type only needs the first 4100 bytes
+    reader.readAsArrayBuffer(chunk)
+  })
+}

+ 30 - 0
src/utils/getArrayBuffer.test.js

@@ -0,0 +1,30 @@
+const getArrayBuffer = require('./getArrayBuffer')
+
+describe('getArrayBuffer', () => {
+  beforeEach(() => {
+    global.FileReader = class FileReader {
+      addEventListener (e, cb) {
+        if (e === 'load') {
+          this.loadCb = cb
+        }
+        if (e === 'error') {
+          this.errorCb = cb
+        }
+      }
+      readAsArrayBuffer (chunk) {
+        this.loadCb({ target: { result: new ArrayBuffer(8) } })
+      }
+      }
+  })
+
+  afterEach(() => {
+    global.FileReader = undefined
+  })
+
+  it('should return a promise that resolves with the specified chunk', () => {
+    return getArrayBuffer('abcde').then(buffer => {
+      expect(typeof buffer).toEqual('object')
+      expect(buffer.byteLength).toEqual(8)
+    })
+  })
+})

+ 3 - 0
src/utils/getBytesRemaining.js

@@ -0,0 +1,3 @@
+module.exports = function getBytesRemaining (fileProgress) {
+  return fileProgress.bytesTotal - fileProgress.bytesUploaded
+}

+ 11 - 0
src/utils/getBytesRemaining.test.js

@@ -0,0 +1,11 @@
+const getBytesRemaining = require('./getBytesRemaining')
+
+describe('getBytesRemaining', () => {
+  it('should calculate the bytes remaining given a fileProgress object', () => {
+    const fileProgress = {
+      bytesUploaded: 1024,
+      bytesTotal: 3096
+    }
+    expect(getBytesRemaining(fileProgress)).toEqual(2072)
+  })
+})

+ 12 - 0
src/utils/getETA.js

@@ -0,0 +1,12 @@
+const getSpeed = require('./getSpeed')
+const getBytesRemaining = require('./getBytesRemaining')
+
+module.exports = function getETA (fileProgress) {
+  if (!fileProgress.bytesUploaded) return 0
+
+  const uploadSpeed = getSpeed(fileProgress)
+  const bytesRemaining = getBytesRemaining(fileProgress)
+  const secondsRemaining = Math.round(bytesRemaining / uploadSpeed * 10) / 10
+
+  return secondsRemaining
+}

+ 14 - 0
src/utils/getETA.test.js

@@ -0,0 +1,14 @@
+const getETA = require('./getETA')
+
+describe('getETA', () => {
+  it('should get the ETA remaining based on a fileProgress object', () => {
+    const dateNow = new Date()
+    const date5SecondsAgo = new Date(dateNow.getTime() - 5 * 1000)
+    const fileProgress = {
+      bytesUploaded: 1024,
+      bytesTotal: 3096,
+      uploadStarted: date5SecondsAgo
+    }
+    expect(getETA(fileProgress)).toEqual(10.1)
+  })
+})

+ 15 - 0
src/utils/getFileNameAndExtension.js

@@ -0,0 +1,15 @@
+/**
+* Takes a full filename string and returns an object {name, extension}
+*
+* @param {string} fullFileName
+* @return {object} {name, extension}
+*/
+module.exports = function getFileNameAndExtension (fullFileName) {
+  var re = /(?:\.([^.]+))?$/
+  var fileExt = re.exec(fullFileName)[1]
+  var fileName = fullFileName.replace('.' + fileExt, '')
+  return {
+    name: fileName,
+    extension: fileExt
+  }
+}

+ 17 - 0
src/utils/getFileNameAndExtension.test.js

@@ -0,0 +1,17 @@
+const getFileNameAndExtension = require('./getFileNameAndExtension')
+
+describe('getFileNameAndExtension', () => {
+  it('should return the filename and extension as an array', () => {
+    expect(getFileNameAndExtension('fsdfjodsuf23rfw.jpg')).toEqual({
+      name: 'fsdfjodsuf23rfw',
+      extension: 'jpg'
+    })
+  })
+
+  it('should handle invalid filenames', () => {
+    expect(getFileNameAndExtension('fsdfjodsuf23rfw')).toEqual({
+      name: 'fsdfjodsuf23rfw',
+      extension: undefined
+    })
+  })
+})

+ 24 - 0
src/utils/getFileType.js

@@ -0,0 +1,24 @@
+const getFileNameAndExtension = require('./getFileNameAndExtension')
+const mimeTypes = require('./mimeTypes')
+
+module.exports = function getFileType (file) {
+  const fileExtension = file.name ? getFileNameAndExtension(file.name).extension : null
+
+  if (file.isRemote) {
+    // some remote providers do not support file types
+    return file.type ? file.type : mimeTypes[fileExtension]
+  }
+
+  // check if mime type is set in the file object
+  if (file.type) {
+    return file.type
+  }
+
+  // see if we can map extension to a mime type
+  if (fileExtension && mimeTypes[fileExtension]) {
+    return mimeTypes[fileExtension]
+  }
+
+  // if all fails, well, return empty
+  return null
+}

+ 47 - 0
src/utils/getFileType.test.js

@@ -0,0 +1,47 @@
+const getFileType = require('./getFileType')
+
+describe('getFileType', () => {
+  it('should trust the filetype if the file comes from a remote source', () => {
+    const file = {
+      isRemote: true,
+      type: 'audio/webm',
+      name: 'foo.webm'
+    }
+    expect(getFileType(file)).toEqual('audio/webm')
+  })
+
+  it('should determine the filetype from the mimetype', () => {
+    const file = {
+      type: 'audio/webm',
+      name: 'foo.webm',
+      data: 'sdfsdfhq9efbicw'
+    }
+    expect(getFileType(file)).toEqual('audio/webm')
+  })
+
+  it('should determine the filetype from the extension', () => {
+    const fileMP3 = {
+      name: 'foo.mp3',
+      data: 'sdfsfhfh329fhwihs'
+    }
+    const fileYAML = {
+      name: 'bar.yaml',
+      data: 'sdfsfhfh329fhwihs'
+    }
+    const fileMKV = {
+      name: 'bar.mkv',
+      data: 'sdfsfhfh329fhwihs'
+    }
+    expect(getFileType(fileMP3)).toEqual('audio/mp3')
+    expect(getFileType(fileYAML)).toEqual('text/yaml')
+    expect(getFileType(fileMKV)).toEqual('video/x-matroska')
+  })
+
+  it('should fail gracefully if unable to detect', () => {
+    const file = {
+      name: 'foobar',
+      data: 'sdfsfhfh329fhwihs'
+    }
+    expect(getFileType(file)).toEqual(null)
+  })
+})

+ 16 - 0
src/utils/getFileTypeExtension.js

@@ -0,0 +1,16 @@
+// TODO Check which types are actually supported in browsers. Chrome likes webm
+// from my testing, but we may need more.
+// We could use a library but they tend to contain dozens of KBs of mappings,
+// most of which will go unused, so not sure if that's worth it.
+const mimeToExtensions = {
+  'video/ogg': 'ogv',
+  'audio/ogg': 'ogg',
+  'video/webm': 'webm',
+  'audio/webm': 'webm',
+  'video/mp4': 'mp4',
+  'audio/mp3': 'mp3'
+}
+
+module.exports = function getFileTypeExtension (mimeType) {
+  return mimeToExtensions[mimeType] || null
+}

+ 13 - 0
src/utils/getFileTypeExtension.test.js

@@ -0,0 +1,13 @@
+const getFileTypeExtension = require('./getFileTypeExtension')
+
+describe('getFileTypeExtension', () => {
+  it('should return the filetype based on the specified mime type', () => {
+    expect(getFileTypeExtension('video/ogg')).toEqual('ogv')
+    expect(getFileTypeExtension('audio/ogg')).toEqual('ogg')
+    expect(getFileTypeExtension('video/webm')).toEqual('webm')
+    expect(getFileTypeExtension('audio/webm')).toEqual('webm')
+    expect(getFileTypeExtension('video/mp4')).toEqual('mp4')
+    expect(getFileTypeExtension('audio/mp3')).toEqual('mp3')
+    expect(getFileTypeExtension('foo/bar')).toEqual(null)
+  })
+})

+ 8 - 0
src/utils/getSocketHost.js

@@ -0,0 +1,8 @@
+module.exports = function getSocketHost (url) {
+  // get the host domain
+  var regex = /^(?:https?:\/\/|\/\/)?(?:[^@\n]+@)?(?:www\.)?([^\n]+)/
+  var host = regex.exec(url)[1]
+  var socketProtocol = location.protocol === 'https:' ? 'wss' : 'ws'
+
+  return `${socketProtocol}://${host}`
+}

+ 9 - 0
src/utils/getSocketHost.test.js

@@ -0,0 +1,9 @@
+const getSocketHost = require('./getSocketHost')
+
+describe('getSocketHost', () => {
+  it('should get the host from the specified url', () => {
+    expect(
+        getSocketHost('https://foo.bar/a/b/cd?e=fghi&l=k&m=n')
+      ).toEqual('ws://foo.bar/a/b/cd?e=fghi&l=k&m=n')
+  })
+})

+ 7 - 0
src/utils/getSpeed.js

@@ -0,0 +1,7 @@
+module.exports = function getSpeed (fileProgress) {
+  if (!fileProgress.bytesUploaded) return 0
+
+  const timeElapsed = (new Date()) - fileProgress.uploadStarted
+  const uploadSpeed = fileProgress.bytesUploaded / (timeElapsed / 1000)
+  return uploadSpeed
+}

+ 13 - 0
src/utils/getSpeed.test.js

@@ -0,0 +1,13 @@
+const getSpeed = require('./getSpeed')
+
+describe('getSpeed', () => {
+  it('should calculate the speed given a fileProgress object', () => {
+    const dateNow = new Date()
+    const date5SecondsAgo = new Date(dateNow.getTime() - 5 * 1000)
+    const fileProgress = {
+      bytesUploaded: 1024,
+      uploadStarted: date5SecondsAgo
+    }
+    expect(Math.round(getSpeed(fileProgress))).toEqual(Math.round(205))
+  })
+})

Vissa filer visades inte eftersom för många filer har ändrats