Quellcode durchsuchen

Make `@uppy/unsplash` production ready (#3196)

Co-authored-by: Alexander Zaytsev <nqst@users.noreply.github.com>
Co-authored-by: Antoine du Hamel <duhamelantoine1995@gmail.com>
Merlijn Vos vor 3 Jahren
Ursprung
Commit
b7559acd7c

+ 4 - 0
packages/@uppy/companion/src/server/provider/unsplash/adapter.js

@@ -49,3 +49,7 @@ exports.getNextPageQuery = (currentQuery) => {
   delete query.q
   return querystring.stringify(query)
 }
+
+exports.getAuthor = (item) => {
+  return { name: item.user.name, url: item.user.links.html }
+}

+ 57 - 51
packages/@uppy/companion/src/server/provider/unsplash/index.js

@@ -7,6 +7,33 @@ const { ProviderApiError } = require('../error')
 
 const BASE_URL = 'https://api.unsplash.com'
 
+function adaptData (body, currentQuery) {
+  const pagesCount = body.total_pages
+  const currentPage = Number(currentQuery.cursor || 1)
+  const hasNextPage = currentPage < pagesCount
+  const subList = adapter.getItemSubList(body) || []
+
+  return {
+    searchedFor: currentQuery.q,
+    username: null,
+    items: subList.map((item) => ({
+      isFolder: adapter.isFolder(item),
+      icon: adapter.getItemIcon(item),
+      name: adapter.getItemName(item),
+      mimeType: adapter.getMimeType(item),
+      id: adapter.getItemId(item),
+      thumbnail: adapter.getItemThumbnailUrl(item),
+      requestPath: adapter.getItemRequestPath(item),
+      modifiedDate: adapter.getItemModifiedDate(item),
+      author: adapter.getAuthor(item),
+      size: null,
+    })),
+    nextPageQuery: hasNextPage
+      ? adapter.getNextPageQuery(currentQuery)
+      : null,
+  }
+}
+
 /**
  * Adapter for API https://api.unsplash.com
  */
@@ -31,11 +58,11 @@ class Unsplash extends SearchProvider {
 
     request(reqOpts, (err, resp, body) => {
       if (err || resp.statusCode !== 200) {
-        err = this._error(err, resp)
-        logger.error(err, 'provider.unsplash.list.error')
-        return done(err)
+        const error = this.error(err, resp)
+        logger.error(error, 'provider.unsplash.list.error')
+        return done(error)
       }
-      done(null, this.adaptData(body, query))
+      return done(null, adaptData(body, query))
     })
   }
 
@@ -48,28 +75,33 @@ class Unsplash extends SearchProvider {
         Authorization: `Client-ID ${token}`,
       },
     }
