Преглед на файлове

Golden Retriever 2: Return of the Ghost (#2701)

* don’t store file.data on local files when we save to localStorage, add isGhost: true, additional event restore-confirmed

Co-Authored-By: Renée Kooi <github@kooi.me>

* Don’t double-emit upload-started for Golden Retriever-restored files that were already started

Co-Authored-By: Renée Kooi <github@kooi.me>

* removeFile: delete updatedUploads[uploadID] instead of calling _removeUpload to avoide excess state updates

Co-Authored-By: Renée Kooi <github@kooi.me>

* clear preview, throttle saving state

* add service message and ghost icons

* regenerate thumnails on restore properly

* allow replacing duplicate files if it’s a ghost

* re-generate thumbnails on componentDidUpdate

* tweak styles for ghosts

* Dashboard cleanup

* refactor: load IndexedDBStore and ServiceWorkerStore together, add uninstall, handle file-edit-complete

* recoveredState — show upload button as “restore”, hide progress pause/resume, handle abortRestore

* tweak serviceMsg mobile styles

* use i18n for strings

* Handle aborting, complete and last file removal with MetaDataStore.cleanup(instanceID)

* fix all the tests

* import .eslintrc from master

* autofix

* add lodash.throttle

* when re-selecting ghost files, only replace the blob, keeping progress, meta, etc

* Delete package-lock.json

* Update en_US locale

* add golden retriever support to darkmode styles

* add golden retriever to website example

* Update package-lock.json

Co-authored-by: Renée Kooi <github@kooi.me>
Artur Paikin преди 4 години
родител
ревизия
c4a637655d
променени са 26 файла, в които са добавени 566 реда и са изтрити 134 реда
  1. 4 2
      package-lock.json
  2. 8 3
      packages/@uppy/aws-s3-multipart/src/index.js
  3. 3 0
      packages/@uppy/core/src/_variables.scss
  4. 35 12
      packages/@uppy/core/src/index.js
  5. 5 0
      packages/@uppy/core/src/index.test.js
  6. 28 0
      packages/@uppy/dashboard/src/components/Dashboard.js
  7. 2 2
      packages/@uppy/dashboard/src/components/FileItem/FileInfo/index.js
  8. 4 0
      packages/@uppy/dashboard/src/components/FileItem/FileProgress/index.js
  9. 16 1
      packages/@uppy/dashboard/src/components/FileItem/index.js
  10. 31 0
      packages/@uppy/dashboard/src/components/FileItem/index.scss
  11. 1 0
      packages/@uppy/dashboard/src/components/FileList.js
  12. 13 0
      packages/@uppy/dashboard/src/index.js
  13. 46 1
      packages/@uppy/dashboard/src/style.scss
  14. 2 1
      packages/@uppy/golden-retriever/package.json
  15. 6 1
      packages/@uppy/golden-retriever/src/MetaDataStore.js
  16. 173 70
      packages/@uppy/golden-retriever/src/index.js
  17. 6 0
      packages/@uppy/locales/src/en_US.js
  18. 32 7
      packages/@uppy/status-bar/src/StatusBar.js
  19. 24 3
      packages/@uppy/status-bar/src/index.js
  20. 73 11
      packages/@uppy/status-bar/src/style.scss
  21. 8 6
      packages/@uppy/thumbnail-generator/src/index.js
  22. 19 13
      packages/@uppy/thumbnail-generator/src/index.test.js
  23. 10 0
      packages/@uppy/tus/src/index.js
  24. 9 0
      website/src/examples/dashboard/app.es6
  25. 4 1
      website/src/examples/dashboard/app.html
  26. 4 0
      website/src/examples/dashboard/index.ejs

+ 4 - 2
package-lock.json

@@ -58844,7 +58844,8 @@
       "license": "MIT",
       "dependencies": {
         "@transloadit/prettier-bytes": "0.0.7",
-        "@uppy/utils": "file:../utils"
+        "@uppy/utils": "file:../utils",
+        "lodash.throttle": "^4.1.1"
       },
       "peerDependencies": {
         "@uppy/core": "^1.0.0"
@@ -65555,7 +65556,8 @@
       "version": "file:packages/@uppy/golden-retriever",
       "requires": {
         "@transloadit/prettier-bytes": "0.0.7",
-        "@uppy/utils": "file:../utils"
+        "@uppy/utils": "file:../utils",
+        "lodash.throttle": "^4.1.1"
       }
     },
     "@uppy/google-drive": {

+ 8 - 3
packages/@uppy/aws-s3-multipart/src/index.js

@@ -264,8 +264,9 @@ module.exports = class AwsS3Multipart extends Plugin {
         })
       })
 
-      if (!file.isRestored) {
-        this.uppy.emit('upload-started', file, upload)
+      // Don't double-emit upload-started for Golden Retriever-restored files that were already started
+      if (!file.progress.uploadStarted || !file.isRestored) {
+        this.uppy.emit('upload-started', file)
       }
     })
   }
@@ -273,7 +274,11 @@ module.exports = class AwsS3Multipart extends Plugin {
   uploadRemote (file) {
     this.resetUploaderReferences(file.id)
 
-    this.uppy.emit('upload-started', file)
+    // Don't double-emit upload-started for Golden Retriever-restored files that were already started
+    if (!file.progress.uploadStarted || !file.isRestored) {
+      this.uppy.emit('upload-started', file)
+    }
+
     if (file.serverToken) {
       return this.connectToServerSocket(file)
     }

+ 3 - 0
packages/@uppy/core/src/_variables.scss

@@ -13,6 +13,7 @@ $green: #1bb240 !default;
 $darkgreen: #1c8b37 !default;
 $blue: #2275d7 !default;
 $lightblue: #aae1ff !default;
+$beige: #edd4b9 !default;
 
 $gray-50: #fafafa !default;
 $gray-100: #f4f4f4 !default;
@@ -27,6 +28,8 @@ $gray-700: #525252 !default;
 $gray-800: #333 !default;
 $gray-900: #1f1f1f !default;
 
+$white-50: #FFFBF7 !default;
+
 $highlight: #eceef2;
 $highlight--dark: #02baf2;
 

+ 35 - 12
packages/@uppy/core/src/index.js

@@ -204,6 +204,7 @@ class Uppy {
         type: 'info',
         message: '',
       },
+      recoveredState: null,
     })
 
     this._storeUnsubscribe = this.store.subscribe((prevState, nextState, patch) => {
@@ -614,7 +615,7 @@ class Uppy {
 
     const fileID = generateFileID(file)
 
-    if (files[fileID]) {
+    if (files[fileID] && !files[fileID].isGhost) {
       this._showOrLogErrorAndThrow(new RestrictionError(this.i18n('noDuplicates', { fileName })), { file })
     }
 
@@ -684,7 +685,18 @@ class Uppy {
     this._assertNewUploadAllowed(file)
 
     const { files } = this.getState()
-    const newFile = this._checkAndCreateFileStateObject(files, file)
+    let newFile = this._checkAndCreateFileStateObject(files, file)
+
+    // Users are asked to re-select recovered files without data,
+    // and to keep the progress, meta and everthing else, we only replace said data
+    if (files[newFile.id] && files[newFile.id].isGhost) {
+      newFile = {
+        ...files[newFile.id],
+        data: file.data,
+        isGhost: false,
+      }
+      this.log(`Replaced the blob in the restored ghost file: ${newFile.name}, ${newFile.id}`)
+    }
 
     this.setState({
       files: {
@@ -705,7 +717,9 @@ class Uppy {
   /**
    * Add multiple files to `state.files`. See the `addFile()` documentation.
    *
-   * If an error occurs while adding a file, it is logged and the user is notified. This is good for UI plugins, but not for programmatic use. Programmatic users should usually still use `addFile()` on individual files.
+   * If an error occurs while adding a file, it is logged and the user is notified.
+   * This is good for UI plugins, but not for programmatic use.
+   * Programmatic users should usually still use `addFile()` on individual files.
    */
   addFiles (fileDescriptors) {
     this._assertNewUploadAllowed()
@@ -716,9 +730,19 @@ class Uppy {
     const errors = []
     for (let i = 0; i < fileDescriptors.length; i++) {
       try {
-        const newFile = this._checkAndCreateFileStateObject(files, fileDescriptors[i])
-        newFiles.push(newFile)
+        let newFile = this._checkAndCreateFileStateObject(files, fileDescriptors[i])
+        // Users are asked to re-select recovered files without data,
+        // and to keep the progress, meta and everthing else, we only replace said data
+        if (files[newFile.id] && files[newFile.id].isGhost) {
+          newFile = {
+            ...files[newFile.id],
+            data: fileDescriptors[i].data,
+            isGhost: false,
+          }
+          this.log(`Replaced blob in a ghost file: ${newFile.name}, ${newFile.id}`)
+        }
         files[newFile.id] = newFile
+        newFiles.push(newFile)
       } catch (err) {
         if (!err.isRestriction) {
           errors.push(err)
@@ -784,13 +808,13 @@ class Uppy {
     function fileIsNotRemoved (uploadFileID) {
       return removedFiles[uploadFileID] === undefined
     }
-    const uploadsToRemove = []
+
     Object.keys(updatedUploads).forEach((uploadID) => {
       const newFileIDs = currentUploads[uploadID].fileIDs.filter(fileIsNotRemoved)
 
       // Remove the upload if no files are associated with it anymore.
       if (newFileIDs.length === 0) {
-        uploadsToRemove.push(uploadID)
+        delete updatedUploads[uploadID]
         return
       }
 
@@ -800,19 +824,17 @@ class Uppy {
       }
     })
 
-    uploadsToRemove.forEach((uploadID) => {
-      delete updatedUploads[uploadID]
-    })
-
     const stateUpdate = {
       currentUploads: updatedUploads,
       files: updatedFiles,
     }
 
-    // If all files were removed - allow new uploads!
+    // If all files were removed - allow new uploads,
+    // and clear recoveredState
     if (Object.keys(updatedFiles).length === 0) {
       stateUpdate.allowNewUpload = true
       stateUpdate.error = null
+      stateUpdate.recoveredState = null
     }
 
     this.setState(stateUpdate)
@@ -935,6 +957,7 @@ class Uppy {
     this.setState({
       totalProgress: 0,
       error: null,
+      recoveredState: null,
     })
   }
 

+ 5 - 0
packages/@uppy/core/src/index.test.js

@@ -158,6 +158,7 @@ describe('src/Core', () => {
         meta: {},
         plugins: {},
         totalProgress: 0,
+        recoveredState: null,
       }
 
       expect(core.getState()).toEqual(newState)
@@ -182,6 +183,7 @@ describe('src/Core', () => {
         meta: {},
         plugins: {},
         totalProgress: 0,
+        recoveredState: null,
       })
       // new state
       expect(stateUpdateEventMock.mock.calls[1][1]).toEqual({
@@ -195,6 +197,7 @@ describe('src/Core', () => {
         meta: {},
         plugins: {},
         totalProgress: 0,
+        recoveredState: null,
       })
     })
 
@@ -234,6 +237,7 @@ describe('src/Core', () => {
       meta: {},
       plugins: {},
       totalProgress: 0,
+      recoveredState: null,
     })
   })
 
@@ -294,6 +298,7 @@ describe('src/Core', () => {
       meta: {},
       plugins: {},
       totalProgress: 0,
+      recoveredState: null,
     })
     expect(plugin.mocks.uninstall.mock.calls.length).toEqual(1)
     expect(core.plugins[Object.keys(core.plugins)[0]].length).toEqual(0)

+ 28 - 0
packages/@uppy/dashboard/src/components/Dashboard.js

@@ -52,6 +52,18 @@ module.exports = function Dashboard (props) {
   }
 
   const showFileList = props.showSelectedFiles && !noFiles
+  const numberOfFilesForRecovery = props.recoveredState ? Object.keys(props.recoveredState.files).length : null
+
+  const renderStartOverBtn = () => {
+    return (
+      <button
+        className="uppy-u-reset uppy-c-btn uppy-Dashboard-serviceMsg-actionBtn"
+        onClick={props.handleCancelRestore}
+      >
+        {props.i18n('startOver')}
+      </button>
+    )
+  }
 
   const dashboard = (
     <div
@@ -102,6 +114,22 @@ module.exports = function Dashboard (props) {
 
           {showFileList && <PanelTopBar {...props} />}
 
+          {numberOfFilesForRecovery ? (
+            <div className="uppy-Dashboard-serviceMsg">
+              <svg className="uppy-Dashboard-serviceMsg-icon" aria-hidden="true" focusable="false" width="24" height="19" viewBox="0 0 24 19">
+                <g transform="translate(0 -1)" fill="none" fillRule="evenodd">
+                  <path d="M12.857 1.43l10.234 17.056A1 1 0 0122.234 20H1.766a1 1 0 01-.857-1.514L11.143 1.429a1 1 0 011.714 0z" fill="#FFD300" />
+                  <path fill="#000" d="M11 6h2l-.3 8h-1.4z" />
+                  <circle fill="#000" cx="12" cy="17" r="1" />
+                </g>
+              </svg>
+              {props.i18nArray('recoveredXFiles', {
+                smart_count: numberOfFilesForRecovery,
+                startOver: renderStartOverBtn(),
+              })}
+            </div>
+          ) : null}
+
           {showFileList ? (
             <FileList
               {...props}

+ 2 - 2
packages/@uppy/dashboard/src/components/FileItem/FileInfo/index.js

@@ -44,10 +44,10 @@ const renderFileName = (props) => {
 }
 
 const renderFileSize = (props) => (
-  props.file.data.size
+  props.file.size
     && (
     <div className="uppy-Dashboard-Item-statusSize">
-      {prettierBytes(props.file.data.size)}
+      {prettierBytes(props.file.size)}
     </div>
     )
 )

+ 4 - 0
packages/@uppy/dashboard/src/components/FileItem/FileProgress/index.js

@@ -116,6 +116,10 @@ module.exports = function FileProgress (props) {
     )
   }
 
+  if (props.recoveredState) {
+    return
+  }
+
   // Retry button for error
   if (props.error && !props.hideRetryButton) {
     return (

+ 16 - 1
packages/@uppy/dashboard/src/components/FileItem/index.js

@@ -18,6 +18,15 @@ module.exports = class FileItem extends Component {
     }
   }
 
+  // VirtualList mounts FileItems again and they emit `thumbnail:request`
+  // Otherwise thumbnails are broken or missing after Golden Retriever restores files
+  componentDidUpdate () {
+    const file = this.props.file
+    if (!file.preview) {
+      this.props.handleRequestThumbnail(file)
+    }
+  }
+
   componentWillUnmount () {
     const file = this.props.file
     if (!file.preview) {
@@ -34,6 +43,10 @@ module.exports = class FileItem extends Component {
     const uploadInProgress = (file.progress.uploadStarted && !file.progress.uploadComplete) || isProcessing
     const error = file.error || false
 
+    // File that Golden Retriever was able to partly restore (only meta, not blob),
+    // users still need to re-add it, so it’s a ghost
+    const isGhost = file.isGhost
+
     let showRemoveButton = this.props.individualCancellation
       ? !isUploaded
       : !uploadInProgress && !isUploaded
@@ -44,12 +57,13 @@ module.exports = class FileItem extends Component {
 
     const dashboardItemClass = classNames({
       'uppy-Dashboard-Item': true,
-      'is-inprogress': uploadInProgress,
+      'is-inprogress': uploadInProgress && !this.props.recoveredState,
       'is-processing': isProcessing,
       'is-complete': isUploaded,
       'is-error': !!error,
       'is-resumable': this.props.resumableUploads,
       'is-noIndividualCancellation': !this.props.individualCancellation,
+      'is-ghost': isGhost,
     })
 
     return (
@@ -70,6 +84,7 @@ module.exports = class FileItem extends Component {
             hideRetryButton={this.props.hideRetryButton}
             hideCancelButton={this.props.hideCancelButton}
             hidePauseResumeButton={this.props.hidePauseResumeButton}
+            recoveredState={this.props.recoveredState}
             showRemoveButtonAfterComplete={this.props.showRemoveButtonAfterComplete}
             resumableUploads={this.props.resumableUploads}
             individualCancellation={this.props.individualCancellation}

+ 31 - 0
packages/@uppy/dashboard/src/components/FileItem/index.scss

@@ -44,6 +44,37 @@
   }
 }
 
+  .uppy-Dashboard-Item.is-ghost .uppy-Dashboard-Item-preview {
+    opacity: 0.5;
+  }
+
+  .uppy-Dashboard-Item.is-ghost .uppy-Dashboard-Item-fileInfo {
+    opacity: 0.5;
+
+    [data-uppy-theme="dark"] & {
+      opacity: 0.8;
+    }
+  }
+
+  .uppy-Dashboard-Item.is-ghost .uppy-Dashboard-Item-preview:before {
+    content: '';
+    position: absolute;
+    top: 0;
+    left: 0;
+    right: 0;
+    bottom: 0;
+    background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='35' height='39' viewBox='0 0 35 39'%3E%3Cpath d='M1.708 38.66c1.709 0 3.417-3.417 6.834-3.417 3.416 0 5.125 3.417 8.61 3.417 3.348 0 5.056-3.417 8.473-3.417 4.305 0 5.125 3.417 6.833 3.417.889 0 1.709-.889 1.709-1.709v-19.68C34.167-5.757 0-5.757 0 17.271v19.68c0 .82.888 1.709 1.708 1.709zm8.542-17.084a3.383 3.383 0 01-3.417-3.416 3.383 3.383 0 013.417-3.417 3.383 3.383 0 013.417 3.417 3.383 3.383 0 01-3.417 3.416zm13.667 0A3.383 3.383 0 0120.5 18.16a3.383 3.383 0 013.417-3.417 3.383 3.383 0 013.416 3.417 3.383 3.383 0 01-3.416 3.416z' fill='%2523000' fill-rule='nonzero'/%3E%3C/svg%3E");
+    background-repeat: no-repeat;
+    background-position: 50% 10px;
+    background-size: 25px;
+    z-index: $zIndex-5;
+
+    .uppy-size--md & {
+      background-size: 40px;
+      background-position: 50% 50%;
+    }
+  }
+
   .uppy-Dashboard-Item-preview {
     // for the FileProgress.js icons
     position: relative;

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

@@ -53,6 +53,7 @@ module.exports = (props) => {
     showRemoveButtonAfterComplete: props.showRemoveButtonAfterComplete,
     isWide: props.isWide,
     metaFields: props.metaFields,
+    recoveredState: props.recoveredState,
     // callbacks
     retryUpload: props.retryUpload,
     pauseUpload: props.pauseUpload,

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

@@ -99,6 +99,12 @@ module.exports = class Dashboard extends Plugin {
           0: 'Processing %{smart_count} file',
           1: 'Processing %{smart_count} files',
         },
+        recoveredXFiles: {
+          0: 'We’ve recovered %{smart_count} file you’ve previousely selected. You can keep it or %{startOver}',
+          1: 'We’ve recovered %{smart_count} files you’ve previousely selected. You can keep them or %{startOver}',
+        },
+        reSelectGhosts: 'Please re-select (or remove) files marked with ghosts',
+        startOver: 'start over',
         // The default `poweredBy2` string only combines the `poweredBy` string (%{backwardsCompat}) with the size.
         // Locales can override `poweredBy2` to specify a different word order. This is for backwards compat with
         // Uppy 1.9.x and below which did a naive concatenation of `poweredBy2 + size` instead of using a locale-specific
@@ -701,6 +707,10 @@ module.exports = class Dashboard extends Plugin {
     }
   }
 
+  handleCancelRestore = () => {
+    this.uppy.emit('restore-canceled')
+  }
+
   _openFileEditorWhenFilesAdded = (files) => {
     const firstFile = files[0]
     if (this.canEditFile(firstFile)) {
@@ -755,6 +765,7 @@ module.exports = class Dashboard extends Plugin {
     this.uppy.off('plugin-remove', this.removeTarget)
     this.uppy.off('file-added', this.hideAllPanels)
     this.uppy.off('dashboard:modal-closed', this.hideAllPanels)
+    this.uppy.off('file-editor:complete', this.hideAllPanels)
     this.uppy.off('complete', this.handleComplete)
 
     document.removeEventListener('focus', this.recordIfFocusedOnUppyRecently)
@@ -969,6 +980,7 @@ module.exports = class Dashboard extends Plugin {
       uppy: this.uppy,
       info: this.uppy.info,
       note: this.opts.note,
+      recoveredState: state.recoveredState,
       metaFields: pluginState.metaFields,
       resumableUploads: capabilities.resumableUploads || false,
       individualCancellation: capabilities.individualCancellation,
@@ -1001,6 +1013,7 @@ module.exports = class Dashboard extends Plugin {
       allowedFileTypes: this.uppy.opts.restrictions.allowedFileTypes,
       maxNumberOfFiles: this.uppy.opts.restrictions.maxNumberOfFiles,
       showSelectedFiles: this.opts.showSelectedFiles,
+      handleCancelRestore: this.handleCancelRestore,
       handleRequestThumbnail: this.handleRequestThumbnail,
       handleCancelThumbnail: this.handleCancelThumbnail,
       // drag props

+ 46 - 1
packages/@uppy/dashboard/src/style.scss

@@ -209,6 +209,50 @@
   }
 }
 
+// Service Message
+
+.uppy-Dashboard-serviceMsg {
+  background-color: $white-50;
+  border-top: 1px solid $beige;
+  border-bottom: 1px solid $beige;
+  font-size: 12px;
+  line-height: 1.3;
+  font-weight: 500;
+  padding: 12px 50px;
+  position: relative;
+  top: -1px;
+  z-index: $zIndex-5;
+
+  .uppy-size--md & {
+    font-size: 14px;
+    line-height: 1.4;
+  }
+
+  [data-uppy-theme="dark"] & {
+    background-color: $gray-900;
+    color: $gray-200;
+    border-top: 1px solid $gray-800;
+    border-bottom: 1px solid $gray-800;
+  }
+}
+
+.uppy-Dashboard-serviceMsg-actionBtn {
+  font-size: inherit;
+  font-weight: inherit;
+  vertical-align: initial;
+  color: $blue;
+
+  [data-uppy-theme="dark"] & {
+    color: rgba($highlight--dark, 0.9);
+  }
+}
+
+.uppy-Dashboard-serviceMsg-icon {
+  position: absolute;
+  top: 13px;
+  left: 15px;
+}
+
 .uppy-Dashboard-AddFiles {
   display: flex;
   justify-content: center;
@@ -679,7 +723,8 @@
   }
   .uppy-DashboardContent-bar,
   .uppy-Dashboard-files,
-  .uppy-Dashboard-progressindicators {
+  .uppy-Dashboard-progressindicators,
+  .uppy-Dashboard-serviceMsg {
     opacity: 0.15;
   }
   .uppy-Dashboard-AddFiles {

+ 2 - 1
packages/@uppy/golden-retriever/package.json

@@ -24,7 +24,8 @@
   },
   "dependencies": {
     "@transloadit/prettier-bytes": "0.0.7",
-    "@uppy/utils": "file:../utils"
+    "@uppy/utils": "file:../utils",
+    "lodash.throttle": "^4.1.1"
   },
   "peerDependencies": {
     "@uppy/core": "^1.0.0"

+ 6 - 1
packages/@uppy/golden-retriever/src/MetaDataStore.js

@@ -69,7 +69,12 @@ module.exports = class MetaDataStore {
   /**
    * Remove all expired state.
    */
-  static cleanup () {
+  static cleanup (instanceID) {
+    if (instanceID) {
+      localStorage.removeItem(`uppyState:${instanceID}`)
+      return
+    }
+
     const instanceIDs = findUppyInstances()
     const now = Date.now()
     instanceIDs.forEach((id) => {

+ 173 - 70
packages/@uppy/golden-retriever/src/index.js

@@ -1,3 +1,4 @@
+const throttle = require('lodash.throttle')
 const { Plugin } = require('@uppy/core')
 const ServiceWorkerStore = require('./ServiceWorkerStore')
 const IndexedDBStore = require('./IndexedDBStore')
@@ -40,23 +41,26 @@ module.exports = class GoldenRetriever extends Plugin {
       storeName: uppy.getID(),
     })
 
-    this.saveFilesStateToLocalStorage = this.saveFilesStateToLocalStorage.bind(this)
-    this.loadFilesStateFromLocalStorage = this.loadFilesStateFromLocalStorage.bind(this)
+    this.saveFilesStateToLocalStorage = throttle(
+      this.saveFilesStateToLocalStorage.bind(this),
+      500,
+      { leading: true, trailing: true }
+    )
+    this.restoreState = this.restoreState.bind(this)
     this.loadFileBlobsFromServiceWorker = this.loadFileBlobsFromServiceWorker.bind(this)
     this.loadFileBlobsFromIndexedDB = this.loadFileBlobsFromIndexedDB.bind(this)
     this.onBlobsLoaded = this.onBlobsLoaded.bind(this)
   }
 
-  loadFilesStateFromLocalStorage () {
+  restoreState () {
     const savedState = this.MetaDataStore.load()
-
     if (savedState) {
       this.uppy.log('[GoldenRetriever] Recovered some state from Local Storage')
       this.uppy.setState({
         currentUploads: savedState.currentUploads || {},
         files: savedState.files || {},
+        recoveredState: savedState,
       })
-
       this.savedPluginData = savedState.pluginData
     }
   }
@@ -100,10 +104,37 @@ module.exports = class GoldenRetriever extends Plugin {
   }
 
   saveFilesStateToLocalStorage () {
-    const filesToSave = Object.assign(
-      this.getWaitingFiles(),
-      this.getUploadingFiles()
-    )
+    const filesToSave = {
+      ...this.getWaitingFiles(),
+      ...this.getUploadingFiles(),
+    }
+
+    // If all files have been removed by the user, clear recovery state
+    if (Object.keys(filesToSave).length === 0) {
+      this.uppy.setState({ recoveredState: null })
+      MetaDataStore.cleanup(this.uppy.opts.id)
+      return
+    }
+
+    // We dont’t need to store file.data on local files, because the actual blob will be restored later,
+    // and we want to avoid having weird properties in the serialized object.
+    // Also adding file.isRestored to all files, since they will be restored from local storage
+    const filesToSaveWithoutData = {}
+    Object.keys(filesToSave).forEach((file) => {
+      if (filesToSave[file].isRemote) {
+        filesToSaveWithoutData[file] = {
+          ...filesToSave[file],
+          isRestored: true,
+        }
+      } else {
+        filesToSaveWithoutData[file] = {
+          ...filesToSave[file],
+          isRestored: true,
+          data: null,
+          preview: null,
+        }
+      }
+    })
 
     const pluginData = {}
     // TODO Find a better way to do this?
@@ -114,49 +145,64 @@ module.exports = class GoldenRetriever extends Plugin {
     })
 
     const { currentUploads } = this.uppy.getState()
+
     this.MetaDataStore.save({
       currentUploads,
-      files: filesToSave,
+      files: filesToSaveWithoutData,
       pluginData,
     })
   }
 
   loadFileBlobsFromServiceWorker () {
-    this.ServiceWorkerStore.list().then((blobs) => {
+    if (!this.ServiceWorkerStore) {
+      return Promise.resolve({})
+    }
+
+    return this.ServiceWorkerStore.list().then((blobs) => {
+      const files = this.uppy.getFiles()
+      const localFilesOnly = files.filter((file) => {
+        // maybe && !file.progress.uploadComplete
+        return !file.isRemote
+      })
+
       const numberOfFilesRecovered = Object.keys(blobs).length
-      const numberOfFilesTryingToRecover = this.uppy.getFiles().length
+      const numberOfFilesTryingToRecover = localFilesOnly.length
+
       if (numberOfFilesRecovered === numberOfFilesTryingToRecover) {
         this.uppy.log(`[GoldenRetriever] Successfully recovered ${numberOfFilesRecovered} blobs from Service Worker!`)
-        this.uppy.info(`Successfully recovered ${numberOfFilesRecovered} files`, 'success', 3000)
-        return this.onBlobsLoaded(blobs)
+        return blobs
       }
       this.uppy.log('[GoldenRetriever] No blobs found in Service Worker, trying IndexedDB now...')
-      return this.loadFileBlobsFromIndexedDB()
+      return {}
     }).catch((err) => {
       this.uppy.log('[GoldenRetriever] Failed to recover blobs from Service Worker', 'warning')
       this.uppy.log(err)
+      return {}
     })
   }
 
   loadFileBlobsFromIndexedDB () {
-    this.IndexedDBStore.list().then((blobs) => {
+    return this.IndexedDBStore.list().then((blobs) => {
       const numberOfFilesRecovered = Object.keys(blobs).length
 
       if (numberOfFilesRecovered > 0) {
         this.uppy.log(`[GoldenRetriever] Successfully recovered ${numberOfFilesRecovered} blobs from IndexedDB!`)
-        this.uppy.info(`Successfully recovered ${numberOfFilesRecovered} files`, 'success', 3000)
-        return this.onBlobsLoaded(blobs)
+        return blobs
       }
       this.uppy.log('[GoldenRetriever] No blobs found in IndexedDB')
+      return {}
     }).catch((err) => {
       this.uppy.log('[GoldenRetriever] Failed to recover blobs from IndexedDB', 'warning')
       this.uppy.log(err)
+      return {}
     })
   }
 
   onBlobsLoaded (blobs) {
     const obsoleteBlobs = []
     const updatedFiles = { ...this.uppy.getState().files }
+
+    // Loop through blobs that we can restore, add blobs to file objects
     Object.keys(blobs).forEach((fileID) => {
       const originalFile = this.uppy.getFile(fileID)
       if (!originalFile) {
@@ -169,9 +215,20 @@ module.exports = class GoldenRetriever extends Plugin {
       const updatedFileData = {
         data: cachedData,
         isRestored: true,
+        isGhost: false,
+      }
+      updatedFiles[fileID] = { ...originalFile, ...updatedFileData }
+    })
+
+    // Loop through files that we can’t restore fully — we only have meta, not blobs,
+    // set .isGhost on them, also set isRestored to all files
+    Object.keys(updatedFiles).forEach((fileID) => {
+      if (updatedFiles[fileID].data === null) {
+        updatedFiles[fileID] = {
+          ...updatedFiles[fileID],
+          isGhost: true,
+        }
       }
-      const updatedFile = { ...originalFile, ...updatedFileData }
-      updatedFiles[fileID] = updatedFile
     })
 
     this.uppy.setState({
@@ -203,71 +260,117 @@ module.exports = class GoldenRetriever extends Plugin {
     return Promise.all(promises)
   }
 
-  install () {
-    this.loadFilesStateFromLocalStorage()
-
-    if (this.uppy.getFiles().length > 0) {
-      if (this.ServiceWorkerStore) {
-        this.uppy.log('[GoldenRetriever] Attempting to load files from Service Worker...')
-        this.loadFileBlobsFromServiceWorker()
-      } else {
-        this.uppy.log('[GoldenRetriever] Attempting to load files from Indexed DB...')
-        this.loadFileBlobsFromIndexedDB()
-      }
-    } else {
-      this.uppy.log('[GoldenRetriever] No files need to be loaded, only restoring processing state...')
-      this.onBlobsLoaded([])
-    }
-
-    this.uppy.on('file-added', (file) => {
-      if (file.isRemote) return
-
-      if (this.ServiceWorkerStore) {
-        this.ServiceWorkerStore.put(file).catch((err) => {
-          this.uppy.log('[GoldenRetriever] Could not store file', 'warning')
-          this.uppy.log(err)
-        })
-      }
+  addBlobToStores = (file) => {
+    if (file.isRemote) return
 
-      this.IndexedDBStore.put(file).catch((err) => {
+    if (this.ServiceWorkerStore) {
+      this.ServiceWorkerStore.put(file).catch((err) => {
         this.uppy.log('[GoldenRetriever] Could not store file', 'warning')
         this.uppy.log(err)
       })
+    }
+
+    this.IndexedDBStore.put(file).catch((err) => {
+      this.uppy.log('[GoldenRetriever] Could not store file', 'warning')
+      this.uppy.log(err)
     })
+  }
 
-    this.uppy.on('file-removed', (file) => {
-      if (this.ServiceWorkerStore) {
-        this.ServiceWorkerStore.delete(file.id).catch((err) => {
-          this.uppy.log('[GoldenRetriever] Failed to remove file', 'warning')
-          this.uppy.log(err)
-        })
-      }
-      this.IndexedDBStore.delete(file.id).catch((err) => {
+  removeBlobFromStores = (file) => {
+    if (this.ServiceWorkerStore) {
+      this.ServiceWorkerStore.delete(file.id).catch((err) => {
         this.uppy.log('[GoldenRetriever] Failed to remove file', 'warning')
         this.uppy.log(err)
       })
+    }
+    this.IndexedDBStore.delete(file.id).catch((err) => {
+      this.uppy.log('[GoldenRetriever] Failed to remove file', 'warning')
+      this.uppy.log(err)
     })
+  }
 
-    this.uppy.on('complete', ({ successful }) => {
-      const fileIDs = successful.map((file) => file.id)
-      this.deleteBlobs(fileIDs).then(() => {
-        this.uppy.log(`[GoldenRetriever] Removed ${successful.length} files that finished uploading`)
-      }).catch((err) => {
-        this.uppy.log(`[GoldenRetriever] Could not remove ${successful.length} files that finished uploading`, 'warning')
-        this.uppy.log(err)
+  replaceBlobInStores = (file) => {
+    this.removeBlobFromStores(file)
+    this.addBlobToStores(file)
+  }
+
+  handleRestoreConfirmed = () => {
+    this.uppy.log('[GoldenRetriever] Restore confirmed, proceeding...')
+    // start all uploads again when file blobs are restored
+    const { currentUploads } = this.uppy.getState()
+    if (currentUploads) {
+      Object.keys(currentUploads).forEach((uploadId) => {
+        this.uppy.restore(uploadId, currentUploads[uploadId])
       })
+    }
+    this.uppy.upload()
+    this.uppy.setState({ recoveredState: null })
+  }
+
+  abortRestore = () => {
+    this.uppy.log('[GoldenRetriever] Aborting restore...')
+
+    const fileIDs = Object.keys(this.uppy.getState().files)
+    this.deleteBlobs(fileIDs).then(() => {
+      this.uppy.log(`[GoldenRetriever] Removed ${fileIDs.length} files`)
+    }).catch((err) => {
+      this.uppy.log(`[GoldenRetriever] Could not remove ${fileIDs.length} files`, 'warning')
+      this.uppy.log(err)
     })
 
-    this.uppy.on('state-update', this.saveFilesStateToLocalStorage)
+    this.uppy.cancelAll()
+    this.uppy.setState({ recoveredState: null })
+    MetaDataStore.cleanup(this.uppy.opts.id)
+  }
 
-    this.uppy.on('restored', () => {
-      // start all uploads again when file blobs are restored
-      const { currentUploads } = this.uppy.getState()
-      if (currentUploads) {
-        Object.keys(currentUploads).forEach((uploadId) => {
-          this.uppy.restore(uploadId, currentUploads[uploadId])
-        })
-      }
+  handleComplete = ({ successful }) => {
+    const fileIDs = successful.map((file) => file.id)
+    this.deleteBlobs(fileIDs).then(() => {
+      this.uppy.log(`[GoldenRetriever] Removed ${successful.length} files that finished uploading`)
+    }).catch((err) => {
+      this.uppy.log(`[GoldenRetriever] Could not remove ${successful.length} files that finished uploading`, 'warning')
+      this.uppy.log(err)
     })
+
+    this.uppy.setState({ recoveredState: null })
+    MetaDataStore.cleanup(this.uppy.opts.id)
+  }
+
+  restoreBlobs = () => {
+    if (this.uppy.getFiles().length > 0) {
+      Promise.all([
+        this.loadFileBlobsFromServiceWorker(),
+        this.loadFileBlobsFromIndexedDB(),
+      ]).then((resultingArrayOfObjects) => {
+        const blobs = { ...resultingArrayOfObjects[0], ...resultingArrayOfObjects[1] }
+        this.onBlobsLoaded(blobs)
+      })
+    } else {
+      this.uppy.log('[GoldenRetriever] No files need to be loaded, only restoring processing state...')
+      this.onBlobsLoaded([])
+    }
+  }
+
+  install () {
+    this.restoreState()
+    this.restoreBlobs()
+
+    this.uppy.on('file-added', this.addBlobToStores)
+    this.uppy.on('file-editor:complete', this.replaceBlobInStores)
+    this.uppy.on('file-removed', this.removeBlobFromStores)
+    this.uppy.on('state-update', this.saveFilesStateToLocalStorage)
+    this.uppy.on('restore-confirmed', this.handleRestoreConfirmed)
+    this.uppy.on('restore-canceled', this.abortRestore)
+    this.uppy.on('complete', this.handleComplete)
+  }
+
+  uninstall () {
+    this.uppy.off('file-added', this.addBlobToStores)
+    this.uppy.off('file-editor:complete', this.replaceBlobInStores)
+    this.uppy.off('file-removed', this.removeBlobFromStores)
+    this.uppy.off('state-update', this.saveFilesStateToLocalStorage)
+    this.uppy.off('restore-confirmed', this.handleRestoreConfirmed)
+    this.uppy.off('restore-canceled', this.abortRestore)
+    this.uppy.off('complete', this.handleComplete)
   }
 }

+ 6 - 0
packages/@uppy/locales/src/en_US.js

@@ -94,9 +94,14 @@ en_US.strings = {
     '0': 'Processing %{smart_count} file',
     '1': 'Processing %{smart_count} files',
   },
+  reSelectGhosts: 'Please re-select (or remove) files marked with ghosts',
   recording: 'Recording',
   recordingLength: 'Recording length %{recording_length}',
   recordingStoppedMaxSize: 'Recording stopped because the file size is about to exceed the limit',
+  recoveredXFiles: {
+    '0': 'We’ve recovered %{smart_count} file you’ve previousely selected. You can keep it or %{startOver}',
+    '1': 'We’ve recovered %{smart_count} files you’ve previousely selected. You can keep them or %{startOver}',
+  },
   removeFile: 'Remove file',
   resetFilter: 'Reset filter',
   resume: 'Resume',
@@ -116,6 +121,7 @@ en_US.strings = {
   },
   smile: 'Smile!',
   startCapturing: 'Begin screen capturing',
+  startOver: 'start over',
   startRecording: 'Begin video recording',
   stopCapturing: 'Stop screen capturing',
   stopRecording: 'Stop video recording',

+ 32 - 7
packages/@uppy/status-bar/src/StatusBar.js

@@ -49,6 +49,17 @@ function togglePauseResume (props) {
   return props.pauseAll()
 }
 
+function RenderReSelectGhosts ({ i18n }) {
+  return (
+    <div className="uppy-StatusBar-ghosts">
+      {i18n('reSelectGhosts')}
+      <svg className="uppy-c-icon uppy-StatusBar-ghostsIcon" aria-hidden="true" width="15" height="19" viewBox="0 0 35 39">
+        <path d="M1.708 38.66c1.709 0 3.417-3.417 6.834-3.417 3.416 0 5.125 3.417 8.61 3.417 3.348 0 5.056-3.417 8.473-3.417 4.305 0 5.125 3.417 6.833 3.417.889 0 1.709-.889 1.709-1.709v-19.68C34.167-5.757 0-5.757 0 17.271v19.68c0 .82.888 1.709 1.708 1.709zm8.542-17.084a3.383 3.383 0 01-3.417-3.416 3.383 3.383 0 013.417-3.417 3.383 3.383 0 013.417 3.417 3.383 3.383 0 01-3.417 3.416zm13.667 0A3.383 3.383 0 0120.5 18.16a3.383 3.383 0 013.417-3.417 3.383 3.383 0 013.416 3.417 3.383 3.383 0 01-3.416 3.416z" fillRule="nonzero" />
+      </svg>
+    </div>
+  )
+}
+
 module.exports = (props) => {
   props = props || {}
 
@@ -63,6 +74,7 @@ module.exports = (props) => {
     hidePauseResumeButton,
     hideCancelButton,
     hideRetryButton,
+    recoveredState,
   } = props
 
   const uploadState = props.uploadState
@@ -94,13 +106,19 @@ module.exports = (props) => {
   }
 
   const width = typeof progressValue === 'number' ? progressValue : 100
-  const isHidden = (uploadState === statusBarStates.STATE_WAITING && props.hideUploadButton)
+  let isHidden = (uploadState === statusBarStates.STATE_WAITING && props.hideUploadButton)
     || (uploadState === statusBarStates.STATE_WAITING && !props.newFiles > 0)
     || (uploadState === statusBarStates.STATE_COMPLETE && props.hideAfterFinish)
 
-  const showUploadBtn = !error && newFiles
+  let showUploadBtn = !error && newFiles
     && !isUploadInProgress && !isAllPaused
     && allowNewUpload && !hideUploadButton
+
+  if (recoveredState) {
+    isHidden = false
+    showUploadBtn = true
+  }
+
   const showCancelBtn = !hideCancelButton
     && uploadState !== statusBarStates.STATE_WAITING
     && uploadState !== statusBarStates.STATE_COMPLETE
@@ -117,7 +135,8 @@ module.exports = (props) => {
   const statusBarClassNames = classNames(
     { 'uppy-Root': props.isTargetDOMEl },
     'uppy-StatusBar',
-    `is-${uploadState}`
+    `is-${uploadState}`,
+    { 'has-ghosts': props.isSomeGhost }
   )
 
   return (
@@ -137,6 +156,8 @@ module.exports = (props) => {
         {showPauseResumeBtn ? <PauseResumeButton {...props} /> : null}
         {showCancelBtn ? <CancelBtn {...props} /> : null}
         {showDoneBtn ? <DoneBtn {...props} /> : null}
+
+        {props.isSomeGhost ? <RenderReSelectGhosts {...props} /> : null}
       </div>
     </div>
   )
@@ -148,20 +169,24 @@ const UploadBtn = (props) => {
     'uppy-c-btn',
     'uppy-StatusBar-actionBtn',
     'uppy-StatusBar-actionBtn--upload',
-    { 'uppy-c-btn-primary': props.uploadState === statusBarStates.STATE_WAITING }
+    { 'uppy-c-btn-primary': props.uploadState === statusBarStates.STATE_WAITING },
+    { 'uppy-StatusBar-actionBtn--disabled': props.isSomeGhost }
   )
 
+  const uploadBtnText = props.newFiles && props.isUploadStarted && !props.recoveredState
+    ? props.i18n('uploadXNewFiles', { smart_count: props.newFiles })
+    : props.i18n('uploadXFiles', { smart_count: props.newFiles })
+
   return (
     <button
       type="button"
       className={uploadBtnClassNames}
       aria-label={props.i18n('uploadXFiles', { smart_count: props.newFiles })}
       onClick={props.startUpload}
+      disabled={props.isSomeGhost}
       data-uppy-super-focusable
     >
-      {props.newFiles && props.isUploadStarted
-        ? props.i18n('uploadXNewFiles', { smart_count: props.newFiles })
-        : props.i18n('uploadXFiles', { smart_count: props.newFiles })}
+      {uploadBtnText}
     </button>
   )
 }

+ 24 - 3
packages/@uppy/status-bar/src/index.js

@@ -50,6 +50,7 @@ module.exports = class StatusBar extends Plugin {
           0: '%{smart_count} more file added',
           1: '%{smart_count} more files added',
         },
+        reSelectGhosts: 'Please re-select (or remove) files marked with ghosts',
       },
     }
 
@@ -106,12 +107,17 @@ module.exports = class StatusBar extends Plugin {
   }
 
   startUpload = () => {
+    const { recoveredState } = this.uppy.getState()
+    if (recoveredState) {
+      this.uppy.emit('restore-confirmed')
+      return
+    }
     return this.uppy.upload().catch(() => {
       // Error logged in Core
     })
   }
 
-  getUploadingState (isAllErrored, isAllComplete, files) {
+  getUploadingState (isAllErrored, isAllComplete, recoveredState, files) {
     if (isAllErrored) {
       return statusBarStates.STATE_ERROR
     }
@@ -120,6 +126,10 @@ module.exports = class StatusBar extends Plugin {
       return statusBarStates.STATE_COMPLETE
     }
 
+    if (recoveredState) {
+      return statusBarStates.STATE_WAITING
+    }
+
     let state = statusBarStates.STATE_WAITING
     const fileIDs = Object.keys(files)
     for (let i = 0; i < fileIDs.length; i++) {
@@ -149,6 +159,7 @@ module.exports = class StatusBar extends Plugin {
       allowNewUpload,
       totalProgress,
       error,
+      recoveredState,
     } = state
 
     // TODO: move this to Core, to share between Status Bar and Dashboard
@@ -156,12 +167,19 @@ module.exports = class StatusBar extends Plugin {
 
     const filesArray = Object.keys(files).map(file => files[file])
 
-    const newFiles = filesArray.filter((file) => {
+    let newFiles = filesArray.filter((file) => {
       return !file.progress.uploadStarted
         && !file.progress.preprocess
         && !file.progress.postprocess
     })
 
+    // If some state was recovered, we want to show Upload button/counter
+    // for all the files, because in this case it’s not an Upload button,
+    // but “Confirm Restore Button”
+    if (recoveredState) {
+      newFiles = filesArray
+    }
+
     const uploadStartedFiles = filesArray.filter(file => file.progress.uploadStarted)
     const pausedFiles = uploadStartedFiles.filter(file => file.isPaused)
     const completeFiles = filesArray.filter(file => file.progress.uploadComplete)
@@ -205,10 +223,11 @@ module.exports = class StatusBar extends Plugin {
     const isUploadInProgress = inProgressFiles.length > 0
     const resumableUploads = capabilities.resumableUploads || false
     const supportsUploadProgress = capabilities.uploadProgress !== false
+    const isSomeGhost = filesArray.some((file) => file.isGhost)
 
     return StatusBarUI({
       error,
-      uploadState: this.getUploadingState(isAllErrored, isAllComplete, state.files || {}),
+      uploadState: this.getUploadingState(isAllErrored, isAllComplete, recoveredState, state.files || {}),
       allowNewUpload,
       totalProgress,
       totalSize,
@@ -218,6 +237,8 @@ module.exports = class StatusBar extends Plugin {
       isAllErrored,
       isUploadStarted,
       isUploadInProgress,
+      isSomeGhost,
+      recoveredState,
       complete: completeFiles.length,
       newFiles: newFiles.length,
       numUploads: startedFiles.length,

+ 73 - 11
packages/@uppy/status-bar/src/style.scss

@@ -14,15 +14,15 @@
   z-index: $zIndex-2;
   transition: height .2s;
 
+  .uppy-size--md & {
+    height: 46px;
+  }
+
   [data-uppy-theme="dark"] & {
     background-color: $gray-900;
   }
 }
 
-  .uppy-size--md .uppy-StatusBar {
-    height: 46px;
-  }
-
   .uppy-StatusBar:before {
     content: '';
     position: absolute;
@@ -162,9 +162,6 @@
     }
   }
 
-  // .uppy-StatusBar--detailedProgress .uppy-StatusBar-statusSecondary {
-  //   display: inline-block;
-  // }
 
 .uppy-StatusBar-statusIndicator {
   position: relative;
@@ -189,6 +186,7 @@
 
 .uppy-StatusBar.is-waiting .uppy-StatusBar-actions {
   width: 100%;
+  height: 100%;
   position: static;
   padding: 0 15px;
   background-color: $gray-50;
@@ -198,6 +196,26 @@
   }
 }
 
+.uppy-StatusBar:not([aria-hidden="true"]).is-waiting.has-ghosts {
+  height: 90px;
+  flex-direction: column;
+
+  .uppy-size--md & {
+    height: 65px;
+    flex-direction: row;
+  }
+
+  .uppy-StatusBar-actions {
+    flex-direction: column;
+    justify-content: center;
+
+    .uppy-size--md & {
+      flex-direction: row;
+      justify-content: initial;
+    }
+  }
+}
+
 .uppy-StatusBar-actionCircleBtn {
   @include blue-border-focus;
   line-height: 1;
@@ -233,9 +251,13 @@
   }
 }
 
-  // .uppy-size--md .uppy-StatusBar-actionBtn {
-  //   padding: 2px 4px;
-  // }
+  .uppy-StatusBar-actionBtn--disabled {
+    opacity: 0.4;
+
+    [data-uppy-theme="dark"] & {
+      opacity: 0.7;
+    }
+  }
 
   .uppy-StatusBar-actionBtn--retry {
     @include blue-border-focus();
@@ -283,14 +305,23 @@
       background-color: darken($darkgreen, 5%);
     }
   }
+
   .uppy-size--md .uppy-StatusBar.is-waiting .uppy-StatusBar-actionBtn--upload {
     padding: 13px 22px;
     width: auto;
   }
 
+  .uppy-StatusBar.is-waiting .uppy-StatusBar-actionBtn--upload.uppy-StatusBar-actionBtn--disabled:hover {
+    cursor: not-allowed;
+    background-color: $green;
+  }
+
+  [data-uppy-theme="dark"] .uppy-StatusBar.is-waiting .uppy-StatusBar-actionBtn--upload.uppy-StatusBar-actionBtn--disabled:hover {
+    background-color: $darkgreen;
+  }
+
   .uppy-StatusBar:not(.is-waiting) .uppy-StatusBar-actionBtn--upload {
     background-color: transparent;
-    // border: 1px solid $white;
     color: $blue;
   }
 
@@ -322,6 +353,37 @@
     font-size: 14px;
   }
 
+.uppy-StatusBar-ghosts {
+  font-size: 11px;
+  line-height: 1.1;
+  color: $black;
+  padding-left: 10px;
+  
+  .uppy-size--md & {
+    font-size: 14px;
+    padding-left: 15px;
+  }
+
+  [data-uppy-theme="dark"] & {
+    color: $gray-200;
+  }
+}
+
+.uppy-StatusBar-ghostsIcon {
+  opacity: 0.5;
+  vertical-align: text-bottom;
+  position: relative;
+  top: 2px;
+  left: 6px;
+  width: 10px;
+
+  .uppy-size--md & {
+    width: 15px;
+    left: 10px;
+    top: 1px;
+  }
+}
+
 .uppy-StatusBar-details {
   line-height: 12px;
   width: 13px;

+ 8 - 6
packages/@uppy/thumbnail-generator/src/index.js

@@ -308,7 +308,12 @@ module.exports = class ThumbnailGenerator extends Plugin {
   }
 
   onFileAdded = (file) => {
-    if (!file.preview && isPreviewSupported(file.type) && !file.isRemote) {
+    if (
+      !file.preview
+      && file.data
+      && isPreviewSupported(file.type)
+      && !file.isRemote
+    ) {
       this.addToQueue(file.id)
     }
   }
@@ -339,11 +344,8 @@ module.exports = class ThumbnailGenerator extends Plugin {
   }
 
   onRestored = () => {
-    const { files } = this.uppy.getState()
-    const fileIDs = Object.keys(files)
-    fileIDs.forEach((fileID) => {
-      const file = this.uppy.getFile(fileID)
-      if (!file.isRestored) return
+    const restoredFiles = this.uppy.getFiles().filter(file => file.isRestored)
+    restoredFiles.forEach((file) => {
       // Only add blob URLs; they are likely invalid after being restored.
       if (!file.preview || isObjectURL(file.preview)) {
         this.addToQueue(file.id)

+ 19 - 13
packages/@uppy/thumbnail-generator/src/index.test.js

@@ -101,9 +101,9 @@ describe('uploader/ThumbnailGeneratorPlugin', () => {
       plugin.requestThumbnail = jest.fn(() => delay(100))
       plugin.install()
 
-      const file1 = { id: 'bar', type: 'image/jpeg' }
-      const file2 = { id: 'bar2', type: 'image/jpeg' }
-      const file3 = { id: 'bar3', type: 'image/jpeg' }
+      const file1 = { id: 'bar', type: 'image/jpeg', data: new Blob() }
+      const file2 = { id: 'bar2', type: 'image/jpeg', data: new Blob() }
+      const file3 = { id: 'bar3', type: 'image/jpeg', data: new Blob() }
       core.mockFile(file1.id, file1)
       core.emit('file-added', file1)
       core.mockFile(file2.id, file2)
@@ -148,8 +148,8 @@ describe('uploader/ThumbnailGeneratorPlugin', () => {
           if (id === 2) file2.preview = preview
         })
 
-        const file1 = { id: 1, name: 'bar.jpg', type: 'image/jpeg' }
-        const file2 = { id: 2, name: 'bar2.jpg', type: 'image/jpeg' }
+        const file1 = { id: 1, name: 'bar.jpg', type: 'image/jpeg', data: new Blob() }
+        const file2 = { id: 2, name: 'bar2.jpg', type: 'image/jpeg', data: new Blob() }
         core.mockFile(file1.id, file1)
         core.emit('file-added', file1)
         core.mockFile(file2.id, file2)
@@ -195,16 +195,16 @@ describe('uploader/ThumbnailGeneratorPlugin', () => {
         }
         if (expected.length === 0) resolve()
       })
-      add({ id: 'bar', type: 'image/png' })
-      add({ id: 'bar2', type: 'image/png' })
-      add({ id: 'bar3', type: 'image/png' })
+      add({ id: 'bar', type: 'image/png', data: new Blob() })
+      add({ id: 'bar2', type: 'image/png', data: new Blob() })
+      add({ id: 'bar3', type: 'image/png', data: new Blob() })
     }))
 
     it('should emit thumbnail:all-generated when all thumbnails were generated', () => {
       return new Promise((resolve) => {
         core.on('thumbnail:all-generated', resolve)
-        add({ id: 'bar4', type: 'image/png' })
-        add({ id: 'bar5', type: 'image/png' })
+        add({ id: 'bar4', type: 'image/png', data: new Blob() })
+        add({ id: 'bar5', type: 'image/png', data: new Blob() })
       }).then(() => {
         expect(plugin.queue).toHaveLength(0)
       })
@@ -484,9 +484,9 @@ describe('uploader/ThumbnailGeneratorPlugin', () => {
   describe('onRestored', () => {
     it('should enqueue restored files', () => {
       const files = {
-        a: { id: 'a', type: 'image/jpeg', preview: 'blob:abc', isRestored: true },
-        b: { id: 'b', type: 'image/jpeg', preview: 'blob:def' },
-        c: { id: 'c', type: 'image/jpeg', preview: 'blob:xyz', isRestored: true },
+        a: { id: 'a', type: 'image/jpeg', isRestored: true, data: new Blob() },
+        b: { id: 'b', type: 'image/jpeg', data: new Blob() },
+        c: { id: 'c', type: 'image/jpeg', isRestored: true, data: new Blob() },
       }
       const core = Object.assign(new MockCore(), {
         getState () {
@@ -495,6 +495,9 @@ describe('uploader/ThumbnailGeneratorPlugin', () => {
         getFile (id) {
           return files[id]
         },
+        getFiles () {
+          return Object.values(files)
+        },
       })
 
       const plugin = new ThumbnailGeneratorPlugin(core)
@@ -519,6 +522,9 @@ describe('uploader/ThumbnailGeneratorPlugin', () => {
         getFile (id) {
           return files[id]
         },
+        getFiles () {
+          return Object.values(files)
+        },
       })
 
       const plugin = new ThumbnailGeneratorPlugin(core)

+ 10 - 0
packages/@uppy/tus/src/index.js

@@ -647,8 +647,18 @@ module.exports = class Tus extends Plugin {
       if ('error' in file && file.error) {
         return Promise.reject(new Error(file.error))
       } if (file.isRemote) {
+        // We emit upload-started here, so that it's also emitted for files
+        // that have to wait due to the `limit` option.
+        // Don't double-emit upload-started for Golden Retriever-restored files that were already started
+        if (!file.progress.uploadStarted || !file.isRestored) {
+          this.uppy.emit('upload-started', file)
+        }
         return this.uploadRemote(file, current, total)
       }
+      // Don't double-emit upload-started for Golden Retriever-restored files that were already started
+      if (!file.progress.uploadStarted || !file.isRestored) {
+        this.uppy.emit('upload-started', file)
+      }
       return this.upload(file, current, total)
     })
 

+ 9 - 0
website/src/examples/dashboard/app.es6

@@ -14,6 +14,7 @@ const Webcam = require('@uppy/webcam')
 const ScreenCapture = require('@uppy/screen-capture')
 const Tus = require('@uppy/tus')
 const DropTarget = require('@uppy/drop-target')
+const GoldenRetriever = require('@uppy/golden-retriever')
 const localeList = require('../locale_list.json')
 
 const COMPANION = require('../env')
@@ -181,6 +182,14 @@ function uppySetOptions () {
   if (!opts.DropTarget && dropTargetInstance) {
     window.uppy.removePlugin(dropTargetInstance)
   }
+
+  const goldenRetrieverInstance = window.uppy.getPlugin('GoldenRetriever')
+  if (opts.GoldenRetriever && !goldenRetrieverInstance) {
+    window.uppy.use(GoldenRetriever)
+  }
+  if (!opts.GoldenRetriever && goldenRetrieverInstance) {
+    window.uppy.removePlugin(goldenRetrieverInstance)
+  }
 }
 
 function whenLocaleAvailable (localeName, callback) {

+ 4 - 1
website/src/examples/dashboard/app.html

@@ -9,6 +9,7 @@
     <li><label for="opts-imageEditor"><input type="checkbox" id="opts-imageEditor" checked/> Image Editor</label></li>
     <li><label for="opts-darkMode"><input type="checkbox" id="opts-darkMode" /> Dark Mode</label></li>
     <li><label for="opts-disabled"><input type="checkbox" id="opts-disabled" checked/> Disabled</label></li>
+    <li><label for="opts-GoldenRetriever"><input type="checkbox" id="opts-GoldenRetriever" checked/> Recover incomplete uploads</label></li>
   </ul>
   <ul>
     <li><label for="opts-Webcam"><input type="checkbox" id="opts-Webcam" checked/> Webcam</label></li>
@@ -55,7 +56,8 @@
     darkMode: document.querySelector('#opts-darkMode'),
     imageEditor: document.querySelector('#opts-imageEditor'),
     disabled: document.querySelector('#opts-disabled'),
-    DropTarget: document.querySelector('#opts-DropTarget')
+    DropTarget: document.querySelector('#opts-DropTarget'),
+    GoldenRetriever: document.querySelector('#opts-GoldenRetriever')
   }
 
   var defaultOpts = {
@@ -74,6 +76,7 @@
     imageEditor: true,
     disabled: false,
     DropTarget: true,
+    GoldenRetriever: false,
   }
 
   // try to get options from localStorage, if its empty, set to defaultOpts

+ 4 - 0
website/src/examples/dashboard/index.ejs

@@ -33,6 +33,9 @@ const Webcam = require('@uppy/webcam')
 const ScreenCapture = require('@uppy/screen-capture')
 const ImageEditor = require('@uppy/image-editor')
 const Tus = require('@uppy/tus')
+const Url = require('@uppy/url')
+const DropTarget = require('@uppy/drop-target')
+const GoldenRetriever = require('@uppy/golden-retriever')
 
 const uppy = new Uppy({
   debug: true,
@@ -68,6 +71,7 @@ const uppy = new Uppy({
 .use(ImageEditor, { target: Dashboard })
 .use(Tus, { endpoint: 'https://tusd.tusdemo.net/files/' })
 .use(DropTarget, {target: document.body })
+.use(GoldenRetriever)
 
 uppy.on('complete', result => {
   console.log('successful files:', result.successful)