-
     request(reqOpts, (err, resp, body) => {
       if (err || resp.statusCode !== 200) {
-        err = this._error(err, resp)
-        logger.error(err, 'provider.unsplash.download.error')
-        onData(err)
+        const error = this.error(err, resp)
+        logger.error(error, 'provider.unsplash.download.error')
+        onData(error)
         return
       }
 
       const url = body.links.download
-      request.get(url)
-        .on('response', (resp) => {
-          if (resp.statusCode !== 200) {
-            onData(this._error(null, resp))
+
+      request
+        .get(url)
+        .on('response', (response) => {
+          if (response.statusCode !== 200) {
+            onData(this.error(null, response))
           } else {
-            resp.on('data', (chunk) => onData(null, chunk))
+            response.on('data', (chunk) => onData(null, chunk))
           }
         })
         .on('end', () => onData(null, null))
-        .on('error', (err) => {
-          logger.error(err, 'provider.unsplash.download.url.error')
-          onData(err)
+        // To attribute the author of the image, we call the `download_location`
+        // endpoint to increment the download count on Unsplash.
+        // https://help.unsplash.com/en/articles/2511258-guideline-triggering-a-download
+        .on('complete', () => request({ ...reqOpts, url: body.links.download_location }))
+        .on('error', (error) => {
+          logger.error(error, 'provider.unsplash.download.url.error')
+          onData(error)
         })
     })
   }
@@ -86,53 +118,27 @@ class Unsplash extends SearchProvider {
 
     request(reqOpts, (err, resp, body) => {
       if (err || resp.statusCode !== 200) {
-        err = this._error(err, resp)
-        logger.error(err, 'provider.unsplash.size.error')
-        done(err)
+        const error = this.error(err, resp)
+        logger.error(error, 'provider.unsplash.size.error')
+        done(error)
         return
       }
 
       getURLMeta(body.links.download)
         .then(({ size }) => done(null, size))
-        .catch((err) => {
-          logger.error(err, 'provider.unsplash.size.error')
+        .catch((error) => {
+          logger.error(error, 'provider.unsplash.size.error')
           done()
         })
     })
   }
 
-  adaptData (body, currentQuery) {
-    const data = {
-      searchedFor: currentQuery.q,
-      username: null,
-      items: [],
-    }
-    const items = adapter.getItemSubList(body)
-    items.forEach((item) => {
-      data.items.push({
-        isFolder: adapter.isFolder(item),
-        icon: adapter.getItemIcon(item),
-        name: adapter.getItemName(item),
-        mimeType: adapter.getMimeType(item),
-        id: adapter.getItemId(item),
-        thumbnail: adapter.getItemThumbnailUrl(item),
-        requestPath: adapter.getItemRequestPath(item),
-        modifiedDate: adapter.getItemModifiedDate(item),
-        size: null,
-      })
-    })
-
-    const pagesCount = body.total_pages
-    const currentPage = parseInt(currentQuery.cursor || 1)
-    const hasNextPage = currentPage < pagesCount
-    data.nextPageQuery = hasNextPage ? adapter.getNextPageQuery(currentQuery) : null
-    return data
-  }
-
-  _error (err, resp) {
+  // eslint-disable-next-line class-methods-use-this
+  error (err, resp) {
     if (resp) {
       const fallbackMessage = `request to Unsplash returned ${resp.statusCode}`
-      const msg = resp.body && resp.body.errors ? `${resp.body.errors}` : fallbackMessage
+      const msg
+        = resp.body && resp.body.errors ? `${resp.body.errors}` : fallbackMessage
       return new ProviderApiError(msg, resp.statusCode)
     }
 

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

@@ -1,51 +1,75 @@
-const { h } = require('preact')
+const { h, Fragment } = require('preact')
 const prettierBytes = require('@transloadit/prettier-bytes')
 const truncateString = require('@uppy/utils/lib/truncateString')
 
 const renderFileName = (props) => {
-  // Take up at most 2 lines on any screen
-  let maxNameLength
-  // For very small mobile screens
-  if (props.containerWidth <= 352) {
-    maxNameLength = 35
-  // For regular mobile screens
-  } else if (props.containerWidth <= 576) {
-    maxNameLength = 60
-  // For desktops
-  } else {
-    maxNameLength = 30
+  const { author, name } = props.file.meta
+
+  function getMaxNameLength () {
+    if (props.containerWidth <= 352) {
+      return 35
+    }
+    if (props.containerWidth <= 576) {
+      return 60
+    }
+    // When `author` is present, we want to make sure
+    // the file name fits on one line so we can place
+    // the author on the second line.
+    return author ? 20 : 30
   }
 
   return (
-    <div className="uppy-Dashboard-Item-name" title={props.file.meta.name}>
-      {truncateString(props.file.meta.name, maxNameLength)}
+    <div className="uppy-Dashboard-Item-name" title={name}>
+      {truncateString(name, getMaxNameLength())}
     </div>
   )
 }
 
-const renderFileSize = (props) => (
-  props.file.size
-    && (
-    <div className="uppy-Dashboard-Item-statusSize">
-      {prettierBytes(props.file.size)}
+const renderAuthor = (props) => {
+  const { author } = props.file.meta
+  const { providerName } = props.file.remote
+  const dot = `\u00B7`
+
+  if (!author) {
+    return null
+  }
+
+  return (
+    <div className="uppy-Dashboard-Item-author">
+      <a
+        href={`${author.url}?utm_source=Companion&utm_medium=referral`}
+        target="_blank"
+        rel="noopener noreferrer"
+      >
+        {truncateString(author.name, 13)}
+      </a>
+      {providerName ? (
+        <Fragment>
+          {` ${dot} `}
+          {providerName}
+        </Fragment>
+      ) : null}
     </div>
-    )
+  )
+}
+
+const renderFileSize = (props) => props.file.size && (
+<div className="uppy-Dashboard-Item-statusSize">
+  {prettierBytes(props.file.size)}
+</div>
 )
 
-const ReSelectButton = (props) => (
-  props.file.isGhost
-    && (
-      <span>
-        {' \u2022 '}
-        <button
-          className="uppy-u-reset uppy-c-btn uppy-Dashboard-Item-reSelect"
-          type="button"
-          onClick={props.toggleAddFilesPanel}
-        >
-          {props.i18n('reSelect')}
-        </button>
-      </span>
-    )
+const ReSelectButton = (props) => props.file.isGhost && (
+<span>
+  {' \u2022 '}
+  <button
+    className="uppy-u-reset uppy-c-btn uppy-Dashboard-Item-reSelect"
+    type="button"
+    onClick={props.toggleAddFilesPanel}
+  >
+    {props.i18n('reSelect')}
+  </button>
+</span>
 )
 
 const ErrorButton = ({ file, onClick }) => {
@@ -68,10 +92,14 @@ const ErrorButton = ({ file, onClick }) => {
 
 module.exports = function FileInfo (props) {
   return (
-    <div className="uppy-Dashboard-Item-fileInfo" data-uppy-file-source={props.file.source}>
+    <div
+      className="uppy-Dashboard-Item-fileInfo"
+      data-uppy-file-source={props.file.source}
+    >
       {renderFileName(props)}
       <div className="uppy-Dashboard-Item-status">
         {renderFileSize(props)}
+        {renderAuthor(props)}
         {ReSelectButton(props)}
         <ErrorButton
           file={props.file}

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

@@ -17,6 +17,19 @@
   }
 }
 
+.uppy-Dashboard-Item-author {
+  color: $gray-600;
+  vertical-align: bottom;
+  font-size: 11px;
+  font-weight: normal;
+  display: inline-block;
+  line-height: 1;
+
+  a {
+    color: $gray-600;
+  }
+}
+
 .uppy-Dashboard-Item-status {
   color: $gray-600;
   font-weight: normal;

+ 116 - 36
packages/@uppy/provider-views/src/Browser.js

@@ -1,57 +1,137 @@
-const classNames = require('classnames')
 const { h } = require('preact')
+const classNames = require('classnames')
+
+const remoteFileObjToLocal = require('@uppy/utils/lib/remoteFileObjToLocal')
+
 const Filter = require('./Filter')
-const ItemList = require('./ItemList')
 const FooterActions = require('./FooterActions')
+const Item = require('./Item/index')
 
-const Browser = (props) => {
+const VIRTUAL_SHARED_DIR = 'shared-with-me'
+
+function Browser (props) {
   const {
     currentSelection,
     folders,
     files,
     uppyFiles,
-    filterItems,
+    viewType,
+    headerComponent,
+    showBreadcrumbs,
+    isChecked,
+    toggleCheckbox,
+    handleScroll,
+    showTitles,
+    i18n,
+    validateRestrictions,
+    showFilter,
+    filterQuery,
     filterInput,
+    getNextFolder,
+    cancel,
+    done,
+    columns,
   } = props
 
-  let filteredFolders = folders
-  let filteredFiles = files
-
-  if (filterInput !== '') {
-    filteredFolders = filterItems(folders)
-    filteredFiles = filterItems(files)
-  }
-
   const selected = currentSelection.length
 
   return (
-    <div className={classNames('uppy-ProviderBrowser', `uppy-ProviderBrowser-viewType--${props.viewType}`)}>
+    <div
+      className={classNames(
+        'uppy-ProviderBrowser',
+        `uppy-ProviderBrowser-viewType--${viewType}`
+      )}
+    >
       <div className="uppy-ProviderBrowser-header">
-        <div className={classNames('uppy-ProviderBrowser-headerBar', !props.showBreadcrumbs && 'uppy-ProviderBrowser-headerBar--simple')}>
-          {props.headerComponent}
+        <div
+          className={classNames(
+            'uppy-ProviderBrowser-headerBar',
+            !showBreadcrumbs && 'uppy-ProviderBrowser-headerBar--simple'
+          )}
+        >
+          {headerComponent}
         </div>
       </div>
-      {props.showFilter && <Filter {...props} />}
-      <ItemList
-        columns={[{
-          name: 'Name',
-          key: 'title',
-        }]}
-        folders={filteredFolders}
-        files={filteredFiles}
-        isChecked={props.isChecked}
-        handleFolderClick={props.getNextFolder}
-        toggleCheckbox={props.toggleCheckbox}
-        handleScroll={props.handleScroll}
-        title={props.title}
-        showTitles={props.showTitles}
-        i18n={props.i18n}
-        viewType={props.viewType}
-        validateRestrictions={props.validateRestrictions}
-        uppyFiles={uppyFiles}
-        currentSelection={currentSelection}
-      />
-      {selected > 0 && <FooterActions selected={selected} {...props} />}
+
+      {showFilter && (
+        <Filter
+          i18n={i18n}
+          filterQuery={filterQuery}
+          filterInput={filterInput}
+        />
+      )}
+
+      {(() => {
+        if (!folders.length && !files.length) {
+          return (
+            <div className="uppy-Provider-empty">
+              {props.i18n('noFilesFound')}
+            </div>
+          )
+        }
+
+        return (
+          <div className="uppy-ProviderBrowser-body">
+            <ul
+              className="uppy-ProviderBrowser-list"
+              onScroll={handleScroll}
+              role="listbox"
+              // making <ul> not focusable for firefox
+              tabIndex="-1"
+            >
+              {folders.map((folder) => {
+                return Item({
+                  columns,
+                  showTitles,
+                  viewType,
+                  i18n,
+                  id: folder.id,
+                  title: folder.name,
+                  getItemIcon: () => folder.icon,
+                  isChecked: isChecked(folder),
+                  toggleCheckbox: (event) => toggleCheckbox(event, folder),
+                  type: 'folder',
+                  isDisabled: isChecked(folder)?.loading,
+                  isCheckboxDisabled: folder.id === VIRTUAL_SHARED_DIR,
+                  handleFolderClick: () => getNextFolder(folder),
+                })
+              })}
+
+              {files.map((file) => {
+                const validated = validateRestrictions(
+                  remoteFileObjToLocal(file),
+                  [...uppyFiles, ...currentSelection]
+                )
+
+                return Item({
+                  id: file.id,
+                  title: file.name,
+                  author: file.author,
+                  getItemIcon: () => file.icon,
+                  isChecked: isChecked(file),
+                  toggleCheckbox: (event) => toggleCheckbox(event, file),
+                  columns,
+                  showTitles,
+                  viewType,
+                  i18n,
+                  type: 'file',
+                  isDisabled: !validated.result && isChecked(file),
+                  restrictionReason: validated.reason,
+                })
+              })}
+            </ul>
+          </div>
+        )
+      })()}
+
+      {selected > 0 && (
+        <FooterActions
+          selected={selected}
+          done={done}
+          cancel={cancel}
+          i18n={i18n}
+        />
+      )}
     </div>
   )
 }

+ 8 - 2
packages/@uppy/provider-views/src/Item/components/GridLi.js

@@ -11,6 +11,7 @@ function GridListItem (props) {
     showTitles,
     toggleCheckbox,
     id,
+    children,
   } = props
 
   return (
@@ -35,8 +36,13 @@ function GridListItem (props) {
         aria-label={title}
         className="uppy-u-reset uppy-ProviderBrowserItem-inner"
       >
-        {itemIconEl}
-        {showTitles && title}
+        <span className="uppy-ProviderBrowserItem-inner-relative">
+          {itemIconEl}
+
+          {showTitles && title}
+
+          {children}
+        </span>
       </label>
     </li>
   )

+ 26 - 4
packages/@uppy/provider-views/src/Item/index.js

@@ -1,10 +1,11 @@
 const { h } = require('preact')
 const classNames = require('classnames')
 const ItemIcon = require('./components/ItemIcon')
-const GridLi = require('./components/GridLi')
-const ListLi = require('./components/ListLi')
+const GridListItem = require('./components/GridLi')
+const ListItem = require('./components/ListLi')
 
 module.exports = (props) => {
+  const { author } = props
   const itemIconString = props.getItemIcon()
 
   const className = classNames(
@@ -18,9 +19,30 @@ module.exports = (props) => {
 
   switch (props.viewType) {
     case 'grid':
-      return <GridLi {...props} className={className} itemIconEl={itemIconEl} />
+      return (
+        <GridListItem
+          {...props}
+          className={className}
+          itemIconEl={itemIconEl}
+        />
+      )
     case 'list':
-      return <ListLi {...props} className={className} itemIconEl={itemIconEl} />
+      return (
+        <ListItem {...props} className={className} itemIconEl={itemIconEl} />
+      )
+    case 'unsplash':
+      return (
+        <GridListItem {...props} className={className} itemIconEl={itemIconEl}>
+          <a
+            href={`${author.url}?utm_source=Companion&utm_medium=referral`}
+            target="_blank"
+            rel="noopener noreferrer"
+            className="uppy-ProviderBrowserItem-author"
+          >
+            {author.name}
+          </a>
+        </GridListItem>
+      )
     default:
       throw new Error(`There is no such type ${props.viewType}`)
   }

+ 0 - 63
packages/@uppy/provider-views/src/ItemList.js

@@ -1,63 +0,0 @@
-const { h } = require('preact')
-const remoteFileObjToLocal = require('@uppy/utils/lib/remoteFileObjToLocal')
-const Item = require('./Item/index')
-
-// Hopefully this name will not be used by Google
-const VIRTUAL_SHARED_DIR = 'shared-with-me'
-
-const getSharedProps = (fileOrFolder, props) => ({
-  id: fileOrFolder.id,
-  title: fileOrFolder.name,
-  getItemIcon: () => fileOrFolder.icon,
-  isChecked: props.isChecked(fileOrFolder),
-  toggleCheckbox: (e) => props.toggleCheckbox(e, fileOrFolder),
-  columns: props.columns,
-  showTitles: props.showTitles,
-  viewType: props.viewType,
-  i18n: props.i18n,
-})
-
-module.exports = (props) => {
-  const { folders, files, handleScroll, isChecked } = props
-
-  if (!folders.length && !files.length) {
-    return <div className="uppy-Provider-empty">{props.i18n('noFilesFound')}</div>
-  }
-
-  return (
-    <div className="uppy-ProviderBrowser-body">
-      <ul
-        className="uppy-ProviderBrowser-list"
-        onScroll={handleScroll}
-        role="listbox"
-        // making <ul> not focusable for firefox
-        tabIndex="-1"
-      >
-        {folders.map(folder => {
-          return Item({
-            ...getSharedProps(folder, props),
-            type: 'folder',
-            isDisabled: isChecked(folder) ? isChecked(folder).loading : false,
-            isCheckboxDisabled: folder.id === VIRTUAL_SHARED_DIR,
-            handleFolderClick: () => props.handleFolderClick(folder),
-          })
-        })}
-        {files.map(file => {
-          const validateRestrictions = props.validateRestrictions(
-            remoteFileObjToLocal(file),
-            [...props.uppyFiles, ...props.currentSelection]
-          )
-          const sharedProps = getSharedProps(file, props)
-          const restrictionReason = validateRestrictions.reason
-
-          return Item({
-            ...sharedProps,
-            type: 'file',
-            isDisabled: !validateRestrictions.result && !sharedProps.isChecked,
-            restrictionReason,
-          })
-        })}
-      </ul>
-    </div>
-  )
-}

+ 51 - 136
packages/@uppy/provider-views/src/ProviderView/ProviderView.js

@@ -1,13 +1,10 @@
 const { h } = require('preact')
-const generateFileID = require('@uppy/utils/lib/generateFileID')
-const getFileType = require('@uppy/utils/lib/getFileType')
-const isPreviewSupported = require('@uppy/utils/lib/isPreviewSupported')
 const AuthView = require('./AuthView')
 const Header = require('./Header')
 const Browser = require('../Browser')
 const LoaderView = require('../Loader')
-const SharedHandler = require('../SharedHandler')
 const CloseWrapper = require('../CloseWrapper')
+const View = require('../View')
 
 function getOrigin () {
   // eslint-disable-next-line no-restricted-globals
@@ -17,22 +14,15 @@ function getOrigin () {
 /**
  * Class to easily generate generic views for Provider plugins
  */
-module.exports = class ProviderView {
+module.exports = class ProviderView extends View {
   static VERSION = require('../../package.json').version
 
-  #isHandlingScroll
-
-  #sharedHandler
-
   /**
    * @param {object} plugin instance of the plugin
    * @param {object} opts
    */
   constructor (plugin, opts) {
-    this.plugin = plugin
-    this.provider = opts.provider
-    this.#sharedHandler = new SharedHandler(plugin)
-
+    super(plugin, opts)
     // set default options
     const defaultOptions = {
       viewType: 'list',
@@ -45,25 +35,18 @@ module.exports = class ProviderView {
     this.opts = { ...defaultOptions, ...opts }
 
     // Logic
-    this.addFile = this.addFile.bind(this)
     this.filterQuery = this.filterQuery.bind(this)
     this.getFolder = this.getFolder.bind(this)
     this.getNextFolder = this.getNextFolder.bind(this)
     this.logout = this.logout.bind(this)
-    this.preFirstRender = this.preFirstRender.bind(this)
     this.handleAuth = this.handleAuth.bind(this)
-    this.handleError = this.handleError.bind(this)
     this.handleScroll = this.handleScroll.bind(this)
     this.listAllFiles = this.listAllFiles.bind(this)
     this.donePicking = this.donePicking.bind(this)
-    this.cancelPicking = this.cancelPicking.bind(this)
-    this.clearSelection = this.clearSelection.bind(this)
 
     // Visual
     this.render = this.render.bind(this)
 
-    this.clearSelection()
-
     // Set default state for the plugin
     this.plugin.setPluginState({
       authenticated: false,
@@ -72,6 +55,7 @@ module.exports = class ProviderView {
       directories: [],
       filterInput: '',
       isSearchVisible: false,
+      currentSelection: [],
     })
   }
 
@@ -92,15 +76,6 @@ module.exports = class ProviderView {
     this.plugin.setPluginState({ folders, files })
   }
 
-  /**
-   * Called only the first time the provider view is rendered.
-   * Kind of like an init function.
-   */
-  preFirstRender () {
-    this.plugin.setPluginState({ didFirstRender: true })
-    this.plugin.onFirstRender()
-  }
-
   /**
    * Based on folder ID, fetch a new folder and update it to state
    *
@@ -108,7 +83,7 @@ module.exports = class ProviderView {
    * @returns {Promise}   Folders/files in folder
    */
   getFolder (id, name) {
-    return this.#sharedHandler.loaderWrapper(
+    return this.sharedHandler.loaderWrapper(
       this.provider.list(id),
       (res) => {
         const folders = []
@@ -142,44 +117,6 @@ module.exports = class ProviderView {
     this.lastCheckbox = undefined
   }
 
-  addFile (file) {
-    const tagFile = {
-      id: this.providerFileToId(file),
-      source: this.plugin.id,
-      data: file,
-      name: file.name || file.id,
-      type: file.mimeType,
-      isRemote: true,
-      body: {
-        fileId: file.id,
-      },
-      remote: {
-        companionUrl: this.plugin.opts.companionUrl,
-        url: `${this.provider.fileUrl(file.requestPath)}`,
-        body: {
-          fileId: file.id,
-        },
-        providerOptions: this.provider.opts,
-      },
-    }
-
-    const fileType = getFileType(tagFile)
-    // TODO Should we just always use the thumbnail URL if it exists?
-    if (fileType && isPreviewSupported(fileType)) {
-      tagFile.preview = file.thumbnail
-    }
-    this.plugin.uppy.log('Adding remote file')
-    try {
-      this.plugin.uppy.addFile(tagFile)
-      return true
-    } catch (err) {
-      if (!err.isRestriction) {
-        this.plugin.uppy.log(err)
-      }
-      return false
-    }
-  }
-
   /**
    * Removes session token on client side.
    */
@@ -281,14 +218,6 @@ module.exports = class ProviderView {
     })
   }
 
-  providerFileToId (file) {
-    return generateFileID({
-      data: file,
-      name: file.name || file.id,
-      type: file.mimeType,
-    })
-  }
-
   handleAuth () {
     const authState = btoa(JSON.stringify({ origin: getOrigin() }))
     const clientVersion = `@uppy/provider-views=${ProviderView.VERSION}`
@@ -341,29 +270,22 @@ module.exports = class ProviderView {
       .some((pattern) => pattern.test(origin) || pattern.test(`${origin}/`)) // allowing for trailing '/'
   }
 
-  handleError (error) {
-    const { uppy } = this.plugin
-    uppy.log(error.toString())
-    if (error.isAuthError) {
-      return
-    }
-    const message = uppy.i18n('companionError')
-    uppy.info({ message, details: error.toString() }, 'error', 5000)
-  }
-
-  handleScroll (e) {
-    const scrollPos = e.target.scrollHeight - (e.target.scrollTop + e.target.offsetHeight)
+  async handleScroll (event) {
     const path = this.nextPagePath || null
 
-    if (scrollPos < 50 && path && !this.#isHandlingScroll) {
-      this.provider.list(path)
-        .then((res) => {
-          const { files, folders } = this.plugin.getPluginState()
-          this.#updateFilesAndFolders(res, files, folders)
-        }).catch(this.handleError)
-        .then(() => { this.#isHandlingScroll = false }) // always called
+    if (this.shouldHandleScroll(event) && path) {
+      this.isHandlingScroll = true
 
-      this.#isHandlingScroll = true
+      try {
+        const response = await this.provider.list(path)
+        const { files, folders } = this.plugin.getPluginState()
+
+        this.#updateFilesAndFolders(response, files, folders)
+      } catch (error) {
+        this.handleError(error)
+      } finally {
+        this.isHandlingScroll = false
+      }
     }
   }
 
@@ -398,53 +320,22 @@ module.exports = class ProviderView {
       return this.addFile(file)
     })
 
-    this.#sharedHandler.loaderWrapper(Promise.all(promises), () => {
+    this.sharedHandler.loaderWrapper(Promise.all(promises), () => {
       this.clearSelection()
     }, () => {})
   }
 
-  cancelPicking () {
-    this.clearSelection()
-
-    const dashboard = this.plugin.uppy.getPlugin('Dashboard')
-    if (dashboard) dashboard.hideAllPanels()
-  }
-
-  clearSelection () {
-    this.plugin.setPluginState({ currentSelection: [] })
-  }
-
   render (state, viewOptions = {}) {
     const { authenticated, didFirstRender } = this.plugin.getPluginState()
+
     if (!didFirstRender) {
       this.preFirstRender()
     }
 
-    // reload pluginState for "loading" attribute because it might
-    // have changed above.
-    if (this.plugin.getPluginState().loading) {
-      return (
-        <CloseWrapper onUnmount={this.clearSelection}>
-          <LoaderView i18n={this.plugin.uppy.i18n} />
-        </CloseWrapper>
-      )
-    }
-
-    if (!authenticated) {
-      return (
-        <CloseWrapper onUnmount={this.clearSelection}>
-          <AuthView
-            pluginName={this.plugin.title}
-            pluginIcon={this.plugin.icon}
-            handleAuth={this.handleAuth}
-            i18n={this.plugin.uppy.i18n}
-            i18nArray={this.plugin.uppy.i18nArray}
-          />
-        </CloseWrapper>
-      )
-    }
-
     const targetViewOptions = { ...this.opts, ...viewOptions }
+    const { files, folders, filterInput, loading, currentSelection } = this.plugin.getPluginState()
+    const { isChecked, toggleCheckbox, filterItems } = this.sharedHandler
+    const hasInput = filterInput !== ''
     const headerProps = {
       showBreadcrumbs: targetViewOptions.showBreadcrumbs,
       getFolder: this.getFolder,
@@ -457,15 +348,17 @@ module.exports = class ProviderView {
     }
 
     const browserProps = {
-      ...this.plugin.getPluginState(),
+      isChecked,
+      toggleCheckbox,
+      currentSelection,
+      files: hasInput ? filterItems(files) : files,
+      folders: hasInput ? filterItems(folders) : folders,
       username: this.username,
       getNextFolder: this.getNextFolder,
       getFolder: this.getFolder,
-      filterItems: this.#sharedHandler.filterItems,
+      filterItems: this.sharedHandler.filterItems,
       filterQuery: this.filterQuery,
       logout: this.logout,
-      isChecked: this.#sharedHandler.isChecked,
-      toggleCheckbox: this.#sharedHandler.toggleCheckbox,
       handleScroll: this.handleScroll,
       listAllFiles: this.listAllFiles,
       done: this.donePicking,
@@ -482,6 +375,28 @@ module.exports = class ProviderView {
       validateRestrictions: (...args) => this.plugin.uppy.validateRestrictions(...args),
     }
 
+    if (loading) {
+      return (
+        <CloseWrapper onUnmount={this.clearSelection}>
+          <LoaderView i18n={this.plugin.uppy.i18n} />
+        </CloseWrapper>
+      )
+    }
+
+    if (!authenticated) {
+      return (
+        <CloseWrapper onUnmount={this.clearSelection}>
+          <AuthView
+            pluginName={this.plugin.title}
+            pluginIcon={this.plugin.icon}
+            handleAuth={this.handleAuth}
+            i18n={this.plugin.uppy.i18n}
+            i18nArray={this.plugin.uppy.i18nArray}
+          />
+        </CloseWrapper>
+      )
+    }
+
     return (
       <CloseWrapper onUnmount={this.clearSelection}>
         <Browser {...browserProps} />

+ 48 - 127
packages/@uppy/provider-views/src/SearchProviderView/SearchProviderView.js

@@ -1,34 +1,25 @@
 const { h } = require('preact')
-const generateFileID = require('@uppy/utils/lib/generateFileID')
-const getFileType = require('@uppy/utils/lib/getFileType')
-const isPreviewSupported = require('@uppy/utils/lib/isPreviewSupported')
 const SearchInput = require('./InputView')
 const Browser = require('../Browser')
 const LoaderView = require('../Loader')
 const Header = require('./Header')
-const SharedHandler = require('../SharedHandler')
 const CloseWrapper = require('../CloseWrapper')
+const View = require('../View')
 
 /**
  * Class to easily generate generic views for Provider plugins
  */
-module.exports = class ProviderView {
+module.exports = class SearchProviderView extends View {
   static VERSION = require('../../package.json').version
 
-  #isHandlingScroll
-
   #searchTerm
 
-  #sharedHandler
-
   /**
    * @param {object} plugin instance of the plugin
    * @param {object} opts
    */
   constructor (plugin, opts) {
-    this.plugin = plugin
-    this.provider = opts.provider
-    this.#sharedHandler = new SharedHandler(plugin)
+    super(plugin, opts)
 
     // set default options
     const defaultOptions = {
@@ -45,18 +36,12 @@ module.exports = class ProviderView {
     this.search = this.search.bind(this)
     this.triggerSearchInput = this.triggerSearchInput.bind(this)
     this.addFile = this.addFile.bind(this)
-    this.preFirstRender = this.preFirstRender.bind(this)
-    this.handleError = this.handleError.bind(this)
     this.handleScroll = this.handleScroll.bind(this)
     this.donePicking = this.donePicking.bind(this)
-    this.cancelPicking = this.cancelPicking.bind(this)
-    this.clearSelection = this.clearSelection.bind(this)
 
     // Visual
     this.render = this.render.bind(this)
 
-    this.clearSelection()
-
     // Set default state for the plugin
     this.plugin.setPluginState({
       isInputMode: true,
@@ -65,6 +50,7 @@ module.exports = class ProviderView {
       directories: [],
       filterInput: '',
       isSearchVisible: false,
+      currentSelection: [],
     })
   }
 
@@ -79,15 +65,6 @@ module.exports = class ProviderView {
     this.plugin.setPluginState({ isInputMode: false, files })
   }
 
-  /**
-   * Called only the first time the provider view is rendered.
-   * Kind of like an init function.
-   */
-  preFirstRender () {
-    this.plugin.setPluginState({ didFirstRender: true })
-    this.plugin.onFirstRender()
-  }
-
   search (query) {
     if (query && query === this.#searchTerm) {
       // no need to search again as this is the same as the previous search
@@ -95,7 +72,7 @@ module.exports = class ProviderView {
       return
     }
 
-    return this.#sharedHandler.loaderWrapper(
+    return this.sharedHandler.loaderWrapper(
       this.provider.search(query),
       (res) => {
         this.#updateFilesAndInputMode(res, [])
@@ -108,72 +85,22 @@ module.exports = class ProviderView {
     this.plugin.setPluginState({ isInputMode: true })
   }
 
-  // @todo this function should really be a function of the plugin and not the view.
-  // maybe we should consider creating a base ProviderPlugin class that has this method
-  addFile (file) {
-    const tagFile = {
-      id: this.providerFileToId(file),
-      source: this.plugin.id,
-      data: file,
-      name: file.name || file.id,
-      type: file.mimeType,
-      isRemote: true,
-      body: {
-        fileId: file.id,
-      },
-      remote: {
-        companionUrl: this.plugin.opts.companionUrl,
-        url: `${this.provider.fileUrl(file.requestPath)}`,
-        body: {
-          fileId: file.id,
-        },
-        providerOptions: { ...this.provider.opts, provider: null },
-      },
-    }
-
-    const fileType = getFileType(tagFile)
-    // TODO Should we just always use the thumbnail URL if it exists?
-    if (fileType && isPreviewSupported(fileType)) {
-      tagFile.preview = file.thumbnail
-    }
-    this.plugin.uppy.log('Adding remote file')
-    try {
-      this.plugin.uppy.addFile(tagFile)
-    } catch (err) {
-      if (!err.isRestriction) {
-        this.plugin.uppy.log(err)
-      }
-    }
-  }
-
-  providerFileToId (file) {
-    return generateFileID({
-      data: file,
-      name: file.name || file.id,
-      type: file.mimeType,
-    })
-  }
-
-  handleError (error) {
-    const { uppy } = this.plugin
-    uppy.log(error.toString())
-    const message = uppy.i18n('companionError')
-    uppy.info({ message, details: error.toString() }, 'error', 5000)
-  }
-
-  handleScroll (e) {
-    const scrollPos = e.target.scrollHeight - (e.target.scrollTop + e.target.offsetHeight)
+  async handleScroll (event) {
     const query = this.nextPageQuery || null
 
-    if (scrollPos < 50 && query && !this.#isHandlingScroll) {
-      this.provider.search(this.#searchTerm, query)
-        .then((res) => {
-          const { files } = this.plugin.getPluginState()
-          this.#updateFilesAndInputMode(res, files)
-        }).catch(this.handleError)
-        .then(() => { this.#isHandlingScroll = false }) // always called
+    if (this.shouldHandleScroll(event) && query) {
+      this.isHandlingScroll = true
 
-      this.#isHandlingScroll = true
+      try {
+        const response = await this.provider.search(this.#searchTerm, query)
+        const { files } = this.plugin.getPluginState()
+
+        this.#updateFilesAndInputMode(response, files)
+      } catch (error) {
+        this.handleError(error)
+      } finally {
+        this.isHandlingScroll = false
+      }
     }
   }
 
@@ -181,54 +108,29 @@ module.exports = class ProviderView {
     const { currentSelection } = this.plugin.getPluginState()
     const promises = currentSelection.map((file) => this.addFile(file))
 
-    this.#sharedHandler.loaderWrapper(Promise.all(promises), () => {
+    this.sharedHandler.loaderWrapper(Promise.all(promises), () => {
       this.clearSelection()
     }, () => {})
   }
 
-  cancelPicking () {
-    this.clearSelection()
-
-    const dashboard = this.plugin.uppy.getPlugin('Dashboard')
-    if (dashboard) dashboard.hideAllPanels()
-  }
-
-  clearSelection () {
-    this.plugin.setPluginState({ currentSelection: [] })
-  }
-
   render (state, viewOptions = {}) {
     const { didFirstRender, isInputMode } = this.plugin.getPluginState()
+
     if (!didFirstRender) {
       this.preFirstRender()
     }
 
-    // reload pluginState for "loading" attribute because it might
-    // have changed above.
-    if (this.plugin.getPluginState().loading) {
-      return (
-        <CloseWrapper onUnmount={this.clearSelection}>
-          <LoaderView i18n={this.plugin.uppy.i18n} />
-        </CloseWrapper>
-      )
-    }
-
-    if (isInputMode) {
-      return (
-        <CloseWrapper onUnmount={this.clearSelection}>
-          <SearchInput
-            search={this.search}
-            i18n={this.plugin.uppy.i18n}
-          />
-        </CloseWrapper>
-      )
-    }
-
     const targetViewOptions = { ...this.opts, ...viewOptions }
+    const { files, folders, filterInput, loading, currentSelection } = this.plugin.getPluginState()
+    const { isChecked, toggleCheckbox, filterItems } = this.sharedHandler
+    const hasInput = filterInput !== ''
+
     const browserProps = {
-      ...this.plugin.getPluginState(),
-      isChecked: this.#sharedHandler.isChecked,
-      toggleCheckbox: this.#sharedHandler.toggleCheckbox,
+      isChecked,
+      toggleCheckbox,
+      currentSelection,
+      files: hasInput ? filterItems(files) : files,
+      folders: hasInput ? filterItems(folders) : folders,
       handleScroll: this.handleScroll,
       done: this.donePicking,
       cancel: this.cancelPicking,
@@ -247,6 +149,25 @@ module.exports = class ProviderView {
       validateRestrictions: (...args) => this.plugin.uppy.validateRestrictions(...args),
     }
 
+    if (loading) {
+      return (
+        <CloseWrapper onUnmount={this.clearSelection}>
+          <LoaderView i18n={this.plugin.uppy.i18n} />
+        </CloseWrapper>
+      )
+    }
+
+    if (isInputMode) {
+      return (
+        <CloseWrapper onUnmount={this.clearSelection}>
+          <SearchInput
+            search={this.search}
+            i18n={this.plugin.uppy.i18n}
+          />
+        </CloseWrapper>
+      )
+    }
+
     return (
       <CloseWrapper onUnmount={this.clearSelection}>
         <Browser {...browserProps} />

+ 119 - 0
packages/@uppy/provider-views/src/View.js

@@ -0,0 +1,119 @@
+const getFileType = require('@uppy/utils/lib/getFileType')
+const isPreviewSupported = require('@uppy/utils/lib/isPreviewSupported')
+const generateFileID = require('@uppy/utils/lib/generateFileID')
+
+// TODO: now that we have a shared `View` class,
+// `SharedHandler` could be cleaned up and moved into here
+const SharedHandler = require('./SharedHandler')
+
+module.exports = class View {
+  constructor (plugin, opts) {
+    this.plugin = plugin
+    this.provider = opts.provider
+    this.sharedHandler = new SharedHandler(plugin)
+
+    this.isHandlingScroll = false
+
+    this.preFirstRender = this.preFirstRender.bind(this)
+    this.handleError = this.handleError.bind(this)
+    this.addFile = this.addFile.bind(this)
+    this.clearSelection = this.clearSelection.bind(this)
+    this.cancelPicking = this.cancelPicking.bind(this)
+  }
+
+  // eslint-disable-next-line class-methods-use-this
+  providerFileToId (file) {
+    return generateFileID({
+      data: file,
+      name: file.name || file.id,
+      type: file.mimetype,
+    })
+  }
+
+  preFirstRender () {
+    this.plugin.setPluginState({ didFirstRender: true })
+    this.plugin.onFirstRender()
+  }
+
+  // eslint-disable-next-line class-methods-use-this
+  shouldHandleScroll (event) {
+    const { scrollHeight, scrollTop, offsetHeight } = event.target
+    const scrollPosition = scrollHeight - (scrollTop + offsetHeight)
+
+    return scrollPosition < 50 && !this.isHandlingScroll
+  }
+
+  clearSelection () {
+    this.plugin.setPluginState({ currentSelection: [] })
+  }
+
+  cancelPicking () {
+    this.clearSelection()
+
+    const dashboard = this.plugin.uppy.getPlugin('Dashboard')
+
+    if (dashboard) {
+      dashboard.hideAllPanels()
+    }
+  }
+
+  handleError (error) {
+    const { uppy } = this.plugin
+    const message = uppy.i18n('companionError')
+
+    uppy.log(error.toString())
+
+    if (error.isAuthError) {
+      return
+    }
+
+    uppy.info({ message, details: error.toString() }, 'error', 5000)
+  }
+
+  addFile (file) {
+    const tagFile = {
+      id: this.providerFileToId(file),
+      source: this.plugin.id,
+      data: file,
+      name: file.name || file.id,
+      type: file.mimeType,
+      isRemote: true,
+      meta: {},
+      body: {
+        fileId: file.id,
+      },
+      remote: {
+        companionUrl: this.plugin.opts.companionUrl,
+        url: `${this.provider.fileUrl(file.requestPath)}`,
+        body: {
+          fileId: file.id,
+        },
+        providerOptions: this.provider.opts,
+        providerName: this.provider.name,
+      },
+    }
+
+    const fileType = getFileType(tagFile)
+
+    // TODO Should we just always use the thumbnail URL if it exists?
+    if (fileType && isPreviewSupported(fileType)) {
+      tagFile.preview = file.thumbnail
+    }
+
+    if (file.author) {
+      tagFile.meta.author = file.author
+    }
+
+    this.plugin.uppy.log('Adding remote file')
+
+    try {
+      this.plugin.uppy.addFile(tagFile)
+      return true
+    } catch (err) {
+      if (!err.isRestriction) {
+        this.plugin.uppy.log(err)
+      }
+      return false
+    }
+  }
+}

+ 40 - 5
packages/@uppy/provider-views/src/style/uppy-ProviderBrowser-viewType--grid.scss

@@ -1,7 +1,8 @@
 // ***
-// View type: grid
+// View type: grid and unsplash
 // ***
-.uppy-ProviderBrowser-viewType--grid {
+.uppy-ProviderBrowser-viewType--grid,
+.uppy-ProviderBrowser-viewType--unsplash {
   ul.uppy-ProviderBrowser-list {
     display: flex;
     flex-direction: row;
@@ -80,9 +81,36 @@
     text-align: center;
     border-radius: 4px;
 
-    &:focus {
-      outline: none;
-      box-shadow: 0 0 0 3px rgba($blue, 0.9);
+    .uppy.uppy-ProviderBrowserItem-inner-relative {
+      position: relative;
+    }
+
+    .uppy-ProviderBrowserItem-author {
+      position: absolute;
+      display: none;
+      bottom: 0;
+      left: 0;
+      width: 100%;
+      background: rgba(black, 0.3);
+      color: white;
+      font-weight: 500;
+      font-size: 12px;
+      margin: 0;
+      padding: 5px;
+      text-decoration: none;
+
+      &:hover {
+        background: rgba(black, 0.4);
+        text-decoration: underline;
+      }
+    }
+
+    // Always show the author on touch devices
+    // https://www.w3.org/TR/mediaqueries-4/#hover
+    @media (hover: none) {
+      .uppy-ProviderBrowserItem-author {
+        display: block;
+      }
     }
 
     [data-uppy-theme="dark"] & {
@@ -122,6 +150,13 @@
     opacity: 1;
   }
 
+  .uppy-ProviderBrowserItem-checkbox--grid:hover,
+  .uppy-ProviderBrowserItem-checkbox--grid:focus {
+    + label .uppy-ProviderBrowserItem-author {
+      display: block;
+    }
+  }
+
   .uppy-ProviderBrowserItem-checkbox--grid:focus + label {
     @include clear-focus();
 

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

@@ -43,6 +43,7 @@ module.exports = class Unsplash extends UIPlugin {
   install () {
     this.view = new SearchProviderViews(this, {
       provider: this.provider,
+      viewType: 'unsplash',
     })
 
     const { target } = this.opts

+ 117 - 0
website/src/docs/unsplash.md

@@ -0,0 +1,117 @@
+---
+type: docs
+order: 14
+title: "Unsplash"
+menu_prefix: "<span title='Requires Companion'>ⓒ </span>"
+module: "@uppy/unsplash"
+permalink: docs/unsplash/
+category: "Sources"
+tagline: "import images from Unsplash"
+---
+
+The `@uppy/unsplash` plugin lets users search and select photos from Unsplash.
+
+A Companion instance is required for the Unsplash plugin to work. Companion handles authentication with Unsplash, downloads the files, and uploads them to the destination. This saves the user bandwidth, especially helpful if they are on a mobile connection.
+
+```js
+import Uppy from '@uppy/core'
+import Unsplash from '@uppy/unsplash'
+
+const uppy = new Uppy()
+
+uppy.use(Unsplash, {
+  // Options
+})
+```
+
+<a class="TryButton" href="/examples/dashboard/">Try it live</a>
+
+## Installation
+
+This plugin is published as the `@uppy/unsplash` package.
+
+Install from NPM:
+
+```shell
+npm install @uppy/unsplash
+```
+
+In the [CDN package](/docs/#With-a-script-tag), it is available on the `Uppy` global object:
+
+```js
+const { Unsplash } = Uppy
+```
+
+## Setting Up
+
+To use the Unsplash provider, you need to configure the Unsplash keys that Companion should use. With the standalone Companion server, specify environment variables:
+```shell
+export COMPANION_UNSPLASH_KEY="Unsplash API key"
+export COMPANION_UNSPLASH_SECRET="Unsplash API secret"
+```
+
+When using the Companion Node.js API, configure these options:
+
+```js
+companion.app({
+  providerOptions: {
+    unsplash: {
+      key: 'Unsplash API key',
+      secret: 'Unsplash API secret',
+    },
+  },
+})
+```
+
+You can create a Unsplash App on the [Unsplash Developers site](https://unsplash.com/developers).
+
+You'll be redirected to the app page. This page lists the app key and app secret, which you should use to configure Companion as shown above.
+
+## CSS
+
+Dashboard plugin is recommended as a container to all Provider plugins, including Unsplash. If you are using Dashboard, it [comes with all the nessesary styles](/docs/dashboard/#CSS) for Unsplash as well.
+
+⚠️ If you are feeling adventurous, and want to use Unsplash plugin separately, without Dashboard, make sure to include `@uppy/provider-views/dist/style.css` (or `style.min.css`) CSS file. This is experimental, not officially supported and not recommended.
+
+## Options
+
+The `@uppy/dropbox` plugin has the following configurable options:
+
+```js
+uppy.use(Unsplash, {
+  target: Dashboard,
+  companionUrl: 'https://companion.uppy.io/',
+})
+```
+
+### `id: 'Unsplash'`
+
+A unique identifier for this plugin. It defaults to `'Unsplash'`.
+
+### `title: 'Unsplash'`
+
+Title / name shown in the UI, such as Dashboard tabs. It defaults to `'Unsplash'`.
+
+### `target: null`
+
+DOM element, CSS selector, or plugin to mount the Unsplash provider into. This should normally be the [`@uppy/dashboard`](/docs/dashboard) plugin.
+
+### `companionUrl: null`
+
+URL to a [Companion](/docs/companion) instance.
+
+### `companionHeaders: {}`
+
+Custom headers that should be sent along to [Companion](/docs/companion) on every request.
+
+### `companionAllowedHosts: companionUrl`
+
+The valid and authorised URL(s) from which OAuth responses should be accepted.
+
+This value can be a `String`, a `Regex` pattern, or an `Array` of both.
+
+This is useful when you have your [Companion](/docs/companion) running on multiple hosts. Otherwise, the default value should do just fine.
+
+### `companionCookiesRule: 'same-origin'`
+
+This option correlates to the [RequestCredentials value](https://developer.mozilla.org/en-US/docs/Web/API/Request/credentials), which tells the plugin whether or not to send cookies to [Companion](/docs/companion).

+ 1 - 1
website/src/docs/url.md

@@ -1,6 +1,6 @@
 ---
 type: docs
-order: 14
+order: 15
 title: "Import From URL"
 menu_prefix: "<span title='Requires Companion'>ⓒ </span>"
 module: "@uppy/url"