Browse Source

Merge stable branch

Antoine du Hamel 10 months ago
parent
commit
a42b150639
52 changed files with 2106 additions and 117 deletions
  1. 1 0
      .eslintrc.js
  2. 2 2
      .github/workflows/companion-deploy.yml
  3. 2 2
      .github/workflows/release.yml
  4. 23 0
      CHANGELOG.md
  5. 43 42
      README.md
  6. 14 11
      docs/companion.md
  7. 9 8
      docs/guides/migration-guides.md
  8. 8 8
      docs/sources/companion-plugins/google-drive.mdx
  9. 185 0
      docs/sources/companion-plugins/google-photos.mdx
  10. 1 1
      docs/sources/companion-plugins/instagram.mdx
  11. 2 0
      docs/user-interfaces/dashboard.mdx
  12. 1 1
      e2e/cypress/integration/dashboard-transloadit.spec.ts
  13. 1 0
      e2e/package.json
  14. 1 0
      examples/angular-example/package.json
  15. 19 22
      examples/aws-nodejs/index.js
  16. 1 0
      packages/@uppy/box/src/Box.tsx
  17. 8 0
      packages/@uppy/companion/CHANGELOG.md
  18. 1 1
      packages/@uppy/companion/src/server/provider/drive/index.js
  19. 190 0
      packages/@uppy/companion/src/server/provider/google/drive/adapter.js
  20. 199 0
      packages/@uppy/companion/src/server/provider/google/drive/index.js
  21. 172 0
      packages/@uppy/companion/src/server/provider/google/googlephotos/index.js
  22. 36 0
      packages/@uppy/companion/src/server/provider/google/index.js
  23. 1 0
      packages/@uppy/core/src/locale.ts
  24. 3 4
      packages/@uppy/dashboard/src/utils/copyToClipboard.ts
  25. 1 0
      packages/@uppy/dropbox/src/Dropbox.tsx
  26. 1 0
      packages/@uppy/google-drive/src/GoogleDrive.tsx
  27. 1 0
      packages/@uppy/google-photos/.npmignore
  28. 8 0
      packages/@uppy/google-photos/CHANGELOG.md
  29. 21 0
      packages/@uppy/google-photos/LICENSE
  30. 51 0
      packages/@uppy/google-photos/README.md
  31. 32 0
      packages/@uppy/google-photos/package.json
  32. 137 0
      packages/@uppy/google-photos/src/GooglePhotos.tsx
  33. 1 0
      packages/@uppy/google-photos/src/index.ts
  34. 5 0
      packages/@uppy/google-photos/src/locale.ts
  35. 35 0
      packages/@uppy/google-photos/tsconfig.build.json
  36. 31 0
      packages/@uppy/google-photos/tsconfig.json
  37. 1 0
      packages/@uppy/onedrive/src/OneDrive.tsx
  38. 5 4
      packages/@uppy/provider-views/src/Browser.tsx
  39. 3 1
      packages/@uppy/provider-views/src/Item/components/ListItem.tsx
  40. 3 0
      packages/@uppy/provider-views/src/ProviderView/ProviderView.tsx
  41. 1 6
      packages/@uppy/provider-views/src/View.ts
  42. 1 0
      packages/@uppy/remote-sources/package.json
  43. 1 1
      packages/@uppy/remote-sources/src/index.test.ts
  44. 2 0
      packages/@uppy/remote-sources/src/index.ts
  45. 5 0
      packages/@uppy/remote-sources/tsconfig.build.json
  46. 5 0
      packages/@uppy/remote-sources/tsconfig.json
  47. 14 0
      packages/@uppy/transloadit/CHANGELOG.md
  48. 1 0
      packages/@uppy/transloadit/src/index.ts
  49. 1 0
      packages/uppy/index.mjs
  50. 1 0
      packages/uppy/package.json
  51. 1 1
      private/dev/Dashboard.js
  52. 814 2
      yarn.lock

+ 1 - 0
.eslintrc.js

@@ -225,6 +225,7 @@ module.exports = {
         'packages/@uppy/form/src/**/*.js',
         'packages/@uppy/golden-retriever/src/**/*.js',
         'packages/@uppy/google-drive/src/**/*.js',
+        'packages/@uppy/google-photos/src/**/*.js',
         'packages/@uppy/image-editor/src/**/*.js',
         'packages/@uppy/informer/src/**/*.js',
         'packages/@uppy/instagram/src/**/*.js',

+ 2 - 2
.github/workflows/companion-deploy.yml

@@ -58,12 +58,12 @@ jobs:
       - uses: docker/setup-qemu-action@68827325e0b33c7199eb31dd4e31fbe9023e06e3 # v3.0.0
       - uses: docker/setup-buildx-action@v3
       - name: Log in to DockerHub
-        uses: docker/login-action@e92390c5fb421da1463c202d546fed0ec5c39f20 # v3.1.0
+        uses: docker/login-action@0d4c9c5ea7693da7b068278f7b52bda2a190a446 # v3.2.0
         with:
           username: ${{secrets.DOCKER_USERNAME}}
           password: ${{secrets.DOCKER_PASSWORD}}
       - name: Build and push
-        uses: docker/build-push-action@2cdde995de11925a030ce8070c3d77a52ffcf1c0 # v5.3.0
+        uses: docker/build-push-action@ca052bb54ab0790a636c9b5f226502c73d547a25 # v5.4.0
         with:
           push: true
           context: .

+ 2 - 2
.github/workflows/release.yml

@@ -156,12 +156,12 @@ jobs:
       - uses: docker/setup-qemu-action@68827325e0b33c7199eb31dd4e31fbe9023e06e3 # v3.0.0
       - uses: docker/setup-buildx-action@v3
       - name: Log in to DockerHub
-        uses: docker/login-action@e92390c5fb421da1463c202d546fed0ec5c39f20 # v3.1.0
+        uses: docker/login-action@0d4c9c5ea7693da7b068278f7b52bda2a190a446 # v3.2.0
         with:
           username: ${{secrets.DOCKER_USERNAME}}
           password: ${{secrets.DOCKER_PASSWORD}}
       - name: Build and push
-        uses: docker/build-push-action@2cdde995de11925a030ce8070c3d77a52ffcf1c0 # v5.3.0
+        uses: docker/build-push-action@ca052bb54ab0790a636c9b5f226502c73d547a25 # v5.4.0
         with:
           push: true
           context: .

+ 23 - 0
CHANGELOG.md

@@ -437,6 +437,29 @@ Released: 2024-03-28
 - @uppy/vue: [v4.x] remove manual types (Antoine du Hamel / #4803)
 - meta: prepare release workflow for beta versions (Antoine du Hamel)
 
+## 3.27.0
+
+Released: 2024-06-18
+
+| Package              | Version | Package              | Version |
+| -------------------- | ------- | -------------------- | ------- |
+| @uppy/box            |   2.4.0 | @uppy/onedrive       |   3.4.0 |
+| @uppy/companion      |  4.14.0 | @uppy/provider-views |  3.13.0 |
+| @uppy/core           |  3.13.0 | @uppy/react          |   3.4.0 |
+| @uppy/dashboard      |   3.9.0 | @uppy/remote-sources |   1.3.0 |
+| @uppy/dropbox        |   3.4.0 | @uppy/transloadit    |   3.8.0 |
+| @uppy/google-drive   |   3.6.0 | uppy                 |  3.27.0 |
+| @uppy/google-photos  |   0.1.0 |                      |         |
+
+- @uppy/google-photos: add plugin (Mikael Finstad / #5061)
+- examples: updating aws-nodejs example listParts logic for resuming uploads (Mitchell Rhoads / #5192)
+- meta: Bump docker/login-action from 3.1.0 to 3.2.0 (dependabot\[bot] / #5217)
+- meta: Bump docker/build-push-action from 5.3.0 to 5.4.0 (dependabot\[bot] / #5252)
+- @uppy/transloadit: also fix outdated assembly transloadit:result (Merlijn Vos / #5246)
+- docs: fix typo in the url (Evgenia Karunus)
+- @uppy/companion: Bump ws from 8.8.1 to 8.17.1 (dependabot\[bot] / #5256)
+
+
 ## 3.26.1
 
 Released: 2024-06-11

+ 43 - 42
README.md

@@ -312,53 +312,54 @@ Use Uppy in your project?
 <tr><td><a href=https://github.com/craig-jennings><img width="117" alt="craig-jennings" src="https://avatars.githubusercontent.com/u/1683368?v=4&s=117"></a></td><td><a href=https://github.com/davekiss><img width="117" alt="davekiss" src="https://avatars.githubusercontent.com/u/1256071?v=4&s=117"></a></td><td><a href=https://github.com/denysdesign><img width="117" alt="denysdesign" src="https://avatars.githubusercontent.com/u/1041797?v=4&s=117"></a></td><td><a href=https://github.com/ethanwillis><img width="117" alt="ethanwillis" src="https://avatars.githubusercontent.com/u/182492?v=4&s=117"></a></td><td><a href=https://github.com/frobinsonj><img width="117" alt="frobinsonj" src="https://avatars.githubusercontent.com/u/16726902?v=4&s=117"></a></td><td><a href=https://github.com/richmeij><img width="117" alt="richmeij" src="https://avatars.githubusercontent.com/u/9741858?v=4&s=117"></a></td></tr>
 <tr><td><a href=https://github.com/richartkeil><img width="117" alt="richartkeil" src="https://avatars.githubusercontent.com/u/8680858?v=4&s=117"></a></td><td><a href=https://github.com/paescuj><img width="117" alt="paescuj" src="https://avatars.githubusercontent.com/u/5363448?v=4&s=117"></a></td><td><a href=https://github.com/msand><img width="117" alt="msand" src="https://avatars.githubusercontent.com/u/1131362?v=4&s=117"></a></td><td><a href=https://github.com/martiuslim><img width="117" alt="martiuslim" src="https://avatars.githubusercontent.com/u/17944339?v=4&s=117"></a></td><td><a href=https://github.com/Martin005><img width="117" alt="Martin005" src="https://avatars.githubusercontent.com/u/10096404?v=4&s=117"></a></td><td><a href=https://github.com/mskelton><img width="117" alt="mskelton" src="https://avatars.githubusercontent.com/u/25914066?v=4&s=117"></a></td></tr>
 <tr><td><a href=https://github.com/mactavishz><img width="117" alt="mactavishz" src="https://avatars.githubusercontent.com/u/12948083?v=4&s=117"></a></td><td><a href=https://github.com/lafe><img width="117" alt="lafe" src="https://avatars.githubusercontent.com/u/4070008?v=4&s=117"></a></td><td><a href=https://github.com/dogrocker><img width="117" alt="dogrocker" src="https://avatars.githubusercontent.com/u/8379027?v=4&s=117"></a></td><td><a href=https://github.com/jedwood><img width="117" alt="jedwood" src="https://avatars.githubusercontent.com/u/369060?v=4&s=117"></a></td><td><a href=https://github.com/geertclerx><img width="117" alt="geertclerx" src="https://avatars.githubusercontent.com/u/1381327?v=4&s=117"></a></td><td><a href=https://github.com/jasonbosco><img width="117" alt="jasonbosco" src="https://avatars.githubusercontent.com/u/458383?v=4&s=117"></a></td></tr>
-<tr><td><a href=https://github.com/ghasrfakhri><img width="117" alt="ghasrfakhri" src="https://avatars.githubusercontent.com/u/4945963?v=4&s=117"></a></td><td><a href=https://github.com/rossng><img width="117" alt="rossng" src="https://avatars.githubusercontent.com/u/565371?v=4&s=117"></a></td><td><a href=https://github.com/scherroman><img width="117" alt="scherroman" src="https://avatars.githubusercontent.com/u/7923938?v=4&s=117"></a></td><td><a href=https://github.com/robwilson1><img width="117" alt="robwilson1" src="https://avatars.githubusercontent.com/u/7114944?v=4&s=117"></a></td><td><a href=https://github.com/SxDx><img width="117" alt="SxDx" src="https://avatars.githubusercontent.com/u/2004247?v=4&s=117"></a></td><td><a href=https://github.com/refo><img width="117" alt="refo" src="https://avatars.githubusercontent.com/u/1114116?v=4&s=117"></a></td></tr>
-<tr><td><a href=https://github.com/raulibanez><img width="117" alt="raulibanez" src="https://avatars.githubusercontent.com/u/1070825?v=4&s=117"></a></td><td><a href=https://github.com/luarmr><img width="117" alt="luarmr" src="https://avatars.githubusercontent.com/u/817416?v=4&s=117"></a></td><td><a href=https://github.com/eman8519><img width="117" alt="eman8519" src="https://avatars.githubusercontent.com/u/2380804?v=4&s=117"></a></td><td><a href=https://github.com/Pzoco><img width="117" alt="Pzoco" src="https://avatars.githubusercontent.com/u/3101348?v=4&s=117"></a></td><td><a href=https://github.com/ppadmavilasom><img width="117" alt="ppadmavilasom" src="https://avatars.githubusercontent.com/u/11167452?v=4&s=117"></a></td><td><a href=https://github.com/phillipalexander><img width="117" alt="phillipalexander" src="https://avatars.githubusercontent.com/u/1577682?v=4&s=117"></a></td></tr>
-<tr><td><a href=https://github.com/pmusaraj><img width="117" alt="pmusaraj" src="https://avatars.githubusercontent.com/u/368961?v=4&s=117"></a></td><td><a href=https://github.com/pedrofs><img width="117" alt="pedrofs" src="https://avatars.githubusercontent.com/u/56484?v=4&s=117"></a></td><td><a href=https://github.com/plneto><img width="117" alt="plneto" src="https://avatars.githubusercontent.com/u/5697434?v=4&s=117"></a></td><td><a href=https://github.com/patricklindsay><img width="117" alt="patricklindsay" src="https://avatars.githubusercontent.com/u/7923681?v=4&s=117"></a></td><td><a href=https://github.com/pascalwengerter><img width="117" alt="pascalwengerter" src="https://avatars.githubusercontent.com/u/16822008?v=4&s=117"></a></td><td><a href=https://github.com/ParsaArvanehPA><img width="117" alt="ParsaArvanehPA" src="https://avatars.githubusercontent.com/u/62149413?v=4&s=117"></a></td></tr>
-<tr><td><a href=https://github.com/taj><img width="117" alt="taj" src="https://avatars.githubusercontent.com/u/16062635?v=4&s=117"></a></td><td><a href=https://github.com/strayer><img width="117" alt="strayer" src="https://avatars.githubusercontent.com/u/310624?v=4&s=117"></a></td><td><a href=https://github.com/sjauld><img width="117" alt="sjauld" src="https://avatars.githubusercontent.com/u/8232503?v=4&s=117"></a></td><td><a href=https://github.com/steverob><img width="117" alt="steverob" src="https://avatars.githubusercontent.com/u/1220480?v=4&s=117"></a></td><td><a href=https://github.com/amaitu><img width="117" alt="amaitu" src="https://avatars.githubusercontent.com/u/15688439?v=4&s=117"></a></td><td><a href=https://github.com/quigebo><img width="117" alt="quigebo" src="https://avatars.githubusercontent.com/u/741?v=4&s=117"></a></td></tr>
-<tr><td><a href=https://github.com/waptik><img width="117" alt="waptik" src="https://avatars.githubusercontent.com/u/1687551?v=4&s=117"></a></td><td><a href=https://github.com/SpazzMarticus><img width="117" alt="SpazzMarticus" src="https://avatars.githubusercontent.com/u/5716457?v=4&s=117"></a></td><td><a href=https://github.com/szh><img width="117" alt="szh" src="https://avatars.githubusercontent.com/u/546965?v=4&s=117"></a></td><td><a href=https://github.com/sergei-zelinsky><img width="117" alt="sergei-zelinsky" src="https://avatars.githubusercontent.com/u/19428086?v=4&s=117"></a></td><td><a href=https://github.com/sebasegovia01><img width="117" alt="sebasegovia01" src="https://avatars.githubusercontent.com/u/35777287?v=4&s=117"></a></td><td><a href=https://github.com/sdebacker><img width="117" alt="sdebacker" src="https://avatars.githubusercontent.com/u/134503?v=4&s=117"></a></td></tr>
-<tr><td><a href=https://github.com/samuelcolburn><img width="117" alt="samuelcolburn" src="https://avatars.githubusercontent.com/u/9741902?v=4&s=117"></a></td><td><a href=https://github.com/fortunto2><img width="117" alt="fortunto2" src="https://avatars.githubusercontent.com/u/1236751?v=4&s=117"></a></td><td><a href=https://github.com/GNURub><img width="117" alt="GNURub" src="https://avatars.githubusercontent.com/u/1318648?v=4&s=117"></a></td><td><a href=https://github.com/rart><img width="117" alt="rart" src="https://avatars.githubusercontent.com/u/3928341?v=4&s=117"></a></td><td><a href=https://github.com/rossng><img width="117" alt="rossng" src="https://avatars.githubusercontent.com/u/565371?v=4&s=117"></a></td><td><a href=https://github.com/mkopinsky><img width="117" alt="mkopinsky" src="https://avatars.githubusercontent.com/u/591435?v=4&s=117"></a></td></tr>
-<tr><td><a href=https://github.com/mhulet><img width="117" alt="mhulet" src="https://avatars.githubusercontent.com/u/293355?v=4&s=117"></a></td><td><a href=https://github.com/hrsh><img width="117" alt="hrsh" src="https://avatars.githubusercontent.com/u/1929359?v=4&s=117"></a></td><td><a href=https://github.com/mauricioribeiro><img width="117" alt="mauricioribeiro" src="https://avatars.githubusercontent.com/u/2589856?v=4&s=117"></a></td><td><a href=https://github.com/matthewhartstonge><img width="117" alt="matthewhartstonge" src="https://avatars.githubusercontent.com/u/6119549?v=4&s=117"></a></td><td><a href=https://github.com/mjesuele><img width="117" alt="mjesuele" src="https://avatars.githubusercontent.com/u/871117?v=4&s=117"></a></td><td><a href=https://github.com/mattfik><img width="117" alt="mattfik" src="https://avatars.githubusercontent.com/u/1638028?v=4&s=117"></a></td></tr>
-<tr><td><a href=https://github.com/mateuscruz><img width="117" alt="mateuscruz" src="https://avatars.githubusercontent.com/u/8962842?v=4&s=117"></a></td><td><a href=https://github.com/masumulu28><img width="117" alt="masumulu28" src="https://avatars.githubusercontent.com/u/49063256?v=4&s=117"></a></td><td><a href=https://github.com/masaok><img width="117" alt="masaok" src="https://avatars.githubusercontent.com/u/1320083?v=4&s=117"></a></td><td><a href=https://github.com/martin-brennan><img width="117" alt="martin-brennan" src="https://avatars.githubusercontent.com/u/920448?v=4&s=117"></a></td><td><a href=https://github.com/marcusforsberg><img width="117" alt="marcusforsberg" src="https://avatars.githubusercontent.com/u/1009069?v=4&s=117"></a></td><td><a href=https://github.com/marcosthejew><img width="117" alt="marcosthejew" src="https://avatars.githubusercontent.com/u/1500967?v=4&s=117"></a></td></tr>
-<tr><td><a href=https://github.com/mperrando><img width="117" alt="mperrando" src="https://avatars.githubusercontent.com/u/525572?v=4&s=117"></a></td><td><a href=https://github.com/onhate><img width="117" alt="onhate" src="https://avatars.githubusercontent.com/u/980905?v=4&s=117"></a></td><td><a href=https://github.com/marc-mabe><img width="117" alt="marc-mabe" src="https://avatars.githubusercontent.com/u/302689?v=4&s=117"></a></td><td><a href=https://github.com/Lucklj521><img width="117" alt="Lucklj521" src="https://avatars.githubusercontent.com/u/93632042?v=4&s=117"></a></td><td><a href=https://github.com/cryptic022><img width="117" alt="cryptic022" src="https://avatars.githubusercontent.com/u/18145703?v=4&s=117"></a></td><td><a href=https://github.com/Ozodbek1405><img width="117" alt="Ozodbek1405" src="https://avatars.githubusercontent.com/u/86141593?v=4&s=117"></a></td></tr>
-<tr><td><a href=https://github.com/leftdevel><img width="117" alt="leftdevel" src="https://avatars.githubusercontent.com/u/843337?v=4&s=117"></a></td><td><a href=https://github.com/nil1511><img width="117" alt="nil1511" src="https://avatars.githubusercontent.com/u/2058170?v=4&s=117"></a></td><td><a href=https://github.com/coreprocess><img width="117" alt="coreprocess" src="https://avatars.githubusercontent.com/u/1226918?v=4&s=117"></a></td><td><a href=https://github.com/nicojones><img width="117" alt="nicojones" src="https://avatars.githubusercontent.com/u/6078915?v=4&s=117"></a></td><td><a href=https://github.com/trungcva10a6tn><img width="117" alt="trungcva10a6tn" src="https://avatars.githubusercontent.com/u/18293783?v=4&s=117"></a></td><td><a href=https://github.com/naveed-ahmad><img width="117" alt="naveed-ahmad" src="https://avatars.githubusercontent.com/u/701567?v=4&s=117"></a></td></tr>
-<tr><td><a href=https://github.com/pleasespammelater><img width="117" alt="pleasespammelater" src="https://avatars.githubusercontent.com/u/11870394?v=4&s=117"></a></td><td><a href=https://github.com/marton-laszlo-attila><img width="117" alt="marton-laszlo-attila" src="https://avatars.githubusercontent.com/u/73295321?v=4&s=117"></a></td><td><a href=https://github.com/navruzm><img width="117" alt="navruzm" src="https://avatars.githubusercontent.com/u/168341?v=4&s=117"></a></td><td><a href=https://github.com/mogzol><img width="117" alt="mogzol" src="https://avatars.githubusercontent.com/u/11789801?v=4&s=117"></a></td><td><a href=https://github.com/shahimclt><img width="117" alt="shahimclt" src="https://avatars.githubusercontent.com/u/8318002?v=4&s=117"></a></td><td><a href=https://github.com/mnafees><img width="117" alt="mnafees" src="https://avatars.githubusercontent.com/u/1763885?v=4&s=117"></a></td></tr>
-<tr><td><a href=https://github.com/boudra><img width="117" alt="boudra" src="https://avatars.githubusercontent.com/u/711886?v=4&s=117"></a></td><td><a href=https://github.com/achmiral><img width="117" alt="achmiral" src="https://avatars.githubusercontent.com/u/10906059?v=4&s=117"></a></td><td><a href=https://github.com/ken-kuro><img width="117" alt="ken-kuro" src="https://avatars.githubusercontent.com/u/47441476?v=4&s=117"></a></td><td><a href=https://github.com/neuronet77><img width="117" alt="neuronet77" src="https://avatars.githubusercontent.com/u/4220037?v=4&s=117"></a></td><td><a href=https://github.com/mosi-kha><img width="117" alt="mosi-kha" src="https://avatars.githubusercontent.com/u/35611016?v=4&s=117"></a></td><td><a href=https://github.com/maddy-jo><img width="117" alt="maddy-jo" src="https://avatars.githubusercontent.com/u/3241493?v=4&s=117"></a></td></tr>
-<tr><td><a href=https://github.com/mdxiaohu><img width="117" alt="mdxiaohu" src="https://avatars.githubusercontent.com/u/42248614?v=4&s=117"></a></td><td><a href=https://github.com/magumbo><img width="117" alt="magumbo" src="https://avatars.githubusercontent.com/u/6683765?v=4&s=117"></a></td><td><a href=https://github.com/jx-zyf><img width="117" alt="jx-zyf" src="https://avatars.githubusercontent.com/u/26456842?v=4&s=117"></a></td><td><a href=https://github.com/kode-ninja><img width="117" alt="kode-ninja" src="https://avatars.githubusercontent.com/u/7857611?v=4&s=117"></a></td><td><a href=https://github.com/sontixyou><img width="117" alt="sontixyou" src="https://avatars.githubusercontent.com/u/19817196?v=4&s=117"></a></td><td><a href=https://github.com/jur-ng><img width="117" alt="jur-ng" src="https://avatars.githubusercontent.com/u/111122756?v=4&s=117"></a></td></tr>
-<tr><td><a href=https://github.com/johnmanjiro13><img width="117" alt="johnmanjiro13" src="https://avatars.githubusercontent.com/u/28798279?v=4&s=117"></a></td><td><a href=https://github.com/jyoungblood><img width="117" alt="jyoungblood" src="https://avatars.githubusercontent.com/u/56104?v=4&s=117"></a></td><td><a href=https://github.com/green-mike><img width="117" alt="green-mike" src="https://avatars.githubusercontent.com/u/5584225?v=4&s=117"></a></td><td><a href=https://github.com/gaelicwinter><img width="117" alt="gaelicwinter" src="https://avatars.githubusercontent.com/u/6510266?v=4&s=117"></a></td><td><a href=https://github.com/franckl><img width="117" alt="franckl" src="https://avatars.githubusercontent.com/u/3875803?v=4&s=117"></a></td><td><a href=https://github.com/fingul><img width="117" alt="fingul" src="https://avatars.githubusercontent.com/u/894739?v=4&s=117"></a></td></tr>
-<tr><td><a href=https://github.com/elliotsayes><img width="117" alt="elliotsayes" src="https://avatars.githubusercontent.com/u/7699058?v=4&s=117"></a></td><td><a href=https://github.com/dzcpy><img width="117" alt="dzcpy" src="https://avatars.githubusercontent.com/u/203980?v=4&s=117"></a></td><td><a href=https://github.com/xhocquet><img width="117" alt="xhocquet" src="https://avatars.githubusercontent.com/u/8116516?v=4&s=117"></a></td><td><a href=https://github.com/JimmyLv><img width="117" alt="JimmyLv" src="https://avatars.githubusercontent.com/u/4997466?v=4&s=117"></a></td><td><a href=https://github.com/zanzlender><img width="117" alt="zanzlender" src="https://avatars.githubusercontent.com/u/44570474?v=4&s=117"></a></td><td><a href=https://github.com/olitomas><img width="117" alt="olitomas" src="https://avatars.githubusercontent.com/u/6918659?v=4&s=117"></a></td></tr>
-<tr><td><a href=https://github.com/yoann-hellopret><img width="117" alt="yoann-hellopret" src="https://avatars.githubusercontent.com/u/46525558?v=4&s=117"></a></td><td><a href=https://github.com/vedran555><img width="117" alt="vedran555" src="https://avatars.githubusercontent.com/u/38395951?v=4&s=117"></a></td><td><a href=https://github.com/tusharjkhunt><img width="117" alt="tusharjkhunt" src="https://avatars.githubusercontent.com/u/31904234?v=4&s=117"></a></td><td><a href=https://github.com/thanhthot><img width="117" alt="thanhthot" src="https://avatars.githubusercontent.com/u/50633205?v=4&s=117"></a></td><td><a href=https://github.com/stduhpf><img width="117" alt="stduhpf" src="https://avatars.githubusercontent.com/u/28208228?v=4&s=117"></a></td><td><a href=https://github.com/slawexxx44><img width="117" alt="slawexxx44" src="https://avatars.githubusercontent.com/u/11180644?v=4&s=117"></a></td></tr>
-<tr><td><a href=https://github.com/rtaieb><img width="117" alt="rtaieb" src="https://avatars.githubusercontent.com/u/35224301?v=4&s=117"></a></td><td><a href=https://github.com/rmoura-92><img width="117" alt="rmoura-92" src="https://avatars.githubusercontent.com/u/419044?v=4&s=117"></a></td><td><a href=https://github.com/rlebosse><img width="117" alt="rlebosse" src="https://avatars.githubusercontent.com/u/2794137?v=4&s=117"></a></td><td><a href=https://github.com/rhymes><img width="117" alt="rhymes" src="https://avatars.githubusercontent.com/u/146201?v=4&s=117"></a></td><td><a href=https://github.com/luntta><img width="117" alt="luntta" src="https://avatars.githubusercontent.com/u/14221637?v=4&s=117"></a></td><td><a href=https://github.com/phil714><img width="117" alt="phil714" src="https://avatars.githubusercontent.com/u/7584581?v=4&s=117"></a></td></tr>
-<tr><td><a href=https://github.com/ordago><img width="117" alt="ordago" src="https://avatars.githubusercontent.com/u/6376814?v=4&s=117"></a></td><td><a href=https://github.com/odselsevier><img width="117" alt="odselsevier" src="https://avatars.githubusercontent.com/u/95745934?v=4&s=117"></a></td><td><a href=https://github.com/ninesalt><img width="117" alt="ninesalt" src="https://avatars.githubusercontent.com/u/7952255?v=4&s=117"></a></td><td><a href=https://github.com/willycamargo><img width="117" alt="willycamargo" src="https://avatars.githubusercontent.com/u/5041887?v=4&s=117"></a></td><td><a href=https://github.com/weston-sankey-mark43><img width="117" alt="weston-sankey-mark43" src="https://avatars.githubusercontent.com/u/97678695?v=4&s=117"></a></td><td><a href=https://github.com/dwnste><img width="117" alt="dwnste" src="https://avatars.githubusercontent.com/u/17119722?v=4&s=117"></a></td></tr>
-<tr><td><a href=https://github.com/nagyv><img width="117" alt="nagyv" src="https://avatars.githubusercontent.com/u/126671?v=4&s=117"></a></td><td><a href=https://github.com/stiig><img width="117" alt="stiig" src="https://avatars.githubusercontent.com/u/8639922?v=4&s=117"></a></td><td><a href=https://github.com/valentinoli><img width="117" alt="valentinoli" src="https://avatars.githubusercontent.com/u/23453691?v=4&s=117"></a></td><td><a href=https://github.com/vially><img width="117" alt="vially" src="https://avatars.githubusercontent.com/u/433598?v=4&s=117"></a></td><td><a href=https://github.com/bodryi><img width="117" alt="bodryi" src="https://avatars.githubusercontent.com/u/7326310?v=4&s=117"></a></td><td><a href=https://github.com/trivikr><img width="117" alt="trivikr" src="https://avatars.githubusercontent.com/u/16024985?v=4&s=117"></a></td></tr>
-<tr><td><a href=https://github.com/top-master><img width="117" alt="top-master" src="https://avatars.githubusercontent.com/u/31405473?v=4&s=117"></a></td><td><a href=https://github.com/tvaliasek><img width="117" alt="tvaliasek" src="https://avatars.githubusercontent.com/u/8644946?v=4&s=117"></a></td><td><a href=https://github.com/tomekp><img width="117" alt="tomekp" src="https://avatars.githubusercontent.com/u/1856393?v=4&s=117"></a></td><td><a href=https://github.com/tomsaleeba><img width="117" alt="tomsaleeba" src="https://avatars.githubusercontent.com/u/1773838?v=4&s=117"></a></td><td><a href=https://github.com/WIStudent><img width="117" alt="WIStudent" src="https://avatars.githubusercontent.com/u/2707930?v=4&s=117"></a></td><td><a href=https://github.com/tmaier><img width="117" alt="tmaier" src="https://avatars.githubusercontent.com/u/350038?v=4&s=117"></a></td></tr>
-<tr><td><a href=https://github.com/twarlop><img width="117" alt="twarlop" src="https://avatars.githubusercontent.com/u/2856082?v=4&s=117"></a></td><td><a href=https://github.com/tcgj><img width="117" alt="tcgj" src="https://avatars.githubusercontent.com/u/7994529?v=4&s=117"></a></td><td><a href=https://github.com/dkisic><img width="117" alt="dkisic" src="https://avatars.githubusercontent.com/u/32257921?v=4&s=117"></a></td><td><a href=https://github.com/craigcbrunner><img width="117" alt="craigcbrunner" src="https://avatars.githubusercontent.com/u/2780521?v=4&s=117"></a></td><td><a href=https://github.com/codehero7386><img width="117" alt="codehero7386" src="https://avatars.githubusercontent.com/u/56253286?v=4&s=117"></a></td><td><a href=https://github.com/christianwengert><img width="117" alt="christianwengert" src="https://avatars.githubusercontent.com/u/12936057?v=4&s=117"></a></td></tr>
-<tr><td><a href=https://github.com/cgoinglove><img width="117" alt="cgoinglove" src="https://avatars.githubusercontent.com/u/86150470?v=4&s=117"></a></td><td><a href=https://github.com/canvasbh><img width="117" alt="canvasbh" src="https://avatars.githubusercontent.com/u/44477734?v=4&s=117"></a></td><td><a href=https://github.com/c0b41><img width="117" alt="c0b41" src="https://avatars.githubusercontent.com/u/2834954?v=4&s=117"></a></td><td><a href=https://github.com/avalla><img width="117" alt="avalla" src="https://avatars.githubusercontent.com/u/986614?v=4&s=117"></a></td><td><a href=https://github.com/arggh><img width="117" alt="arggh" src="https://avatars.githubusercontent.com/u/17210302?v=4&s=117"></a></td><td><a href=https://github.com/alfatv><img width="117" alt="alfatv" src="https://avatars.githubusercontent.com/u/62238673?v=4&s=117"></a></td></tr>
-<tr><td><a href=https://github.com/agreene-coursera><img width="117" alt="agreene-coursera" src="https://avatars.githubusercontent.com/u/30501355?v=4&s=117"></a></td><td><a href=https://github.com/aduh95-test-account><img width="117" alt="aduh95-test-account" src="https://avatars.githubusercontent.com/u/93441190?v=4&s=117"></a></td><td><a href=https://github.com/sartoshi-foot-dao><img width="117" alt="sartoshi-foot-dao" src="https://avatars.githubusercontent.com/u/99770068?v=4&s=117"></a></td><td><a href=https://github.com/zackbloom><img width="117" alt="zackbloom" src="https://avatars.githubusercontent.com/u/55347?v=4&s=117"></a></td><td><a href=https://github.com/zlawson-ut><img width="117" alt="zlawson-ut" src="https://avatars.githubusercontent.com/u/7375444?v=4&s=117"></a></td><td><a href=https://github.com/zachconner><img width="117" alt="zachconner" src="https://avatars.githubusercontent.com/u/11339326?v=4&s=117"></a></td></tr>
-<tr><td><a href=https://github.com/yafkari><img width="117" alt="yafkari" src="https://avatars.githubusercontent.com/u/41365655?v=4&s=117"></a></td><td><a href=https://github.com/YehudaKremer><img width="117" alt="YehudaKremer" src="https://avatars.githubusercontent.com/u/946652?v=4&s=117"></a></td><td><a href=https://github.com/ardeois><img width="117" alt="ardeois" src="https://avatars.githubusercontent.com/u/1867939?v=4&s=117"></a></td><td><a href=https://github.com/CommanderRoot><img width="117" alt="CommanderRoot" src="https://avatars.githubusercontent.com/u/4395417?v=4&s=117"></a></td><td><a href=https://github.com/czj><img width="117" alt="czj" src="https://avatars.githubusercontent.com/u/14306?v=4&s=117"></a></td><td><a href=https://github.com/cbush06><img width="117" alt="cbush06" src="https://avatars.githubusercontent.com/u/15720146?v=4&s=117"></a></td></tr>
-<tr><td><a href=https://github.com/Aarbel><img width="117" alt="Aarbel" src="https://avatars.githubusercontent.com/u/25119847?v=4&s=117"></a></td><td><a href=https://github.com/cfra><img width="117" alt="cfra" src="https://avatars.githubusercontent.com/u/1347051?v=4&s=117"></a></td><td><a href=https://github.com/csprance><img width="117" alt="csprance" src="https://avatars.githubusercontent.com/u/7902617?v=4&s=117"></a></td><td><a href=https://github.com/prattcmp><img width="117" alt="prattcmp" src="https://avatars.githubusercontent.com/u/1497950?v=4&s=117"></a></td><td><a href=https://github.com/subvertallchris><img width="117" alt="subvertallchris" src="https://avatars.githubusercontent.com/u/4097271?v=4&s=117"></a></td><td><a href=https://github.com/charlybillaud><img width="117" alt="charlybillaud" src="https://avatars.githubusercontent.com/u/31970410?v=4&s=117"></a></td></tr>
-<tr><td><a href=https://github.com/Cretezy><img width="117" alt="Cretezy" src="https://avatars.githubusercontent.com/u/2672503?v=4&s=117"></a></td><td><a href=https://github.com/chao><img width="117" alt="chao" src="https://avatars.githubusercontent.com/u/55872?v=4&s=117"></a></td><td><a href=https://github.com/cellvinchung><img width="117" alt="cellvinchung" src="https://avatars.githubusercontent.com/u/5347394?v=4&s=117"></a></td><td><a href=https://github.com/cartfisk><img width="117" alt="cartfisk" src="https://avatars.githubusercontent.com/u/8764375?v=4&s=117"></a></td><td><a href=https://github.com/cyu><img width="117" alt="cyu" src="https://avatars.githubusercontent.com/u/2431?v=4&s=117"></a></td><td><a href=https://github.com/bryanjswift><img width="117" alt="bryanjswift" src="https://avatars.githubusercontent.com/u/9911?v=4&s=117"></a></td></tr>
-<tr><td><a href=https://github.com/bedgerotto><img width="117" alt="bedgerotto" src="https://avatars.githubusercontent.com/u/4459657?v=4&s=117"></a></td><td><a href=https://github.com/functino><img width="117" alt="functino" src="https://avatars.githubusercontent.com/u/415498?v=4&s=117"></a></td><td><a href=https://github.com/yoldar><img width="117" alt="yoldar" src="https://avatars.githubusercontent.com/u/1597578?v=4&s=117"></a></td><td><a href=https://github.com/efbautista><img width="117" alt="efbautista" src="https://avatars.githubusercontent.com/u/35430671?v=4&s=117"></a></td><td><a href=https://github.com/emuell><img width="117" alt="emuell" src="https://avatars.githubusercontent.com/u/11521600?v=4&s=117"></a></td><td><a href=https://github.com/EdgarSantiago93><img width="117" alt="EdgarSantiago93" src="https://avatars.githubusercontent.com/u/14806877?v=4&s=117"></a></td></tr>
-<tr><td><a href=https://github.com/sweetro><img width="117" alt="sweetro" src="https://avatars.githubusercontent.com/u/6228717?v=4&s=117"></a></td><td><a href=https://github.com/jeetiss><img width="117" alt="jeetiss" src="https://avatars.githubusercontent.com/u/6726016?v=4&s=117"></a></td><td><a href=https://github.com/DennisKofflard><img width="117" alt="DennisKofflard" src="https://avatars.githubusercontent.com/u/8669129?v=4&s=117"></a></td><td><a href=https://github.com/hoangsvit><img width="117" alt="hoangsvit" src="https://avatars.githubusercontent.com/u/11882322?v=4&s=117"></a></td><td><a href=https://github.com/davilima6><img width="117" alt="davilima6" src="https://avatars.githubusercontent.com/u/422130?v=4&s=117"></a></td><td><a href=https://github.com/akizor><img width="117" alt="akizor" src="https://avatars.githubusercontent.com/u/1052439?v=4&s=117"></a></td></tr>
-<tr><td><a href=https://github.com/KaminskiDaniell><img width="117" alt="KaminskiDaniell" src="https://avatars.githubusercontent.com/u/27357868?v=4&s=117"></a></td><td><a href=https://github.com/Cantabar><img width="117" alt="Cantabar" src="https://avatars.githubusercontent.com/u/6812207?v=4&s=117"></a></td><td><a href=https://github.com/mrboomer><img width="117" alt="mrboomer" src="https://avatars.githubusercontent.com/u/5942912?v=4&s=117"></a></td><td><a href=https://github.com/danilat><img width="117" alt="danilat" src="https://avatars.githubusercontent.com/u/22763?v=4&s=117"></a></td><td><a href=https://github.com/danschalow><img width="117" alt="danschalow" src="https://avatars.githubusercontent.com/u/3527437?v=4&s=117"></a></td><td><a href=https://github.com/danmichaelo><img width="117" alt="danmichaelo" src="https://avatars.githubusercontent.com/u/434495?v=4&s=117"></a></td></tr>
-<tr><td><a href=https://github.com/Cruaier><img width="117" alt="Cruaier" src="https://avatars.githubusercontent.com/u/5204940?v=4&s=117"></a></td><td><a href=https://github.com/sercraig><img width="117" alt="sercraig" src="https://avatars.githubusercontent.com/u/24261518?v=4&s=117"></a></td><td><a href=https://github.com/amitport><img width="117" alt="amitport" src="https://avatars.githubusercontent.com/u/1131991?v=4&s=117"></a></td><td><a href=https://github.com/tekacs><img width="117" alt="tekacs" src="https://avatars.githubusercontent.com/u/63247?v=4&s=117"></a></td><td><a href=https://github.com/Dogfalo><img width="117" alt="Dogfalo" src="https://avatars.githubusercontent.com/u/2775751?v=4&s=117"></a></td><td><a href=https://github.com/alirezahi><img width="117" alt="alirezahi" src="https://avatars.githubusercontent.com/u/16666064?v=4&s=117"></a></td></tr>
-<tr><td><a href=https://github.com/aalepis><img width="117" alt="aalepis" src="https://avatars.githubusercontent.com/u/35684834?v=4&s=117"></a></td><td><a href=https://github.com/alexnj><img width="117" alt="alexnj" src="https://avatars.githubusercontent.com/u/683500?v=4&s=117"></a></td><td><a href=https://github.com/asmt3><img width="117" alt="asmt3" src="https://avatars.githubusercontent.com/u/1777709?v=4&s=117"></a></td><td><a href=https://github.com/ahmadissa><img width="117" alt="ahmadissa" src="https://avatars.githubusercontent.com/u/9936573?v=4&s=117"></a></td><td><a href=https://github.com/adritasharma><img width="117" alt="adritasharma" src="https://avatars.githubusercontent.com/u/29271635?v=4&s=117"></a></td><td><a href=https://github.com/Adrrei><img width="117" alt="Adrrei" src="https://avatars.githubusercontent.com/u/22191685?v=4&s=117"></a></td></tr>
-<tr><td><a href=https://github.com/adityapatadia><img width="117" alt="adityapatadia" src="https://avatars.githubusercontent.com/u/1086617?v=4&s=117"></a></td><td><a href=https://github.com/adamvigneault><img width="117" alt="adamvigneault" src="https://avatars.githubusercontent.com/u/18236120?v=4&s=117"></a></td><td><a href=https://github.com/ajh-sr><img width="117" alt="ajh-sr" src="https://avatars.githubusercontent.com/u/71472057?v=4&s=117"></a></td><td><a href=https://github.com/adamdottv><img width="117" alt="adamdottv" src="https://avatars.githubusercontent.com/u/2363879?v=4&s=117"></a></td><td><a href=https://github.com/abannach><img width="117" alt="abannach" src="https://avatars.githubusercontent.com/u/43150303?v=4&s=117"></a></td><td><a href=https://github.com/superhawk610><img width="117" alt="superhawk610" src="https://avatars.githubusercontent.com/u/18172185?v=4&s=117"></a></td></tr>
-<tr><td><a href=https://github.com/ajschmidt8><img width="117" alt="ajschmidt8" src="https://avatars.githubusercontent.com/u/7400326?v=4&s=117"></a></td><td><a href=https://github.com/wbaaron><img width="117" alt="wbaaron" src="https://avatars.githubusercontent.com/u/1048988?v=4&s=117"></a></td><td><a href=https://github.com/Quorafind><img width="117" alt="Quorafind" src="https://avatars.githubusercontent.com/u/13215013?v=4&s=117"></a></td><td><a href=https://github.com/bducharme><img width="117" alt="bducharme" src="https://avatars.githubusercontent.com/u/4173569?v=4&s=117"></a></td><td><a href=https://github.com/azizk><img width="117" alt="azizk" src="https://avatars.githubusercontent.com/u/37282?v=4&s=117"></a></td><td><a href=https://github.com/azeemba><img width="117" alt="azeemba" src="https://avatars.githubusercontent.com/u/2160795?v=4&s=117"></a></td></tr>
-<tr><td><a href=https://github.com/ayhankesicioglu><img width="117" alt="ayhankesicioglu" src="https://avatars.githubusercontent.com/u/36304312?v=4&s=117"></a></td><td><a href=https://github.com/avneetmalhotra><img width="117" alt="avneetmalhotra" src="https://avatars.githubusercontent.com/u/10562207?v=4&s=117"></a></td><td><a href=https://github.com/atsawin><img width="117" alt="atsawin" src="https://avatars.githubusercontent.com/u/666663?v=4&s=117"></a></td><td><a href=https://github.com/ash-jc-allen><img width="117" alt="ash-jc-allen" src="https://avatars.githubusercontent.com/u/39652331?v=4&s=117"></a></td><td><a href=https://github.com/apuyou><img width="117" alt="apuyou" src="https://avatars.githubusercontent.com/u/520053?v=4&s=117"></a></td><td><a href=https://github.com/arthurdenner><img width="117" alt="arthurdenner" src="https://avatars.githubusercontent.com/u/13774309?v=4&s=117"></a></td></tr>
-<tr><td><a href=https://github.com/Abourass><img width="117" alt="Abourass" src="https://avatars.githubusercontent.com/u/39917231?v=4&s=117"></a></td><td><a href=https://github.com/tyndria><img width="117" alt="tyndria" src="https://avatars.githubusercontent.com/u/17138916?v=4&s=117"></a></td><td><a href=https://github.com/anthony0030><img width="117" alt="anthony0030" src="https://avatars.githubusercontent.com/u/13033263?v=4&s=117"></a></td><td><a href=https://github.com/andychongyz><img width="117" alt="andychongyz" src="https://avatars.githubusercontent.com/u/12697240?v=4&s=117"></a></td><td><a href=https://github.com/andrii-bodnar><img width="117" alt="andrii-bodnar" src="https://avatars.githubusercontent.com/u/29282228?v=4&s=117"></a></td><td><a href=https://github.com/superandrew213><img width="117" alt="superandrew213" src="https://avatars.githubusercontent.com/u/13059204?v=4&s=117"></a></td></tr>
-<tr><td><a href=https://github.com/radarhere><img width="117" alt="radarhere" src="https://avatars.githubusercontent.com/u/3112309?v=4&s=117"></a></td><td><a href=https://github.com/firesharkstudios><img width="117" alt="firesharkstudios" src="https://avatars.githubusercontent.com/u/17069637?v=4&s=117"></a></td><td><a href=https://github.com/kaspermeinema><img width="117" alt="kaspermeinema" src="https://avatars.githubusercontent.com/u/73821331?v=4&s=117"></a></td><td><a href=https://github.com/tykarol><img width="117" alt="tykarol" src="https://avatars.githubusercontent.com/u/9386320?v=4&s=117"></a></td><td><a href=https://github.com/jvelten><img width="117" alt="jvelten" src="https://avatars.githubusercontent.com/u/48118068?v=4&s=117"></a></td><td><a href=https://github.com/mellow-fellow><img width="117" alt="mellow-fellow" src="https://avatars.githubusercontent.com/u/19280122?v=4&s=117"></a></td></tr>
+<tr><td><a href=https://github.com/ghasrfakhri><img width="117" alt="ghasrfakhri" src="https://avatars.githubusercontent.com/u/4945963?v=4&s=117"></a></td><td><a href=https://github.com/rart><img width="117" alt="rart" src="https://avatars.githubusercontent.com/u/3928341?v=4&s=117"></a></td><td><a href=https://github.com/rossng><img width="117" alt="rossng" src="https://avatars.githubusercontent.com/u/565371?v=4&s=117"></a></td><td><a href=https://github.com/scherroman><img width="117" alt="scherroman" src="https://avatars.githubusercontent.com/u/7923938?v=4&s=117"></a></td><td><a href=https://github.com/robwilson1><img width="117" alt="robwilson1" src="https://avatars.githubusercontent.com/u/7114944?v=4&s=117"></a></td><td><a href=https://github.com/SxDx><img width="117" alt="SxDx" src="https://avatars.githubusercontent.com/u/2004247?v=4&s=117"></a></td></tr>
+<tr><td><a href=https://github.com/refo><img width="117" alt="refo" src="https://avatars.githubusercontent.com/u/1114116?v=4&s=117"></a></td><td><a href=https://github.com/raulibanez><img width="117" alt="raulibanez" src="https://avatars.githubusercontent.com/u/1070825?v=4&s=117"></a></td><td><a href=https://github.com/luarmr><img width="117" alt="luarmr" src="https://avatars.githubusercontent.com/u/817416?v=4&s=117"></a></td><td><a href=https://github.com/eman8519><img width="117" alt="eman8519" src="https://avatars.githubusercontent.com/u/2380804?v=4&s=117"></a></td><td><a href=https://github.com/Pzoco><img width="117" alt="Pzoco" src="https://avatars.githubusercontent.com/u/3101348?v=4&s=117"></a></td><td><a href=https://github.com/ppadmavilasom><img width="117" alt="ppadmavilasom" src="https://avatars.githubusercontent.com/u/11167452?v=4&s=117"></a></td></tr>
+<tr><td><a href=https://github.com/phillipalexander><img width="117" alt="phillipalexander" src="https://avatars.githubusercontent.com/u/1577682?v=4&s=117"></a></td><td><a href=https://github.com/pmusaraj><img width="117" alt="pmusaraj" src="https://avatars.githubusercontent.com/u/368961?v=4&s=117"></a></td><td><a href=https://github.com/pedrofs><img width="117" alt="pedrofs" src="https://avatars.githubusercontent.com/u/56484?v=4&s=117"></a></td><td><a href=https://github.com/plneto><img width="117" alt="plneto" src="https://avatars.githubusercontent.com/u/5697434?v=4&s=117"></a></td><td><a href=https://github.com/patricklindsay><img width="117" alt="patricklindsay" src="https://avatars.githubusercontent.com/u/7923681?v=4&s=117"></a></td><td><a href=https://github.com/pascalwengerter><img width="117" alt="pascalwengerter" src="https://avatars.githubusercontent.com/u/16822008?v=4&s=117"></a></td></tr>
+<tr><td><a href=https://github.com/JimmyLv><img width="117" alt="JimmyLv" src="https://avatars.githubusercontent.com/u/4997466?v=4&s=117"></a></td><td><a href=https://github.com/Tashows><img width="117" alt="Tashows" src="https://avatars.githubusercontent.com/u/16656928?v=4&s=117"></a></td><td><a href=https://github.com/taj><img width="117" alt="taj" src="https://avatars.githubusercontent.com/u/16062635?v=4&s=117"></a></td><td><a href=https://github.com/strayer><img width="117" alt="strayer" src="https://avatars.githubusercontent.com/u/310624?v=4&s=117"></a></td><td><a href=https://github.com/sjauld><img width="117" alt="sjauld" src="https://avatars.githubusercontent.com/u/8232503?v=4&s=117"></a></td><td><a href=https://github.com/steverob><img width="117" alt="steverob" src="https://avatars.githubusercontent.com/u/1220480?v=4&s=117"></a></td></tr>
+<tr><td><a href=https://github.com/amaitu><img width="117" alt="amaitu" src="https://avatars.githubusercontent.com/u/15688439?v=4&s=117"></a></td><td><a href=https://github.com/quigebo><img width="117" alt="quigebo" src="https://avatars.githubusercontent.com/u/741?v=4&s=117"></a></td><td><a href=https://github.com/waptik><img width="117" alt="waptik" src="https://avatars.githubusercontent.com/u/1687551?v=4&s=117"></a></td><td><a href=https://github.com/SpazzMarticus><img width="117" alt="SpazzMarticus" src="https://avatars.githubusercontent.com/u/5716457?v=4&s=117"></a></td><td><a href=https://github.com/szh><img width="117" alt="szh" src="https://avatars.githubusercontent.com/u/546965?v=4&s=117"></a></td><td><a href=https://github.com/sergei-zelinsky><img width="117" alt="sergei-zelinsky" src="https://avatars.githubusercontent.com/u/19428086?v=4&s=117"></a></td></tr>
+<tr><td><a href=https://github.com/sebasegovia01><img width="117" alt="sebasegovia01" src="https://avatars.githubusercontent.com/u/35777287?v=4&s=117"></a></td><td><a href=https://github.com/sdebacker><img width="117" alt="sdebacker" src="https://avatars.githubusercontent.com/u/134503?v=4&s=117"></a></td><td><a href=https://github.com/Rattone><img width="117" alt="Rattone" src="https://avatars.githubusercontent.com/u/7362607?v=4&s=117"></a></td><td><a href=https://github.com/samuelcolburn><img width="117" alt="samuelcolburn" src="https://avatars.githubusercontent.com/u/9741902?v=4&s=117"></a></td><td><a href=https://github.com/fortunto2><img width="117" alt="fortunto2" src="https://avatars.githubusercontent.com/u/1236751?v=4&s=117"></a></td><td><a href=https://github.com/GNURub><img width="117" alt="GNURub" src="https://avatars.githubusercontent.com/u/1318648?v=4&s=117"></a></td></tr>
+<tr><td><a href=https://github.com/ParsaArvanehPA><img width="117" alt="ParsaArvanehPA" src="https://avatars.githubusercontent.com/u/62149413?v=4&s=117"></a></td><td><a href=https://github.com/ken-kuro><img width="117" alt="ken-kuro" src="https://avatars.githubusercontent.com/u/47441476?v=4&s=117"></a></td><td><a href=https://github.com/milannakum><img width="117" alt="milannakum" src="https://avatars.githubusercontent.com/u/11374368?v=4&s=117"></a></td><td><a href=https://github.com/mkopinsky><img width="117" alt="mkopinsky" src="https://avatars.githubusercontent.com/u/591435?v=4&s=117"></a></td><td><a href=https://github.com/mhulet><img width="117" alt="mhulet" src="https://avatars.githubusercontent.com/u/293355?v=4&s=117"></a></td><td><a href=https://github.com/hrsh><img width="117" alt="hrsh" src="https://avatars.githubusercontent.com/u/1929359?v=4&s=117"></a></td></tr>
+<tr><td><a href=https://github.com/mauricioribeiro><img width="117" alt="mauricioribeiro" src="https://avatars.githubusercontent.com/u/2589856?v=4&s=117"></a></td><td><a href=https://github.com/matthewhartstonge><img width="117" alt="matthewhartstonge" src="https://avatars.githubusercontent.com/u/6119549?v=4&s=117"></a></td><td><a href=https://github.com/mjesuele><img width="117" alt="mjesuele" src="https://avatars.githubusercontent.com/u/871117?v=4&s=117"></a></td><td><a href=https://github.com/mattfik><img width="117" alt="mattfik" src="https://avatars.githubusercontent.com/u/1638028?v=4&s=117"></a></td><td><a href=https://github.com/mateuscruz><img width="117" alt="mateuscruz" src="https://avatars.githubusercontent.com/u/8962842?v=4&s=117"></a></td><td><a href=https://github.com/masumulu28><img width="117" alt="masumulu28" src="https://avatars.githubusercontent.com/u/49063256?v=4&s=117"></a></td></tr>
+<tr><td><a href=https://github.com/masaok><img width="117" alt="masaok" src="https://avatars.githubusercontent.com/u/1320083?v=4&s=117"></a></td><td><a href=https://github.com/martin-brennan><img width="117" alt="martin-brennan" src="https://avatars.githubusercontent.com/u/920448?v=4&s=117"></a></td><td><a href=https://github.com/marcusforsberg><img width="117" alt="marcusforsberg" src="https://avatars.githubusercontent.com/u/1009069?v=4&s=117"></a></td><td><a href=https://github.com/marcosthejew><img width="117" alt="marcosthejew" src="https://avatars.githubusercontent.com/u/1500967?v=4&s=117"></a></td><td><a href=https://github.com/mperrando><img width="117" alt="mperrando" src="https://avatars.githubusercontent.com/u/525572?v=4&s=117"></a></td><td><a href=https://github.com/onhate><img width="117" alt="onhate" src="https://avatars.githubusercontent.com/u/980905?v=4&s=117"></a></td></tr>
+<tr><td><a href=https://github.com/cryptic022><img width="117" alt="cryptic022" src="https://avatars.githubusercontent.com/u/18145703?v=4&s=117"></a></td><td><a href=https://github.com/Ozodbek1405><img width="117" alt="Ozodbek1405" src="https://avatars.githubusercontent.com/u/86141593?v=4&s=117"></a></td><td><a href=https://github.com/leftdevel><img width="117" alt="leftdevel" src="https://avatars.githubusercontent.com/u/843337?v=4&s=117"></a></td><td><a href=https://github.com/nil1511><img width="117" alt="nil1511" src="https://avatars.githubusercontent.com/u/2058170?v=4&s=117"></a></td><td><a href=https://github.com/coreprocess><img width="117" alt="coreprocess" src="https://avatars.githubusercontent.com/u/1226918?v=4&s=117"></a></td><td><a href=https://github.com/nicojones><img width="117" alt="nicojones" src="https://avatars.githubusercontent.com/u/6078915?v=4&s=117"></a></td></tr>
+<tr><td><a href=https://github.com/trungcva10a6tn><img width="117" alt="trungcva10a6tn" src="https://avatars.githubusercontent.com/u/18293783?v=4&s=117"></a></td><td><a href=https://github.com/naveed-ahmad><img width="117" alt="naveed-ahmad" src="https://avatars.githubusercontent.com/u/701567?v=4&s=117"></a></td><td><a href=https://github.com/pleasespammelater><img width="117" alt="pleasespammelater" src="https://avatars.githubusercontent.com/u/11870394?v=4&s=117"></a></td><td><a href=https://github.com/marton-laszlo-attila><img width="117" alt="marton-laszlo-attila" src="https://avatars.githubusercontent.com/u/73295321?v=4&s=117"></a></td><td><a href=https://github.com/navruzm><img width="117" alt="navruzm" src="https://avatars.githubusercontent.com/u/168341?v=4&s=117"></a></td><td><a href=https://github.com/mogzol><img width="117" alt="mogzol" src="https://avatars.githubusercontent.com/u/11789801?v=4&s=117"></a></td></tr>
+<tr><td><a href=https://github.com/shahimclt><img width="117" alt="shahimclt" src="https://avatars.githubusercontent.com/u/8318002?v=4&s=117"></a></td><td><a href=https://github.com/mnafees><img width="117" alt="mnafees" src="https://avatars.githubusercontent.com/u/1763885?v=4&s=117"></a></td><td><a href=https://github.com/boudra><img width="117" alt="boudra" src="https://avatars.githubusercontent.com/u/711886?v=4&s=117"></a></td><td><a href=https://github.com/Mitchell8210><img width="117" alt="Mitchell8210" src="https://avatars.githubusercontent.com/u/23045264?v=4&s=117"></a></td><td><a href=https://github.com/achmiral><img width="117" alt="achmiral" src="https://avatars.githubusercontent.com/u/10906059?v=4&s=117"></a></td><td><a href=https://github.com/netdown><img width="117" alt="netdown" src="https://avatars.githubusercontent.com/u/4265403?v=4&s=117"></a></td></tr>
+<tr><td><a href=https://github.com/mosi-kha><img width="117" alt="mosi-kha" src="https://avatars.githubusercontent.com/u/35611016?v=4&s=117"></a></td><td><a href=https://github.com/maddy-jo><img width="117" alt="maddy-jo" src="https://avatars.githubusercontent.com/u/3241493?v=4&s=117"></a></td><td><a href=https://github.com/mdxiaohu><img width="117" alt="mdxiaohu" src="https://avatars.githubusercontent.com/u/42248614?v=4&s=117"></a></td><td><a href=https://github.com/magumbo><img width="117" alt="magumbo" src="https://avatars.githubusercontent.com/u/6683765?v=4&s=117"></a></td><td><a href=https://github.com/jx-zyf><img width="117" alt="jx-zyf" src="https://avatars.githubusercontent.com/u/26456842?v=4&s=117"></a></td><td><a href=https://github.com/kode-ninja><img width="117" alt="kode-ninja" src="https://avatars.githubusercontent.com/u/7857611?v=4&s=117"></a></td></tr>
+<tr><td><a href=https://github.com/sontixyou><img width="117" alt="sontixyou" src="https://avatars.githubusercontent.com/u/19817196?v=4&s=117"></a></td><td><a href=https://github.com/jur-ng><img width="117" alt="jur-ng" src="https://avatars.githubusercontent.com/u/111122756?v=4&s=117"></a></td><td><a href=https://github.com/johnmanjiro13><img width="117" alt="johnmanjiro13" src="https://avatars.githubusercontent.com/u/28798279?v=4&s=117"></a></td><td><a href=https://github.com/jyoungblood><img width="117" alt="jyoungblood" src="https://avatars.githubusercontent.com/u/56104?v=4&s=117"></a></td><td><a href=https://github.com/green-mike><img width="117" alt="green-mike" src="https://avatars.githubusercontent.com/u/5584225?v=4&s=117"></a></td><td><a href=https://github.com/gaelicwinter><img width="117" alt="gaelicwinter" src="https://avatars.githubusercontent.com/u/6510266?v=4&s=117"></a></td></tr>
+<tr><td><a href=https://github.com/franckl><img width="117" alt="franckl" src="https://avatars.githubusercontent.com/u/3875803?v=4&s=117"></a></td><td><a href=https://github.com/fingul><img width="117" alt="fingul" src="https://avatars.githubusercontent.com/u/894739?v=4&s=117"></a></td><td><a href=https://github.com/elliotsayes><img width="117" alt="elliotsayes" src="https://avatars.githubusercontent.com/u/7699058?v=4&s=117"></a></td><td><a href=https://github.com/dzcpy><img width="117" alt="dzcpy" src="https://avatars.githubusercontent.com/u/203980?v=4&s=117"></a></td><td><a href=https://github.com/dkisic><img width="117" alt="dkisic" src="https://avatars.githubusercontent.com/u/32257921?v=4&s=117"></a></td><td><a href=https://github.com/zanzlender><img width="117" alt="zanzlender" src="https://avatars.githubusercontent.com/u/44570474?v=4&s=117"></a></td></tr>
+<tr><td><a href=https://github.com/olitomas><img width="117" alt="olitomas" src="https://avatars.githubusercontent.com/u/6918659?v=4&s=117"></a></td><td><a href=https://github.com/yoann-hellopret><img width="117" alt="yoann-hellopret" src="https://avatars.githubusercontent.com/u/46525558?v=4&s=117"></a></td><td><a href=https://github.com/vedran555><img width="117" alt="vedran555" src="https://avatars.githubusercontent.com/u/38395951?v=4&s=117"></a></td><td><a href=https://github.com/tusharjkhunt><img width="117" alt="tusharjkhunt" src="https://avatars.githubusercontent.com/u/31904234?v=4&s=117"></a></td><td><a href=https://github.com/thanhthot><img width="117" alt="thanhthot" src="https://avatars.githubusercontent.com/u/50633205?v=4&s=117"></a></td><td><a href=https://github.com/stduhpf><img width="117" alt="stduhpf" src="https://avatars.githubusercontent.com/u/28208228?v=4&s=117"></a></td></tr>
+<tr><td><a href=https://github.com/slawexxx44><img width="117" alt="slawexxx44" src="https://avatars.githubusercontent.com/u/11180644?v=4&s=117"></a></td><td><a href=https://github.com/rtaieb><img width="117" alt="rtaieb" src="https://avatars.githubusercontent.com/u/35224301?v=4&s=117"></a></td><td><a href=https://github.com/rmoura-92><img width="117" alt="rmoura-92" src="https://avatars.githubusercontent.com/u/419044?v=4&s=117"></a></td><td><a href=https://github.com/rlebosse><img width="117" alt="rlebosse" src="https://avatars.githubusercontent.com/u/2794137?v=4&s=117"></a></td><td><a href=https://github.com/rhymes><img width="117" alt="rhymes" src="https://avatars.githubusercontent.com/u/146201?v=4&s=117"></a></td><td><a href=https://github.com/luntta><img width="117" alt="luntta" src="https://avatars.githubusercontent.com/u/14221637?v=4&s=117"></a></td></tr>
+<tr><td><a href=https://github.com/phil714><img width="117" alt="phil714" src="https://avatars.githubusercontent.com/u/7584581?v=4&s=117"></a></td><td><a href=https://github.com/ordago><img width="117" alt="ordago" src="https://avatars.githubusercontent.com/u/6376814?v=4&s=117"></a></td><td><a href=https://github.com/odselsevier><img width="117" alt="odselsevier" src="https://avatars.githubusercontent.com/u/95745934?v=4&s=117"></a></td><td><a href=https://github.com/ninesalt><img width="117" alt="ninesalt" src="https://avatars.githubusercontent.com/u/7952255?v=4&s=117"></a></td><td><a href=https://github.com/neuronet77><img width="117" alt="neuronet77" src="https://avatars.githubusercontent.com/u/4220037?v=4&s=117"></a></td><td><a href=https://github.com/willycamargo><img width="117" alt="willycamargo" src="https://avatars.githubusercontent.com/u/5041887?v=4&s=117"></a></td></tr>
+<tr><td><a href=https://github.com/weston-sankey-mark43><img width="117" alt="weston-sankey-mark43" src="https://avatars.githubusercontent.com/u/97678695?v=4&s=117"></a></td><td><a href=https://github.com/dwnste><img width="117" alt="dwnste" src="https://avatars.githubusercontent.com/u/17119722?v=4&s=117"></a></td><td><a href=https://github.com/nagyv><img width="117" alt="nagyv" src="https://avatars.githubusercontent.com/u/126671?v=4&s=117"></a></td><td><a href=https://github.com/stiig><img width="117" alt="stiig" src="https://avatars.githubusercontent.com/u/8639922?v=4&s=117"></a></td><td><a href=https://github.com/valentinoli><img width="117" alt="valentinoli" src="https://avatars.githubusercontent.com/u/23453691?v=4&s=117"></a></td><td><a href=https://github.com/vially><img width="117" alt="vially" src="https://avatars.githubusercontent.com/u/433598?v=4&s=117"></a></td></tr>
+<tr><td><a href=https://github.com/bodryi><img width="117" alt="bodryi" src="https://avatars.githubusercontent.com/u/7326310?v=4&s=117"></a></td><td><a href=https://github.com/trivikr><img width="117" alt="trivikr" src="https://avatars.githubusercontent.com/u/16024985?v=4&s=117"></a></td><td><a href=https://github.com/top-master><img width="117" alt="top-master" src="https://avatars.githubusercontent.com/u/31405473?v=4&s=117"></a></td><td><a href=https://github.com/tvaliasek><img width="117" alt="tvaliasek" src="https://avatars.githubusercontent.com/u/8644946?v=4&s=117"></a></td><td><a href=https://github.com/tomekp><img width="117" alt="tomekp" src="https://avatars.githubusercontent.com/u/1856393?v=4&s=117"></a></td><td><a href=https://github.com/tomsaleeba><img width="117" alt="tomsaleeba" src="https://avatars.githubusercontent.com/u/1773838?v=4&s=117"></a></td></tr>
+<tr><td><a href=https://github.com/WIStudent><img width="117" alt="WIStudent" src="https://avatars.githubusercontent.com/u/2707930?v=4&s=117"></a></td><td><a href=https://github.com/tmaier><img width="117" alt="tmaier" src="https://avatars.githubusercontent.com/u/350038?v=4&s=117"></a></td><td><a href=https://github.com/Tiarhai><img width="117" alt="Tiarhai" src="https://avatars.githubusercontent.com/u/12871513?v=4&s=117"></a></td><td><a href=https://github.com/twarlop><img width="117" alt="twarlop" src="https://avatars.githubusercontent.com/u/2856082?v=4&s=117"></a></td><td><a href=https://github.com/tcgj><img width="117" alt="tcgj" src="https://avatars.githubusercontent.com/u/7994529?v=4&s=117"></a></td><td><a href=https://github.com/craigcbrunner><img width="117" alt="craigcbrunner" src="https://avatars.githubusercontent.com/u/2780521?v=4&s=117"></a></td></tr>
+<tr><td><a href=https://github.com/codehero7386><img width="117" alt="codehero7386" src="https://avatars.githubusercontent.com/u/56253286?v=4&s=117"></a></td><td><a href=https://github.com/christianwengert><img width="117" alt="christianwengert" src="https://avatars.githubusercontent.com/u/12936057?v=4&s=117"></a></td><td><a href=https://github.com/cgoinglove><img width="117" alt="cgoinglove" src="https://avatars.githubusercontent.com/u/86150470?v=4&s=117"></a></td><td><a href=https://github.com/canvasbh><img width="117" alt="canvasbh" src="https://avatars.githubusercontent.com/u/44477734?v=4&s=117"></a></td><td><a href=https://github.com/c0b41><img width="117" alt="c0b41" src="https://avatars.githubusercontent.com/u/2834954?v=4&s=117"></a></td><td><a href=https://github.com/avalla><img width="117" alt="avalla" src="https://avatars.githubusercontent.com/u/986614?v=4&s=117"></a></td></tr>
+<tr><td><a href=https://github.com/arggh><img width="117" alt="arggh" src="https://avatars.githubusercontent.com/u/17210302?v=4&s=117"></a></td><td><a href=https://github.com/alfatv><img width="117" alt="alfatv" src="https://avatars.githubusercontent.com/u/62238673?v=4&s=117"></a></td><td><a href=https://github.com/agreene-coursera><img width="117" alt="agreene-coursera" src="https://avatars.githubusercontent.com/u/30501355?v=4&s=117"></a></td><td><a href=https://github.com/aduh95-test-account><img width="117" alt="aduh95-test-account" src="https://avatars.githubusercontent.com/u/93441190?v=4&s=117"></a></td><td><a href=https://github.com/sartoshi-foot-dao><img width="117" alt="sartoshi-foot-dao" src="https://avatars.githubusercontent.com/u/99770068?v=4&s=117"></a></td><td><a href=https://github.com/zackbloom><img width="117" alt="zackbloom" src="https://avatars.githubusercontent.com/u/55347?v=4&s=117"></a></td></tr>
+<tr><td><a href=https://github.com/zlawson-ut><img width="117" alt="zlawson-ut" src="https://avatars.githubusercontent.com/u/7375444?v=4&s=117"></a></td><td><a href=https://github.com/zachconner><img width="117" alt="zachconner" src="https://avatars.githubusercontent.com/u/11339326?v=4&s=117"></a></td><td><a href=https://github.com/yafkari><img width="117" alt="yafkari" src="https://avatars.githubusercontent.com/u/41365655?v=4&s=117"></a></td><td><a href=https://github.com/YehudaKremer><img width="117" alt="YehudaKremer" src="https://avatars.githubusercontent.com/u/946652?v=4&s=117"></a></td><td><a href=https://github.com/xhocquet><img width="117" alt="xhocquet" src="https://avatars.githubusercontent.com/u/8116516?v=4&s=117"></a></td><td><a href=https://github.com/sercraig><img width="117" alt="sercraig" src="https://avatars.githubusercontent.com/u/24261518?v=4&s=117"></a></td></tr>
+<tr><td><a href=https://github.com/ardeois><img width="117" alt="ardeois" src="https://avatars.githubusercontent.com/u/1867939?v=4&s=117"></a></td><td><a href=https://github.com/CommanderRoot><img width="117" alt="CommanderRoot" src="https://avatars.githubusercontent.com/u/4395417?v=4&s=117"></a></td><td><a href=https://github.com/czj><img width="117" alt="czj" src="https://avatars.githubusercontent.com/u/14306?v=4&s=117"></a></td><td><a href=https://github.com/cbush06><img width="117" alt="cbush06" src="https://avatars.githubusercontent.com/u/15720146?v=4&s=117"></a></td><td><a href=https://github.com/Aarbel><img width="117" alt="Aarbel" src="https://avatars.githubusercontent.com/u/25119847?v=4&s=117"></a></td><td><a href=https://github.com/cfra><img width="117" alt="cfra" src="https://avatars.githubusercontent.com/u/1347051?v=4&s=117"></a></td></tr>
+<tr><td><a href=https://github.com/csprance><img width="117" alt="csprance" src="https://avatars.githubusercontent.com/u/7902617?v=4&s=117"></a></td><td><a href=https://github.com/prattcmp><img width="117" alt="prattcmp" src="https://avatars.githubusercontent.com/u/1497950?v=4&s=117"></a></td><td><a href=https://github.com/subvertallchris><img width="117" alt="subvertallchris" src="https://avatars.githubusercontent.com/u/4097271?v=4&s=117"></a></td><td><a href=https://github.com/charlybillaud><img width="117" alt="charlybillaud" src="https://avatars.githubusercontent.com/u/31970410?v=4&s=117"></a></td><td><a href=https://github.com/Cretezy><img width="117" alt="Cretezy" src="https://avatars.githubusercontent.com/u/2672503?v=4&s=117"></a></td><td><a href=https://github.com/chao><img width="117" alt="chao" src="https://avatars.githubusercontent.com/u/55872?v=4&s=117"></a></td></tr>
+<tr><td><a href=https://github.com/cellvinchung><img width="117" alt="cellvinchung" src="https://avatars.githubusercontent.com/u/5347394?v=4&s=117"></a></td><td><a href=https://github.com/cartfisk><img width="117" alt="cartfisk" src="https://avatars.githubusercontent.com/u/8764375?v=4&s=117"></a></td><td><a href=https://github.com/cyu><img width="117" alt="cyu" src="https://avatars.githubusercontent.com/u/2431?v=4&s=117"></a></td><td><a href=https://github.com/bryanjswift><img width="117" alt="bryanjswift" src="https://avatars.githubusercontent.com/u/9911?v=4&s=117"></a></td><td><a href=https://github.com/bedgerotto><img width="117" alt="bedgerotto" src="https://avatars.githubusercontent.com/u/4459657?v=4&s=117"></a></td><td><a href=https://github.com/eliOcs><img width="117" alt="eliOcs" src="https://avatars.githubusercontent.com/u/1283954?v=4&s=117"></a></td></tr>
+<tr><td><a href=https://github.com/yoldar><img width="117" alt="yoldar" src="https://avatars.githubusercontent.com/u/1597578?v=4&s=117"></a></td><td><a href=https://github.com/efbautista><img width="117" alt="efbautista" src="https://avatars.githubusercontent.com/u/35430671?v=4&s=117"></a></td><td><a href=https://github.com/emuell><img width="117" alt="emuell" src="https://avatars.githubusercontent.com/u/11521600?v=4&s=117"></a></td><td><a href=https://github.com/EdgarSantiago93><img width="117" alt="EdgarSantiago93" src="https://avatars.githubusercontent.com/u/14806877?v=4&s=117"></a></td><td><a href=https://github.com/sweetro><img width="117" alt="sweetro" src="https://avatars.githubusercontent.com/u/6228717?v=4&s=117"></a></td><td><a href=https://github.com/jeetiss><img width="117" alt="jeetiss" src="https://avatars.githubusercontent.com/u/6726016?v=4&s=117"></a></td></tr>
+<tr><td><a href=https://github.com/DennisKofflard><img width="117" alt="DennisKofflard" src="https://avatars.githubusercontent.com/u/8669129?v=4&s=117"></a></td><td><a href=https://github.com/hoangsvit><img width="117" alt="hoangsvit" src="https://avatars.githubusercontent.com/u/11882322?v=4&s=117"></a></td><td><a href=https://github.com/davilima6><img width="117" alt="davilima6" src="https://avatars.githubusercontent.com/u/422130?v=4&s=117"></a></td><td><a href=https://github.com/akizor><img width="117" alt="akizor" src="https://avatars.githubusercontent.com/u/1052439?v=4&s=117"></a></td><td><a href=https://github.com/KaminskiDaniell><img width="117" alt="KaminskiDaniell" src="https://avatars.githubusercontent.com/u/27357868?v=4&s=117"></a></td><td><a href=https://github.com/Cantabar><img width="117" alt="Cantabar" src="https://avatars.githubusercontent.com/u/6812207?v=4&s=117"></a></td></tr>
+<tr><td><a href=https://github.com/mrboomer><img width="117" alt="mrboomer" src="https://avatars.githubusercontent.com/u/5942912?v=4&s=117"></a></td><td><a href=https://github.com/danilat><img width="117" alt="danilat" src="https://avatars.githubusercontent.com/u/22763?v=4&s=117"></a></td><td><a href=https://github.com/danschalow><img width="117" alt="danschalow" src="https://avatars.githubusercontent.com/u/3527437?v=4&s=117"></a></td><td><a href=https://github.com/danmichaelo><img width="117" alt="danmichaelo" src="https://avatars.githubusercontent.com/u/434495?v=4&s=117"></a></td><td><a href=https://github.com/Cruaier><img width="117" alt="Cruaier" src="https://avatars.githubusercontent.com/u/5204940?v=4&s=117"></a></td><td><a href=https://github.com/functino><img width="117" alt="functino" src="https://avatars.githubusercontent.com/u/415498?v=4&s=117"></a></td></tr>
+<tr><td><a href=https://github.com/amitport><img width="117" alt="amitport" src="https://avatars.githubusercontent.com/u/1131991?v=4&s=117"></a></td><td><a href=https://github.com/tekacs><img width="117" alt="tekacs" src="https://avatars.githubusercontent.com/u/63247?v=4&s=117"></a></td><td><a href=https://github.com/Dogfalo><img width="117" alt="Dogfalo" src="https://avatars.githubusercontent.com/u/2775751?v=4&s=117"></a></td><td><a href=https://github.com/alirezahi><img width="117" alt="alirezahi" src="https://avatars.githubusercontent.com/u/16666064?v=4&s=117"></a></td><td><a href=https://github.com/aalepis><img width="117" alt="aalepis" src="https://avatars.githubusercontent.com/u/35684834?v=4&s=117"></a></td><td><a href=https://github.com/alexnj><img width="117" alt="alexnj" src="https://avatars.githubusercontent.com/u/683500?v=4&s=117"></a></td></tr>
+<tr><td><a href=https://github.com/asmt3><img width="117" alt="asmt3" src="https://avatars.githubusercontent.com/u/1777709?v=4&s=117"></a></td><td><a href=https://github.com/ahmadissa><img width="117" alt="ahmadissa" src="https://avatars.githubusercontent.com/u/9936573?v=4&s=117"></a></td><td><a href=https://github.com/adritasharma><img width="117" alt="adritasharma" src="https://avatars.githubusercontent.com/u/29271635?v=4&s=117"></a></td><td><a href=https://github.com/Adrrei><img width="117" alt="Adrrei" src="https://avatars.githubusercontent.com/u/22191685?v=4&s=117"></a></td><td><a href=https://github.com/adityapatadia><img width="117" alt="adityapatadia" src="https://avatars.githubusercontent.com/u/1086617?v=4&s=117"></a></td><td><a href=https://github.com/adamvigneault><img width="117" alt="adamvigneault" src="https://avatars.githubusercontent.com/u/18236120?v=4&s=117"></a></td></tr>
+<tr><td><a href=https://github.com/ajh-sr><img width="117" alt="ajh-sr" src="https://avatars.githubusercontent.com/u/71472057?v=4&s=117"></a></td><td><a href=https://github.com/adamdottv><img width="117" alt="adamdottv" src="https://avatars.githubusercontent.com/u/2363879?v=4&s=117"></a></td><td><a href=https://github.com/abannach><img width="117" alt="abannach" src="https://avatars.githubusercontent.com/u/43150303?v=4&s=117"></a></td><td><a href=https://github.com/superhawk610><img width="117" alt="superhawk610" src="https://avatars.githubusercontent.com/u/18172185?v=4&s=117"></a></td><td><a href=https://github.com/ajschmidt8><img width="117" alt="ajschmidt8" src="https://avatars.githubusercontent.com/u/7400326?v=4&s=117"></a></td><td><a href=https://github.com/wbaaron><img width="117" alt="wbaaron" src="https://avatars.githubusercontent.com/u/1048988?v=4&s=117"></a></td></tr>
+<tr><td><a href=https://github.com/Quorafind><img width="117" alt="Quorafind" src="https://avatars.githubusercontent.com/u/13215013?v=4&s=117"></a></td><td><a href=https://github.com/bducharme><img width="117" alt="bducharme" src="https://avatars.githubusercontent.com/u/4173569?v=4&s=117"></a></td><td><a href=https://github.com/azizk><img width="117" alt="azizk" src="https://avatars.githubusercontent.com/u/37282?v=4&s=117"></a></td><td><a href=https://github.com/azeemba><img width="117" alt="azeemba" src="https://avatars.githubusercontent.com/u/2160795?v=4&s=117"></a></td><td><a href=https://github.com/ayhankesicioglu><img width="117" alt="ayhankesicioglu" src="https://avatars.githubusercontent.com/u/36304312?v=4&s=117"></a></td><td><a href=https://github.com/avneetmalhotra><img width="117" alt="avneetmalhotra" src="https://avatars.githubusercontent.com/u/10562207?v=4&s=117"></a></td></tr>
+<tr><td><a href=https://github.com/atsawin><img width="117" alt="atsawin" src="https://avatars.githubusercontent.com/u/666663?v=4&s=117"></a></td><td><a href=https://github.com/ash-jc-allen><img width="117" alt="ash-jc-allen" src="https://avatars.githubusercontent.com/u/39652331?v=4&s=117"></a></td><td><a href=https://github.com/apuyou><img width="117" alt="apuyou" src="https://avatars.githubusercontent.com/u/520053?v=4&s=117"></a></td><td><a href=https://github.com/arthurdenner><img width="117" alt="arthurdenner" src="https://avatars.githubusercontent.com/u/13774309?v=4&s=117"></a></td><td><a href=https://github.com/Abourass><img width="117" alt="Abourass" src="https://avatars.githubusercontent.com/u/39917231?v=4&s=117"></a></td><td><a href=https://github.com/tyndria><img width="117" alt="tyndria" src="https://avatars.githubusercontent.com/u/17138916?v=4&s=117"></a></td></tr>
+<tr><td><a href=https://github.com/anthony0030><img width="117" alt="anthony0030" src="https://avatars.githubusercontent.com/u/13033263?v=4&s=117"></a></td><td><a href=https://github.com/andychongyz><img width="117" alt="andychongyz" src="https://avatars.githubusercontent.com/u/12697240?v=4&s=117"></a></td><td><a href=https://github.com/andrii-bodnar><img width="117" alt="andrii-bodnar" src="https://avatars.githubusercontent.com/u/29282228?v=4&s=117"></a></td><td><a href=https://github.com/superandrew213><img width="117" alt="superandrew213" src="https://avatars.githubusercontent.com/u/13059204?v=4&s=117"></a></td><td><a href=https://github.com/radarhere><img width="117" alt="radarhere" src="https://avatars.githubusercontent.com/u/3112309?v=4&s=117"></a></td><td><a href=https://github.com/kevin-west-10x><img width="117" alt="kevin-west-10x" src="https://avatars.githubusercontent.com/u/65194914?v=4&s=117"></a></td></tr>
+<tr><td><a href=https://github.com/kergekacsa><img width="117" alt="kergekacsa" src="https://avatars.githubusercontent.com/u/16637320?v=4&s=117"></a></td><td><a href=https://github.com/firesharkstudios><img width="117" alt="firesharkstudios" src="https://avatars.githubusercontent.com/u/17069637?v=4&s=117"></a></td><td><a href=https://github.com/kaspermeinema><img width="117" alt="kaspermeinema" src="https://avatars.githubusercontent.com/u/73821331?v=4&s=117"></a></td><td><a href=https://github.com/tykarol><img width="117" alt="tykarol" src="https://avatars.githubusercontent.com/u/9386320?v=4&s=117"></a></td><td><a href=https://github.com/jvelten><img width="117" alt="jvelten" src="https://avatars.githubusercontent.com/u/48118068?v=4&s=117"></a></td><td><a href=https://github.com/mellow-fellow><img width="117" alt="mellow-fellow" src="https://avatars.githubusercontent.com/u/19280122?v=4&s=117"></a></td></tr>
 <tr><td><a href=https://github.com/jmontoyaa><img width="117" alt="jmontoyaa" src="https://avatars.githubusercontent.com/u/158935?v=4&s=117"></a></td><td><a href=https://github.com/jcalonso><img width="117" alt="jcalonso" src="https://avatars.githubusercontent.com/u/664474?v=4&s=117"></a></td><td><a href=https://github.com/jbelej><img width="117" alt="jbelej" src="https://avatars.githubusercontent.com/u/2229202?v=4&s=117"></a></td><td><a href=https://github.com/jszobody><img width="117" alt="jszobody" src="https://avatars.githubusercontent.com/u/203749?v=4&s=117"></a></td><td><a href=https://github.com/jorgeepc><img width="117" alt="jorgeepc" src="https://avatars.githubusercontent.com/u/3879892?v=4&s=117"></a></td><td><a href=https://github.com/jondewoo><img width="117" alt="jondewoo" src="https://avatars.githubusercontent.com/u/1108358?v=4&s=117"></a></td></tr>
-<tr><td><a href=https://github.com/jonathanarbely><img width="117" alt="jonathanarbely" src="https://avatars.githubusercontent.com/u/18177203?v=4&s=117"></a></td><td><a href=https://github.com/jsanchez034><img width="117" alt="jsanchez034" src="https://avatars.githubusercontent.com/u/761087?v=4&s=117"></a></td><td><a href=https://github.com/Jokcy><img width="117" alt="Jokcy" src="https://avatars.githubusercontent.com/u/2088642?v=4&s=117"></a></td><td><a href=https://github.com/chromacoma><img width="117" alt="chromacoma" src="https://avatars.githubusercontent.com/u/1535623?v=4&s=117"></a></td><td><a href=https://github.com/profsmallpine><img width="117" alt="profsmallpine" src="https://avatars.githubusercontent.com/u/7328006?v=4&s=117"></a></td><td><a href=https://github.com/theJoeBiz><img width="117" alt="theJoeBiz" src="https://avatars.githubusercontent.com/u/189589?v=4&s=117"></a></td></tr>
-<tr><td><a href=https://github.com/huydod><img width="117" alt="huydod" src="https://avatars.githubusercontent.com/u/37580530?v=4&s=117"></a></td><td><a href=https://github.com/lucax88x><img width="117" alt="lucax88x" src="https://avatars.githubusercontent.com/u/6294464?v=4&s=117"></a></td><td><a href=https://github.com/lucaperret><img width="117" alt="lucaperret" src="https://avatars.githubusercontent.com/u/1887122?v=4&s=117"></a></td><td><a href=https://github.com/ombr><img width="117" alt="ombr" src="https://avatars.githubusercontent.com/u/857339?v=4&s=117"></a></td><td><a href=https://github.com/louim><img width="117" alt="louim" src="https://avatars.githubusercontent.com/u/923718?v=4&s=117"></a></td><td><a href=https://github.com/dolphinigle><img width="117" alt="dolphinigle" src="https://avatars.githubusercontent.com/u/7020472?v=4&s=117"></a></td></tr>
+<tr><td><a href=https://github.com/jonathanarbely><img width="117" alt="jonathanarbely" src="https://avatars.githubusercontent.com/u/18177203?v=4&s=117"></a></td><td><a href=https://github.com/jsanchez034><img width="117" alt="jsanchez034" src="https://avatars.githubusercontent.com/u/761087?v=4&s=117"></a></td><td><a href=https://github.com/Jokcy><img width="117" alt="Jokcy" src="https://avatars.githubusercontent.com/u/2088642?v=4&s=117"></a></td><td><a href=https://github.com/chromacoma><img width="117" alt="chromacoma" src="https://avatars.githubusercontent.com/u/1535623?v=4&s=117"></a></td><td><a href=https://github.com/profsmallpine><img width="117" alt="profsmallpine" src="https://avatars.githubusercontent.com/u/7328006?v=4&s=117"></a></td><td><a href=https://github.com/marc-mabe><img width="117" alt="marc-mabe" src="https://avatars.githubusercontent.com/u/302689?v=4&s=117"></a></td></tr>
+<tr><td><a href=https://github.com/Lucklj521><img width="117" alt="Lucklj521" src="https://avatars.githubusercontent.com/u/93632042?v=4&s=117"></a></td><td><a href=https://github.com/lucax88x><img width="117" alt="lucax88x" src="https://avatars.githubusercontent.com/u/6294464?v=4&s=117"></a></td><td><a href=https://github.com/lucaperret><img width="117" alt="lucaperret" src="https://avatars.githubusercontent.com/u/1887122?v=4&s=117"></a></td><td><a href=https://github.com/ombr><img width="117" alt="ombr" src="https://avatars.githubusercontent.com/u/857339?v=4&s=117"></a></td><td><a href=https://github.com/louim><img width="117" alt="louim" src="https://avatars.githubusercontent.com/u/923718?v=4&s=117"></a></td><td><a href=https://github.com/dolphinigle><img width="117" alt="dolphinigle" src="https://avatars.githubusercontent.com/u/7020472?v=4&s=117"></a></td></tr>
 <tr><td><a href=https://github.com/leomelzer><img width="117" alt="leomelzer" src="https://avatars.githubusercontent.com/u/23313?v=4&s=117"></a></td><td><a href=https://github.com/leods92><img width="117" alt="leods92" src="https://avatars.githubusercontent.com/u/879395?v=4&s=117"></a></td><td><a href=https://github.com/galli-leo><img width="117" alt="galli-leo" src="https://avatars.githubusercontent.com/u/5339762?v=4&s=117"></a></td><td><a href=https://github.com/dviry><img width="117" alt="dviry" src="https://avatars.githubusercontent.com/u/1230260?v=4&s=117"></a></td><td><a href=https://github.com/larowlan><img width="117" alt="larowlan" src="https://avatars.githubusercontent.com/u/555254?v=4&s=117"></a></td><td><a href=https://github.com/leaanthony><img width="117" alt="leaanthony" src="https://avatars.githubusercontent.com/u/1943904?v=4&s=117"></a></td></tr>
-<tr><td><a href=https://github.com/hoangbits><img width="117" alt="hoangbits" src="https://avatars.githubusercontent.com/u/7990827?v=4&s=117"></a></td><td><a href=https://github.com/labohkip81><img width="117" alt="labohkip81" src="https://avatars.githubusercontent.com/u/36964869?v=4&s=117"></a></td><td><a href=https://github.com/kyleparisi><img width="117" alt="kyleparisi" src="https://avatars.githubusercontent.com/u/1286753?v=4&s=117"></a></td><td><a href=https://github.com/elkebab><img width="117" alt="elkebab" src="https://avatars.githubusercontent.com/u/6313468?v=4&s=117"></a></td><td><a href=https://github.com/kidonng><img width="117" alt="kidonng" src="https://avatars.githubusercontent.com/u/44045911?v=4&s=117"></a></td><td><a href=https://github.com/kevin-west-10x><img width="117" alt="kevin-west-10x" src="https://avatars.githubusercontent.com/u/65194914?v=4&s=117"></a></td></tr>
-<tr><td><a href=https://github.com/kergekacsa><img width="117" alt="kergekacsa" src="https://avatars.githubusercontent.com/u/16637320?v=4&s=117"></a></td><td><a href=https://github.com/HussainAlkhalifah><img width="117" alt="HussainAlkhalifah" src="https://avatars.githubusercontent.com/u/43642162?v=4&s=117"></a></td><td><a href=https://github.com/HughbertD><img width="117" alt="HughbertD" src="https://avatars.githubusercontent.com/u/1580021?v=4&s=117"></a></td><td><a href=https://github.com/hiromi2424><img width="117" alt="hiromi2424" src="https://avatars.githubusercontent.com/u/191297?v=4&s=117"></a></td><td><a href=https://github.com/giacomocerquone><img width="117" alt="giacomocerquone" src="https://avatars.githubusercontent.com/u/9303791?v=4&s=117"></a></td><td><a href=https://github.com/roenschg><img width="117" alt="roenschg" src="https://avatars.githubusercontent.com/u/9590236?v=4&s=117"></a></td></tr>
+<tr><td><a href=https://github.com/hoangbits><img width="117" alt="hoangbits" src="https://avatars.githubusercontent.com/u/7990827?v=4&s=117"></a></td><td><a href=https://github.com/labohkip81><img width="117" alt="labohkip81" src="https://avatars.githubusercontent.com/u/36964869?v=4&s=117"></a></td><td><a href=https://github.com/kyleparisi><img width="117" alt="kyleparisi" src="https://avatars.githubusercontent.com/u/1286753?v=4&s=117"></a></td><td><a href=https://github.com/elkebab><img width="117" alt="elkebab" src="https://avatars.githubusercontent.com/u/6313468?v=4&s=117"></a></td><td><a href=https://github.com/kidonng><img width="117" alt="kidonng" src="https://avatars.githubusercontent.com/u/44045911?v=4&s=117"></a></td><td><a href=https://github.com/IanVS><img width="117" alt="IanVS" src="https://avatars.githubusercontent.com/u/4616705?v=4&s=117"></a></td></tr>
+<tr><td><a href=https://github.com/huydod><img width="117" alt="huydod" src="https://avatars.githubusercontent.com/u/37580530?v=4&s=117"></a></td><td><a href=https://github.com/HussainAlkhalifah><img width="117" alt="HussainAlkhalifah" src="https://avatars.githubusercontent.com/u/43642162?v=4&s=117"></a></td><td><a href=https://github.com/HughbertD><img width="117" alt="HughbertD" src="https://avatars.githubusercontent.com/u/1580021?v=4&s=117"></a></td><td><a href=https://github.com/hiromi2424><img width="117" alt="hiromi2424" src="https://avatars.githubusercontent.com/u/191297?v=4&s=117"></a></td><td><a href=https://github.com/giacomocerquone><img width="117" alt="giacomocerquone" src="https://avatars.githubusercontent.com/u/9303791?v=4&s=117"></a></td><td><a href=https://github.com/roenschg><img width="117" alt="roenschg" src="https://avatars.githubusercontent.com/u/9590236?v=4&s=117"></a></td></tr>
 <tr><td><a href=https://github.com/gjungb><img width="117" alt="gjungb" src="https://avatars.githubusercontent.com/u/3391068?v=4&s=117"></a></td><td><a href=https://github.com/geoffappleford><img width="117" alt="geoffappleford" src="https://avatars.githubusercontent.com/u/731678?v=4&s=117"></a></td><td><a href=https://github.com/gabiganam><img width="117" alt="gabiganam" src="https://avatars.githubusercontent.com/u/28859646?v=4&s=117"></a></td><td><a href=https://github.com/fuadscodes><img width="117" alt="fuadscodes" src="https://avatars.githubusercontent.com/u/60370584?v=4&s=117"></a></td><td><a href=https://github.com/dtrucs><img width="117" alt="dtrucs" src="https://avatars.githubusercontent.com/u/1926041?v=4&s=117"></a></td><td><a href=https://github.com/ferdiusa><img width="117" alt="ferdiusa" src="https://avatars.githubusercontent.com/u/1997982?v=4&s=117"></a></td></tr>
-<tr><td><a href=https://github.com/fgallinari><img width="117" alt="fgallinari" src="https://avatars.githubusercontent.com/u/6473638?v=4&s=117"></a></td><td><a href=https://github.com/Gkleinereva><img width="117" alt="Gkleinereva" src="https://avatars.githubusercontent.com/u/23621633?v=4&s=117"></a></td><td><a href=https://github.com/epexa><img width="117" alt="epexa" src="https://avatars.githubusercontent.com/u/2198826?v=4&s=117"></a></td><td><a href=https://github.com/EnricoSottile><img width="117" alt="EnricoSottile" src="https://avatars.githubusercontent.com/u/10349653?v=4&s=117"></a></td><td><a href=https://github.com/elliotdickison><img width="117" alt="elliotdickison" src="https://avatars.githubusercontent.com/u/2523678?v=4&s=117"></a></td><td><a href=https://github.com/eliOcs><img width="117" alt="eliOcs" src="https://avatars.githubusercontent.com/u/1283954?v=4&s=117"></a></td></tr>
+<tr><td><a href=https://github.com/fgallinari><img width="117" alt="fgallinari" src="https://avatars.githubusercontent.com/u/6473638?v=4&s=117"></a></td><td><a href=https://github.com/Gkleinereva><img width="117" alt="Gkleinereva" src="https://avatars.githubusercontent.com/u/23621633?v=4&s=117"></a></td><td><a href=https://github.com/epexa><img width="117" alt="epexa" src="https://avatars.githubusercontent.com/u/2198826?v=4&s=117"></a></td><td><a href=https://github.com/EnricoSottile><img width="117" alt="EnricoSottile" src="https://avatars.githubusercontent.com/u/10349653?v=4&s=117"></a></td><td><a href=https://github.com/elliotdickison><img width="117" alt="elliotdickison" src="https://avatars.githubusercontent.com/u/2523678?v=4&s=117"></a></td><td><a href=https://github.com/theJoeBiz><img width="117" alt="theJoeBiz" src="https://avatars.githubusercontent.com/u/189589?v=4&s=117"></a></td></tr>
 <tr><td><a href=https://github.com/Jmales><img width="117" alt="Jmales" src="https://avatars.githubusercontent.com/u/22914881?v=4&s=117"></a></td><td><a href=https://github.com/jessica-coursera><img width="117" alt="jessica-coursera" src="https://avatars.githubusercontent.com/u/35155465?v=4&s=117"></a></td><td><a href=https://github.com/vith><img width="117" alt="vith" src="https://avatars.githubusercontent.com/u/3265539?v=4&s=117"></a></td><td><a href=https://github.com/janwilts><img width="117" alt="janwilts" src="https://avatars.githubusercontent.com/u/16721581?v=4&s=117"></a></td><td><a href=https://github.com/janklimo><img width="117" alt="janklimo" src="https://avatars.githubusercontent.com/u/7811733?v=4&s=117"></a></td><td><a href=https://github.com/jamestiotio><img width="117" alt="jamestiotio" src="https://avatars.githubusercontent.com/u/18364745?v=4&s=117"></a></td></tr>
 <tr><td><a href=https://github.com/jcjmcclean><img width="117" alt="jcjmcclean" src="https://avatars.githubusercontent.com/u/1822574?v=4&s=117"></a></td><td><a href=https://github.com/Jbithell><img width="117" alt="Jbithell" src="https://avatars.githubusercontent.com/u/8408967?v=4&s=117"></a></td><td><a href=https://github.com/JakubHaladej><img width="117" alt="JakubHaladej" src="https://avatars.githubusercontent.com/u/77832677?v=4&s=117"></a></td><td><a href=https://github.com/jakemcallister><img width="117" alt="jakemcallister" src="https://avatars.githubusercontent.com/u/1185699?v=4&s=117"></a></td><td><a href=https://github.com/gaejabong><img width="117" alt="gaejabong" src="https://avatars.githubusercontent.com/u/978944?v=4&s=117"></a></td><td><a href=https://github.com/JacobMGEvans><img width="117" alt="JacobMGEvans" src="https://avatars.githubusercontent.com/u/27247160?v=4&s=117"></a></td></tr>
-<tr><td><a href=https://github.com/mazoruss><img width="117" alt="mazoruss" src="https://avatars.githubusercontent.com/u/17625190?v=4&s=117"></a></td><td><a href=https://github.com/GreenJimmy><img width="117" alt="GreenJimmy" src="https://avatars.githubusercontent.com/u/39386?v=4&s=117"></a></td><td><a href=https://github.com/intenzive><img width="117" alt="intenzive" src="https://avatars.githubusercontent.com/u/11055931?v=4&s=117"></a></td><td><a href=https://github.com/NaxYo><img width="117" alt="NaxYo" src="https://avatars.githubusercontent.com/u/1963876?v=4&s=117"></a></td><td><a href=https://github.com/ishendyweb><img width="117" alt="ishendyweb" src="https://avatars.githubusercontent.com/u/10582418?v=4&s=117"></a></td><td><a href=https://github.com/IanVS><img width="117" alt="IanVS" src="https://avatars.githubusercontent.com/u/4616705?v=4&s=117"></a></td></tr>
+<tr><td><a href=https://github.com/mazoruss><img width="117" alt="mazoruss" src="https://avatars.githubusercontent.com/u/17625190?v=4&s=117"></a></td><td><a href=https://github.com/GreenJimmy><img width="117" alt="GreenJimmy" src="https://avatars.githubusercontent.com/u/39386?v=4&s=117"></a></td><td><a href=https://github.com/intenzive><img width="117" alt="intenzive" src="https://avatars.githubusercontent.com/u/11055931?v=4&s=117"></a></td><td><a href=https://github.com/NaxYo><img width="117" alt="NaxYo" src="https://avatars.githubusercontent.com/u/1963876?v=4&s=117"></a></td><td><a href=https://github.com/ishendyweb><img width="117" alt="ishendyweb" src="https://avatars.githubusercontent.com/u/10582418?v=4&s=117"></a></td></tr>
 <!--/contributors-->
 </table>
 

+ 14 - 11
docs/companion.md

@@ -22,8 +22,9 @@ OAuth.
 ## When should I use it?
 
 If you want to let users download files from [Box][], [Dropbox][], [Facebook][],
-[Google Drive][googledrive], [Instagram][], [OneDrive][], [Unsplash][], [Import
-from URL][url], or [Zoom][] — you need Companion.
+[Google Drive][googledrive], [Google Photos][googlephotos], [Instagram][],
+[OneDrive][], [Unsplash][], [Import from URL][url], or [Zoom][] — you need
+Companion.
 
 Companion supports the same [uploaders](/docs/guides/choosing-uploader) as Uppy:
 [Tus](/docs/tus), [AWS S3](/docs/aws-s3), and [regular multipart](/docs/tus).
@@ -432,15 +433,16 @@ the secret, nothing else.
 
 :::
 
-| Service      | Key         | Environment variables                                                                                                                                                                                                                  |
-| ------------ | ----------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
-| Box          | `box`       | `COMPANION_BOX_KEY`, `COMPANION_BOX_SECRET`, `COMPANION_BOX_SECRET_FILE`                                                                                                                                                               |
-| Dropbox      | `dropbox`   | `COMPANION_DROPBOX_KEY`, `COMPANION_DROPBOX_SECRET`, `COMPANION_DROPBOX_SECRET_FILE`                                                                                                                                                   |
-| Facebook     | `facebook`  | `COMPANION_FACEBOOK_KEY`, `COMPANION_FACEBOOK_SECRET`, `COMPANION_FACEBOOK_SECRET_FILE`                                                                                                                                                |
-| Google Drive | `drive`     | `COMPANION_GOOGLE_KEY`, `COMPANION_GOOGLE_SECRET`, `COMPANION_GOOGLE_SECRET_FILE`                                                                                                                                                      |
-| Instagram    | `instagram` | `COMPANION_INSTAGRAM_KEY`, `COMPANION_INSTAGRAM_SECRET`, `COMPANION_INSTAGRAM_SECRET_FILE`                                                                                                                                             |
-| OneDrive     | `onedrive`  | `COMPANION_ONEDRIVE_KEY`, `COMPANION_ONEDRIVE_SECRET`, `COMPANION_ONEDRIVE_SECRET_FILE`, `COMPANION_ONEDRIVE_DOMAIN_VALIDATION` (Settings this variable to `true` enables a route that can be used to validate your app with OneDrive) |
-| Zoom         | `zoom`      | `COMPANION_ZOOM_KEY`, `COMPANION_ZOOM_SECRET`, `COMPANION_ZOOM_SECRET_FILE`, `COMPANION_ZOOM_VERIFICATION_TOKEN`                                                                                                                       |
+| Service       | Key            | Environment variables                                                                                                                                                                                                                  |
+| ------------- | -------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
+| Box           | `box`          | `COMPANION_BOX_KEY`, `COMPANION_BOX_SECRET`, `COMPANION_BOX_SECRET_FILE`                                                                                                                                                               |
+| Dropbox       | `dropbox`      | `COMPANION_DROPBOX_KEY`, `COMPANION_DROPBOX_SECRET`, `COMPANION_DROPBOX_SECRET_FILE`                                                                                                                                                   |
+| Facebook      | `facebook`     | `COMPANION_FACEBOOK_KEY`, `COMPANION_FACEBOOK_SECRET`, `COMPANION_FACEBOOK_SECRET_FILE`                                                                                                                                                |
+| Google Drive  | `drive`        | `COMPANION_GOOGLE_KEY`, `COMPANION_GOOGLE_SECRET`, `COMPANION_GOOGLE_SECRET_FILE`                                                                                                                                                      |
+| Google Photos | `googlephotos` | `COMPANION_GOOGLE_KEY`, `COMPANION_GOOGLE_SECRET`, `COMPANION_GOOGLE_SECRET_FILE`                                                                                                                                                      |
+| Instagram     | `instagram`    | `COMPANION_INSTAGRAM_KEY`, `COMPANION_INSTAGRAM_SECRET`, `COMPANION_INSTAGRAM_SECRET_FILE`                                                                                                                                             |
+| OneDrive      | `onedrive`     | `COMPANION_ONEDRIVE_KEY`, `COMPANION_ONEDRIVE_SECRET`, `COMPANION_ONEDRIVE_SECRET_FILE`, `COMPANION_ONEDRIVE_DOMAIN_VALIDATION` (Settings this variable to `true` enables a route that can be used to validate your app with OneDrive) |
+| Zoom          | `zoom`         | `COMPANION_ZOOM_KEY`, `COMPANION_ZOOM_SECRET`, `COMPANION_ZOOM_SECRET_FILE`, `COMPANION_ZOOM_VERIFICATION_TOKEN`                                                                                                                       |
 
 #### `s3`
 
@@ -912,6 +914,7 @@ automatically restart when files are changed.
 [dropbox]: /docs/dropbox
 [facebook]: /docs/facebook
 [googledrive]: /docs/google-drive
+[googlephotos]: /docs/google-photos
 [instagram]: /docs/instagram
 [onedrive]: /docs/onedrive
 [unsplash]: /docs/unsplash

+ 9 - 8
docs/guides/migration-guides.md

@@ -829,14 +829,15 @@ to:
 
 <div class="table-responsive">
 
-| Provider     | New Redirect URI                                  |
-| ------------ | ------------------------------------------------- |
-| Dropbox      | `https://$COMPANION_HOST_NAME/dropbox/redirect`   |
-| Google Drive | `https://$COMPANION_HOST_NAME/drive/redirect`     |
-| OneDrive     | `https://$COMPANION_HOST_NAME/onedrive/redirect`  |
-| Box          | `https://$YOUR_COMPANION_HOST_NAME/box/redirect`  |
-| Facebook     | `https://$COMPANION_HOST_NAME/facebook/redirect`  |
-| Instagram    | `https://$COMPANION_HOST_NAME/instagram/redirect` |
+| Provider      | New Redirect URI                                     |
+| ------------- | ---------------------------------------------------- |
+| Dropbox       | `https://$COMPANION_HOST_NAME/dropbox/redirect`      |
+| Google Drive  | `https://$COMPANION_HOST_NAME/drive/redirect`        |
+| Google Photos | `https://$COMPANION_HOST_NAME/googlephotos/redirect` |
+| OneDrive      | `https://$COMPANION_HOST_NAME/onedrive/redirect`     |
+| Box           | `https://$YOUR_COMPANION_HOST_NAME/box/redirect`     |
+| Facebook      | `https://$COMPANION_HOST_NAME/facebook/redirect`     |
+| Instagram     | `https://$COMPANION_HOST_NAME/instagram/redirect`    |
 
 </div>
 

+ 8 - 8
docs/sources/companion-plugins/google-drive.mdx

@@ -10,7 +10,7 @@ import UppyCdnExample from '/src/components/UppyCdnExample';
 # Google Drive
 
 The `@uppy/google-drive` plugin lets users import files from their
-[Google Drive](https://www.drive.google.com) account.
+[Google Drive](https://drive.google.com) account.
 
 :::tip
 
@@ -22,7 +22,7 @@ The `@uppy/google-drive` plugin lets users import files from their
 ## When should I use this?
 
 When you want to let users import files from their
-[Google Drive](https://www.drive.google.com) account.
+[Google Drive](https://drive.google.com) account.
 
 A [Companion](/docs/companion) instance is required for the Google Drive plugin
 to work. Companion handles authentication with Google Drive, downloads the
@@ -112,12 +112,12 @@ https://api2.transloadit.com/companion/drive/redirect
 
 Google will give you an OAuth client ID and client secret.
 
-Configure the Google Drive key and secret in Companion. With the standalone
-Companion server, specify environment variables:
+Configure the Google key and secret in Companion. With the standalone Companion
+server, specify environment variables:
 
 ```shell
-export COMPANION_GOOGLE_KEY="Google Drive OAuth client ID"
-export COMPANION_GOOGLE_SECRET="Google Drive OAuth client secret"
+export COMPANION_GOOGLE_KEY="Google OAuth client ID"
+export COMPANION_GOOGLE_SECRET="Google OAuth client secret"
 ```
 
 When using the Companion Node.js API, configure these options:
@@ -126,8 +126,8 @@ When using the Companion Node.js API, configure these options:
 companion.app({
 	providerOptions: {
 		drive: {
-			key: 'Google Drive OAuth client ID',
-			secret: 'Google Drive OAuth client secret',
+			key: 'Google OAuth client ID',
+			secret: 'Google OAuth client secret',
 		},
 	},
 });

+ 185 - 0
docs/sources/companion-plugins/google-photos.mdx

@@ -0,0 +1,185 @@
+---
+slug: /google-photos
+---
+
+import Tabs from '@theme/Tabs';
+import TabItem from '@theme/TabItem';
+
+import UppyCdnExample from '/src/components/UppyCdnExample';
+
+# Google Photos
+
+The `@uppy/google-photos` plugin lets users import files from their
+[Google Photos](https://photos.google.com) account.
+
+:::tip
+
+[Try out the live example](/examples) or take it for a spin in
+[StackBlitz](https://stackblitz.com/edit/vitejs-vite-zaqyaf?file=main.js).
+
+:::
+
+## When should I use this?
+
+When you want to let users import files from their
+[Google Photos](https://photos.google.com) account.
+
+A [Companion](/docs/companion) instance is required for the Google Photos plugin
+to work. Companion handles authentication with Google Photos, downloads the
+photos/videos, and uploads them to the destination. This saves the user
+bandwidth, especially helpful if they are on a mobile connection.
+
+You can self-host Companion or get a hosted version with any
+[Transloadit plan](https://transloadit.com/pricing/).
+
+<Tabs>
+  <TabItem value="npm" label="NPM" default>
+
+```shell
+npm install @uppy/google-photos
+```
+
+  </TabItem>
+
+  <TabItem value="yarn" label="Yarn">
+
+```shell
+yarn add @uppy/google-photos
+```
+
+  </TabItem>
+
+  <TabItem value="cdn" label="CDN">
+    <UppyCdnExample>
+      {`
+        import { Uppy, GooglePhotos } from "{{UPPY_JS_URL}}"
+        const uppy = new Uppy()
+        uppy.use(GooglePhotos, {
+          // Options
+        })
+      `}
+    </UppyCdnExample>
+  </TabItem>
+</Tabs>
+
+## Use
+
+Using Google Photos requires setup in both Uppy and Companion.
+
+### Use in Uppy
+
+```js {10-13} showLineNumbers
+import Uppy from '@uppy/core';
+import Dashboard from '@uppy/dashboard';
+import GooglePhotos from '@uppy/google-photos';
+
+import '@uppy/core/dist/style.min.css';
+import '@uppy/dashboard/dist/style.min.css';
+
+new Uppy()
+	.use(Dashboard, { inline: true, target: '#dashboard' })
+	.use(GooglePhotos, {
+		target: Dashboard,
+		companionUrl: 'https://your-companion.com',
+	});
+```
+
+### Use in Companion
+
+To sign up for API keys, go to the
+[Google Developer Console](https://console.developers.google.com/).
+
+Create a project for your app if you don’t have one yet.
+
+- On the project’s dashboard,
+  [enable the Google Photos API](https://developers.google.com/photos).
+- [Set up OAuth authorization](https://developers.google.com/photos/library/guides/authorization).
+
+The app page has a `"Redirect URIs"` field. Here, add:
+
+```
+https://$YOUR_COMPANION_HOST_NAME/googlephotos/redirect
+```
+
+If you are using Transloadit hosted Companion:
+
+```
+https://api2.transloadit.com/companion/googlephotos/redirect
+```
+
+Google will give you an OAuth client ID and client secret.
+
+Configure the Google key and secret in Companion. With the standalone Companion
+server, specify environment variables:
+
+```shell
+export COMPANION_GOOGLE_KEY="Google OAuth client ID"
+export COMPANION_GOOGLE_SECRET="Google OAuth client secret"
+```
+
+When using the Companion Node.js API, configure these options:
+
+```js
+companion.app({
+	providerOptions: {
+		googlephotos: {
+			key: 'Google OAuth client ID',
+			secret: 'Google OAuth client secret',
+		},
+	},
+});
+```
+
+## API
+
+### Options
+
+#### `id`
+
+A unique identifier for this plugin (`string`, default: `'GooglePhotos'`).
+
+#### `title`
+
+Title / name shown in the UI, such as Dashboard tabs (`string`, default:
+`'GooglePhotos'`).
+
+#### `target`
+
+DOM element, CSS selector, or plugin to place the drag and drop area into
+(`string` or `Element`, default: `null`).
+
+#### `companionUrl`
+
+URL to a [Companion](/docs/companion) instance (`string`, default: `null`).
+
+#### `companionHeaders`
+
+Custom headers that should be sent along to [Companion](/docs/companion) on
+every request (`Object`, default: `{}`).
+
+#### `companionAllowedHosts`
+
+The valid and authorised URL(s) from which OAuth responses should be accepted
+(`string` or `RegExp` or `Array`, default: `companionUrl`).
+
+This value can be a `string`, a `RegExp` pattern, or an `Array` of both. This is
+useful when you have your [Companion](/docs/companion) running on several hosts.
+Otherwise, the default value should do fine.
+
+#### `companionCookiesRule`
+
+This option correlates to the
+[RequestCredentials value](https://developer.mozilla.org/en-US/docs/Web/API/Request/credentials)
+(`string`, default: `'same-origin'`).
+
+This tells the plugin whether to send cookies to [Companion](/docs/companion).
+
+#### `locale`
+
+```js
+export default {
+	strings: {
+		pluginNameGooglePhotos: 'GooglePhotos',
+	},
+};
+```

+ 1 - 1
docs/sources/companion-plugins/instagram.mdx

@@ -115,7 +115,7 @@ When using the Companion Node.js API, configure these options:
 ```js
 companion.app({
 	providerOptions: {
-		drive: {
+		instagram: {
 			key: 'Instagram OAuth client ID',
 			secret: 'Instagram OAuth client secret',
 		},

+ 2 - 0
docs/user-interfaces/dashboard.mdx

@@ -714,6 +714,8 @@ all Uppy plugins.
   [Facebook](https://facebook.com).
 - [`@uppy/google-drive`](/docs/google-drive) — import from
   [Google Drive](https://drive.google.com).
+- [`@uppy/google-photos`](/docs/google-photos) — import from
+  [Google Photos](https://photos.google.com).
 - [`@uppy/instagram`](/docs/instagram) — import from
   [Instagram](https://instagram.com).
 - [`@uppy/onedrive`](/docs/onedrive) — import from

+ 1 - 1
e2e/cypress/integration/dashboard-transloadit.spec.ts

@@ -258,7 +258,7 @@ describe('Dashboard with Transloadit', () => {
         client_ip: null,
         client_referer: null,
         transloadit_client:
-          'uppy-core:3.2.0,uppy-transloadit:3.1.3,uppy-tus:3.1.0,uppy-dropbox:3.1.1,uppy-box:2.1.1,uppy-facebook:3.1.1,uppy-google-drive:3.1.1,uppy-instagram:3.1.1,uppy-onedrive:3.1.1,uppy-zoom:2.1.1,uppy-url:3.3.1',
+          'uppy-core:3.2.0,uppy-transloadit:3.1.3,uppy-tus:3.1.0,uppy-dropbox:3.1.1,uppy-box:2.1.1,uppy-facebook:3.1.1,uppy-google-drive:3.1.1,uppy-google-photos:0.0.1,uppy-instagram:3.1.1,uppy-onedrive:3.1.1,uppy-zoom:2.1.1,uppy-url:3.3.1',
         start_date: new Date().toISOString(),
         upload_meta_data_extracted: false,
         warnings: [],

+ 1 - 0
e2e/package.json

@@ -25,6 +25,7 @@
     "@uppy/form": "workspace:^",
     "@uppy/golden-retriever": "workspace:^",
     "@uppy/google-drive": "workspace:^",
+    "@uppy/google-photos": "workspace:^",
     "@uppy/image-editor": "workspace:^",
     "@uppy/informer": "workspace:^",
     "@uppy/instagram": "workspace:^",

+ 1 - 0
examples/angular-example/package.json

@@ -23,6 +23,7 @@
     "@uppy/core": "workspace:*",
     "@uppy/drag-drop": "workspace:*",
     "@uppy/google-drive": "workspace:*",
+    "@uppy/google-photos": "workspace:*",
     "@uppy/progress-bar": "workspace:*",
     "@uppy/tus": "workspace:*",
     "@uppy/webcam": "workspace:*",

+ 19 - 22
examples/aws-nodejs/index.js

@@ -227,32 +227,29 @@ app.get('/s3/multipart/:uploadId', (req, res, next) => {
 
   const parts = []
 
-  function listPartsPage(startAt) {
-    client.send(
-      new ListPartsCommand({
-        Bucket: process.env.COMPANION_AWS_BUCKET,
-        Key: key,
-        UploadId: uploadId,
-        PartNumberMarker: startAt,
-      }),
-      (err, data) => {
-        if (err) {
-          next(err)
-          return
-        }
+  function listPartsPage(startsAt = undefined) {
+    client.send(new ListPartsCommand({
+      Bucket: process.env.COMPANION_AWS_BUCKET,
+      Key: key,
+      UploadId: uploadId,
+      PartNumberMarker: startsAt,
+    }), (err, data) => {
+      if (err) {
+        next(err)
+        return
+      }
 
         parts.push(...data.Parts)
 
-        if (data.IsTruncated) {
-          // Get the next page.
-          listPartsPage(data.NextPartNumberMarker)
-        } else {
-          res.json(parts)
-        }
-      },
-    )
+      // continue to get list of all uploaded parts until the IsTruncated flag is false
+      if (data.IsTruncated) {
+        listPartsPage(data.NextPartNumberMarker)
+      } else {
+        res.json(parts)
+      }
+    })
   }
-  listPartsPage(0)
+  listPartsPage()
 })
 
 function isValidPart(part) {

+ 1 - 0
packages/@uppy/box/src/Box.tsx

@@ -85,6 +85,7 @@ export default class Box<M extends Meta, B extends Body> extends UIPlugin<
     this.view = new ProviderViews(this, {
       provider: this.provider,
       loadAllFiles: true,
+      virtualList: true,
     })
 
     const { target } = this.opts

+ 8 - 0
packages/@uppy/companion/CHANGELOG.md

@@ -66,6 +66,14 @@ Included in: Uppy v4.0.0-beta.1
 - @uppy/companion: improve error msg (Mikael Finstad / #5010)
 - @uppy/companion: crash if trying to set path to / (Mikael Finstad / #5003)
 
+## 4.14.0
+
+Released: 2024-06-18
+Included in: Uppy v3.27.0
+
+- @uppy/google-photos: add plugin (Mikael Finstad / #5061)
+- @uppy/companion: Bump ws from 8.8.1 to 8.17.1 (#5256) 
+
 ## 4.13.3
 
 Released: 2024-05-22

+ 1 - 1
packages/@uppy/companion/src/server/provider/drive/index.js

@@ -9,7 +9,7 @@ const { ProviderAuthError } = require('../error')
 const got = require('../../got')
 
 // For testing refresh token:
-// first run a download with mockAccessTokenExpiredError = true 
+// first run a download with mockAccessTokenExpiredError = true
 // then when you want to test expiry, set to mockAccessTokenExpiredError to the logged access token
 // This will trigger companion/nodemon to restart, and it will respond with a simulated invalid token response
 const mockAccessTokenExpiredError = undefined

+ 190 - 0
packages/@uppy/companion/src/server/provider/google/drive/adapter.js

@@ -0,0 +1,190 @@
+const querystring = require('node:querystring')
+
+const getUsername = (data) => {
+  return data.user.emailAddress
+}
+
+exports.isGsuiteFile = (mimeType) => {
+  return mimeType && mimeType.startsWith('application/vnd.google')
+}
+
+const isSharedDrive = (item) => {
+  return item.kind === 'drive#drive'
+}
+
+const isFolder = (item) => {
+  return item.mimeType === 'application/vnd.google-apps.folder' || isSharedDrive(item)
+}
+
+exports.isShortcut = (mimeType) => {
+  return mimeType === 'application/vnd.google-apps.shortcut'
+}
+
+const getItemSize = (item) => {
+  return parseInt(item.size, 10)
+}
+
+const getItemIcon = (item) => {
+  if (isSharedDrive(item)) {
+    const size = '=w16-h16-n'
+    const sizeParamRegex = /=[-whncsp0-9]*$/
+    return item.backgroundImageLink.match(sizeParamRegex)
+      ? item.backgroundImageLink.replace(sizeParamRegex, size)
+      : `${item.backgroundImageLink}${size}`
+  }
+
+  if (item.thumbnailLink && !item.mimeType.startsWith('application/vnd.google')) {
+    const smallerThumbnailLink = item.thumbnailLink.replace('s220', 's40')
+    return smallerThumbnailLink
+  }
+
+  return item.iconLink
+}
+
+const getItemSubList = (item) => {
+  const allowedGSuiteTypes = [
+    'application/vnd.google-apps.document',
+    'application/vnd.google-apps.drawing',
+    'application/vnd.google-apps.script',
+    'application/vnd.google-apps.spreadsheet',
+    'application/vnd.google-apps.presentation',
+    'application/vnd.google-apps.shortcut',
+  ]
+
+  return item.files.filter((i) => {
+    return isFolder(i) || !exports.isGsuiteFile(i.mimeType) || allowedGSuiteTypes.includes(i.mimeType)
+  })
+}
+
+const getItemName = (item) => {
+  const extensionMaps = {
+    'application/vnd.google-apps.document': '.docx',
+    'application/vnd.google-apps.drawing': '.png',
+    'application/vnd.google-apps.script': '.json',
+    'application/vnd.google-apps.spreadsheet': '.xlsx',
+    'application/vnd.google-apps.presentation': '.ppt',
+  }
+
+  const extension = extensionMaps[item.mimeType]
+  if (extension && item.name && !item.name.endsWith(extension)) {
+    return item.name + extension
+  }
+
+  return item.name ? item.name : '/'
+}
+
+exports.getGsuiteExportType = (mimeType) => {
+  const typeMaps = {
+    'application/vnd.google-apps.document': 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
+    'application/vnd.google-apps.drawing': 'image/png',
+    'application/vnd.google-apps.script': 'application/vnd.google-apps.script+json',
+    'application/vnd.google-apps.spreadsheet': 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
+    'application/vnd.google-apps.presentation': 'application/vnd.openxmlformats-officedocument.presentationml.presentation',
+  }
+
+  return typeMaps[mimeType] || 'application/pdf'
+}
+
+function getMimeType2 (mimeType) {
+  if (exports.isGsuiteFile(mimeType)) {
+    return exports.getGsuiteExportType(mimeType)
+  }
+  return mimeType
+}
+
+const getMimeType = (item) => {
+  if (exports.isShortcut(item.mimeType)) {
+    return getMimeType2(item.shortcutDetails.targetMimeType)
+  }
+  return getMimeType2(item.mimeType)
+}
+
+const getItemId = (item) => {
+  return item.id
+}
+
+const getItemRequestPath = (item) => {
+  return item.id
+}
+
+const getItemModifiedDate = (item) => {
+  return item.modifiedTime
+}
+
+const getItemThumbnailUrl = (item) => {
+  return item.thumbnailLink
+}
+
+const getNextPagePath = (data, currentQuery, currentPath) => {
+  if (!data.nextPageToken) {
+    return null
+  }
+  const query = { ...currentQuery, cursor: data.nextPageToken }
+  return `${currentPath}?${querystring.stringify(query)}`
+}
+
+const getImageHeight = (item) => item.imageMediaMetadata && item.imageMediaMetadata.height
+
+const getImageWidth = (item) => item.imageMediaMetadata && item.imageMediaMetadata.width
+
+const getImageRotation = (item) => item.imageMediaMetadata && item.imageMediaMetadata.rotation
+
+const getImageDate = (item) => item.imageMediaMetadata && item.imageMediaMetadata.date
+
+const getVideoHeight = (item) => item.videoMediaMetadata && item.videoMediaMetadata.height
+
+const getVideoWidth = (item) => item.videoMediaMetadata && item.videoMediaMetadata.width
+
+const getVideoDurationMillis = (item) => item.videoMediaMetadata && item.videoMediaMetadata.durationMillis
+
+// Hopefully this name will not be used by Google
+exports.VIRTUAL_SHARED_DIR = 'shared-with-me'
+
+exports.adaptData = (listFilesResp, sharedDrivesResp, directory, query, showSharedWithMe, about) => {
+  const adaptItem = (item) => ({
+    isFolder: isFolder(item),
+    icon: getItemIcon(item),
+    name: getItemName(item),
+    mimeType: getMimeType(item),
+    id: getItemId(item),
+    thumbnail: getItemThumbnailUrl(item),
+    requestPath: getItemRequestPath(item),
+    modifiedDate: getItemModifiedDate(item),
+    size: getItemSize(item),
+    custom: {
+      isSharedDrive: isSharedDrive(item),
+      imageHeight: getImageHeight(item),
+      imageWidth: getImageWidth(item),
+      imageRotation: getImageRotation(item),
+      imageDateTime: getImageDate(item),
+      videoHeight: getVideoHeight(item),
+      videoWidth: getVideoWidth(item),
+      videoDurationMillis: getVideoDurationMillis(item),
+    },
+  })
+
+  const items = getItemSubList(listFilesResp)
+  const sharedDrives = sharedDrivesResp ? sharedDrivesResp.drives || [] : []
+
+  // “Shared with me” is a list of shared documents,
+  // not the same as sharedDrives
+  const virtualItem = showSharedWithMe && ({
+    isFolder: true,
+    icon: 'folder',
+    name: 'Shared with me',
+    mimeType: 'application/vnd.google-apps.folder',
+    id: exports.VIRTUAL_SHARED_DIR,
+    requestPath: exports.VIRTUAL_SHARED_DIR,
+  })
+
+  const adaptedItems = [
+    ...(virtualItem ? [virtualItem] : []), // shared folder first
+    ...([...sharedDrives, ...items].map(adaptItem)),
+  ]
+
+  return {
+    username: getUsername(about),
+    items: adaptedItems,
+    nextPagePath: getNextPagePath(listFilesResp, query, directory),
+  }
+}

+ 199 - 0
packages/@uppy/companion/src/server/provider/google/drive/index.js

@@ -0,0 +1,199 @@
+const got = require('got').default
+
+const { logout, refreshToken } = require('../index')
+const logger = require('../../../logger')
+const { VIRTUAL_SHARED_DIR, adaptData, isShortcut, isGsuiteFile, getGsuiteExportType } = require('./adapter')
+const { prepareStream } = require('../../../helpers/utils')
+const { MAX_AGE_REFRESH_TOKEN } = require('../../../helpers/jwt')
+const { ProviderAuthError } = require('../../error')
+const { withGoogleErrorHandling } = require('../../providerErrors')
+const Provider = require('../../Provider')
+
+
+// For testing refresh token:
+// first run a download with mockAccessTokenExpiredError = true 
+// then when you want to test expiry, set to mockAccessTokenExpiredError to the logged access token
+// This will trigger companion/nodemon to restart, and it will respond with a simulated invalid token response
+const mockAccessTokenExpiredError = undefined
+// const mockAccessTokenExpiredError = true
+// const mockAccessTokenExpiredError = ''
+
+const DRIVE_FILE_FIELDS = 'kind,id,imageMediaMetadata,name,mimeType,ownedByMe,size,modifiedTime,iconLink,thumbnailLink,teamDriveId,videoMediaMetadata,exportLinks,shortcutDetails(targetId,targetMimeType)'
+const DRIVE_FILES_FIELDS = `kind,nextPageToken,incompleteSearch,files(${DRIVE_FILE_FIELDS})`
+// using wildcard to get all 'drive' fields because specifying fields seems no to work for the /drives endpoint
+const SHARED_DRIVE_FIELDS = '*'
+
+const getClient = ({ token }) => got.extend({
+  prefixUrl: 'https://www.googleapis.com/drive/v3',
+  headers: {
+    authorization: `Bearer ${token}`,
+  },
+})
+
+async function getStats ({ id, token }) {
+  const client = getClient({ token })
+
+  const getStatsInner = async (statsOfId) => (
+    client.get(`files/${encodeURIComponent(statsOfId)}`, { searchParams: { fields: DRIVE_FILE_FIELDS, supportsAllDrives: true }, responseType: 'json' }).json()
+  )
+
+  const stats = await getStatsInner(id)
+
+  // If it is a shortcut, we need to get stats again on the target
+  if (isShortcut(stats.mimeType)) return getStatsInner(stats.shortcutDetails.targetId)
+  return stats
+}
+
+/**
+ * Adapter for API https://developers.google.com/drive/api/v3/
+ */
+class Drive extends Provider {
+  static get authProvider () {
+    return 'googledrive'
+  }
+
+  static get authStateExpiry () {
+    return MAX_AGE_REFRESH_TOKEN
+  }
+
+  // eslint-disable-next-line class-methods-use-this
+  async list (options) {
+    return withGoogleErrorHandling(Drive.authProvider, 'provider.drive.list.error', async () => {
+      const directory = options.directory || 'root'
+      const query = options.query || {}
+      const { token } = options
+
+      const isRoot = directory === 'root'
+      const isVirtualSharedDirRoot = directory === VIRTUAL_SHARED_DIR
+
+      const client = getClient({ token })
+
+      async function fetchSharedDrives (pageToken = null) {
+        const shouldListSharedDrives = isRoot && !query.cursor
+        if (!shouldListSharedDrives) return undefined
+
+        const response = await client.get('drives', { searchParams: { fields: SHARED_DRIVE_FIELDS, pageToken, pageSize: 100 }, responseType: 'json' }).json()
+
+        const { nextPageToken } = response
+        if (nextPageToken) {
+          const nextResponse = await fetchSharedDrives(nextPageToken)
+          if (!nextResponse) return response
+          return { ...nextResponse, drives: [...response.drives, ...nextResponse.drives] }
+        }
+
+        return response
+      }
+
+      async function fetchFiles () {
+        // Shared with me items in root don't have any parents
+        const q = isVirtualSharedDirRoot
+          ? `sharedWithMe and trashed=false`
+          : `('${directory}' in parents) and trashed=false`
+
+        const searchParams = {
+          fields: DRIVE_FILES_FIELDS,
+          pageToken: query.cursor,
+          q,
+          // We can only do a page size of 1000 because we do not request permissions in DRIVE_FILES_FIELDS.
+          // Otherwise we are limited to 100. Instead we get the user info from `this.user()`
+          pageSize: 1000,
+          orderBy: 'folder,name',
+          includeItemsFromAllDrives: true,
+          supportsAllDrives: true,
+        }
+
+        return client.get('files', { searchParams, responseType: 'json' }).json()
+      }
+
+      async function fetchAbout () {
+        const searchParams = { fields: 'user' }
+
+        return client.get('about', { searchParams, responseType: 'json' }).json()
+      }
+
+      const [sharedDrives, filesResponse, about] = await Promise.all([fetchSharedDrives(), fetchFiles(), fetchAbout()])
+
+      return adaptData(
+        filesResponse,
+        sharedDrives,
+        directory,
+        query,
+        isRoot && !query.cursor, // we can only show it on the first page request, or else we will have duplicates of it
+        about,
+      )
+    })
+  }
+
+  // eslint-disable-next-line class-methods-use-this
+  async download ({ id: idIn, token }) {
+    if (mockAccessTokenExpiredError != null) {
+      logger.warn(`Access token: ${token}`)
+
+      if (mockAccessTokenExpiredError === token) {
+        logger.warn('Mocking expired access token!')
+        throw new ProviderAuthError()
+      }
+    }
+
+    return withGoogleErrorHandling(Drive.authProvider, 'provider.drive.download.error', async () => {
+      const client = getClient({ token })
+
+      const { mimeType, id, exportLinks } = await getStats({ id: idIn, token })
+
+      let stream
+
+      if (isGsuiteFile(mimeType)) {
+        const mimeType2 = getGsuiteExportType(mimeType)
+        logger.info(`calling google file export for ${id} to ${mimeType2}`, 'provider.drive.export')
+
+        // GSuite files exported with large converted size results in error using standard export method.
+        // Error message: "This file is too large to be exported.".
+        // Issue logged in Google APIs: https://github.com/googleapis/google-api-nodejs-client/issues/3446
+        // Implemented based on the answer from StackOverflow: https://stackoverflow.com/a/59168288
+        const mimeTypeExportLink = exportLinks?.[mimeType2]
+        if (mimeTypeExportLink) {
+          const gSuiteFilesClient = got.extend({
+            headers: {
+              authorization: `Bearer ${token}`,
+            },
+          })
+          stream = gSuiteFilesClient.stream.get(mimeTypeExportLink, { responseType: 'json' })
+        } else {
+          stream = client.stream.get(`files/${encodeURIComponent(id)}/export`, { searchParams: { supportsAllDrives: true, mimeType: mimeType2 }, responseType: 'json' })
+        }
+      } else {
+        stream = client.stream.get(`files/${encodeURIComponent(id)}`, { searchParams: { alt: 'media', supportsAllDrives: true }, responseType: 'json' })
+      }
+
+      await prepareStream(stream)
+      return { stream }
+    })
+  }
+
+  // eslint-disable-next-line class-methods-use-this
+  async size ({ id, token }) {
+    return withGoogleErrorHandling(Drive.authProvider, 'provider.drive.size.error', async () => {
+      const { mimeType, size } = await getStats({ id, token })
+
+      if (isGsuiteFile(mimeType)) {
+        // GSuite file sizes cannot be predetermined (but are max 10MB)
+        // e.g. Transfer-Encoding: chunked
+        return undefined
+      }
+
+      return parseInt(size, 10)
+    })
+  }
+
+  // eslint-disable-next-line class-methods-use-this
+  async logout(...args) {
+    return logout(...args)
+  }
+
+  // eslint-disable-next-line class-methods-use-this
+}
+
+Drive.prototype.logout = logout
+Drive.prototype.refreshToken = refreshToken
+
+module.exports = Drive

+ 172 - 0
packages/@uppy/companion/src/server/provider/google/googlephotos/index.js

@@ -0,0 +1,172 @@
+const got = require('got').default
+
+const { logout, refreshToken } = require('../index')
+const { withGoogleErrorHandling } = require('../../providerErrors')
+const { prepareStream } = require('../../../helpers/utils')
+const { MAX_AGE_REFRESH_TOKEN } = require('../../../helpers/jwt')
+const logger = require('../../../logger')
+const Provider = require('../../Provider')
+
+
+const getBaseClient = ({ token }) => got.extend({
+  headers: {
+    authorization: `Bearer ${token}`,
+  },
+})
+
+const getPhotosClient = ({ token }) => getBaseClient({ token }).extend({
+  prefixUrl: 'https://photoslibrary.googleapis.com/v1',
+})
+
+const getOauthClient = ({ token }) => getBaseClient({ token }).extend({
+  prefixUrl: 'https://www.googleapis.com/oauth2/v1',
+})
+
+async function paginate(fn, getter, limit = 5) {
+  const items = []
+  let pageToken
+
+  for (let i = 0; (i === 0 || pageToken != null); i++) {
+    if (i >= limit) {
+      logger.warn(`Hit pagination limit of ${limit}`)
+      break;
+    }
+    const response = await fn(pageToken);
+    items.push(...getter(response));
+    pageToken = response.nextPageToken
+  }
+  return items
+}
+
+/**
+ * Provider for Google Photos API
+ */
+class GooglePhotos extends Provider {
+  static get authProvider () {
+    return 'googlephotos'
+  }
+
+  static get authStateExpiry () {
+    return MAX_AGE_REFRESH_TOKEN
+  }
+
+  // eslint-disable-next-line class-methods-use-this
+  async list (options) {
+    return withGoogleErrorHandling(GooglePhotos.authProvider, 'provider.photos.list.error', async () => {
+      const { directory, query } = options
+      const { token } = options
+
+      const isRoot = !directory
+
+      const client = getPhotosClient({ token })
+
+
+      async function fetchAlbums () {
+        if (!isRoot) return [] // albums are only in the root
+
+        return paginate(
+          (pageToken) => client.get('albums', { searchParams: { pageToken, pageSize: 50 }, responseType: 'json' }).json(),
+          (response) => response.albums,
+        )
+      }
+
+      async function fetchSharedAlbums () {
+        if (!isRoot) return [] // albums are only in the root
+
+        return paginate(
+          (pageToken) => client.get('sharedAlbums', { searchParams: { pageToken, pageSize: 50 }, responseType: 'json' }).json(),
+          (response) => response.sharedAlbums ?? [], // seems to be undefined if no shared albums
+        )
+      }
+
+      async function fetchMediaItems () {
+        if (isRoot) return { mediaItems: [] } // no images in root (album list only)
+        const resp = await client.post('mediaItems:search', { json: { pageToken: query?.cursor, albumId: directory, pageSize: 50 }, responseType: 'json' }).json();
+        return resp
+      }
+
+      const [sharedAlbums, albums, { mediaItems, nextPageToken }] = await Promise.all([
+        fetchSharedAlbums(), fetchAlbums(), fetchMediaItems()
+      ])
+
+      const newSp = new URLSearchParams(Object.entries(query));
+      if (nextPageToken) newSp.set('cursor', nextPageToken);
+
+      const iconSize = 64
+      const thumbSize = 300
+      const getIcon = (baseUrl) => `${baseUrl}=w${iconSize}-h${iconSize}-c`
+      const getThumbnail = (baseUrl) => `${baseUrl}=w${thumbSize}-h${thumbSize}-c`
+      const adaptedItems = [
+        ...albums.map((album) => ({
+          isFolder: true,
+          icon: 'https://drive-thirdparty.googleusercontent.com/32/type/application/vnd.google-apps.folder',
+          mimeType: 'application/vnd.google-apps.folder',
+          thumbnail: getThumbnail(album.coverPhotoBaseUrl),
+          name: album.title,
+          id: album.id,
+          requestPath: album.id,
+        })),
+        ...sharedAlbums.map((sharedAlbum) => ({
+          isFolder: true,
+          icon: 'https://drive-thirdparty.googleusercontent.com/32/type/application/vnd.google-apps.folder',
+          mimeType: 'application/vnd.google-apps.folder',
+          thumbnail: getThumbnail(sharedAlbum.coverPhotoBaseUrl),
+          name: sharedAlbum.title,
+          id: sharedAlbum.id,
+          requestPath: sharedAlbum.id,
+        })),
+        ...mediaItems.map((mediaItem) => ({
+          isFolder: false,
+          icon: getIcon(mediaItem.baseUrl),
+          thumbnail: getThumbnail(mediaItem.baseUrl),
+          name: mediaItem.filename,
+          id: mediaItem.id,
+          mimeType: mediaItem.mimeType,
+          modifiedDate: mediaItem.creationTime,
+          requestPath: mediaItem.id,
+          custom: {
+            imageWidth: mediaItem.photo ? mediaItem.width : undefined,
+            imageHeight: mediaItem.photo ? mediaItem.height : undefined,
+            videoWidth: mediaItem.video ? mediaItem.width : undefined,
+            videoHeight: mediaItem.video ? mediaItem.height : undefined,
+          },
+        })),
+      ];
+
+      const { email: username } = await getOauthClient({ token }).get('userinfo').json()
+
+      return {
+        username,
+        items: adaptedItems,
+        nextPagePath: newSp.size > 0 ? `${directory ?? ''}?${newSp.toString()}` : null,
+      }
+    })
+  }
+
+  // eslint-disable-next-line class-methods-use-this
+  async download ({ id, token }) {
+    return withGoogleErrorHandling(GooglePhotos.authProvider, 'provider.photos.download.error', async () => {
+      const client = getPhotosClient({ token })
+
+      const { baseUrl } = await client.get(`mediaItems/${encodeURIComponent(id)}`, { responseType: 'json' }).json()
+
+      const url = `${baseUrl}=d`;
+      const stream = got.stream.get(url, { responseType: 'json' })
+      const { size } = await prepareStream(stream)
+
+      return { stream, size }
+    })
+  }
+
+  // eslint-disable-next-line class-methods-use-this
+  async logout(...args) {
+    return logout(...args)
+  }
+
+  // eslint-disable-next-line class-methods-use-this
+  async refreshToken(...args) {
+    return refreshToken(...args)
+  }
+}
+
+module.exports = GooglePhotos

+ 36 - 0
packages/@uppy/companion/src/server/provider/google/index.js

@@ -0,0 +1,36 @@
+const got = require('got').default
+
+
+const { withGoogleErrorHandling } = require('../providerErrors')
+
+
+/**
+ * Reusable google stuff
+ */
+
+const getOauthClient = () => got.extend({
+  prefixUrl: 'https://oauth2.googleapis.com',
+})
+
+async function refreshToken({ clientId, clientSecret, refreshToken: theRefreshToken }) {
+  return withGoogleErrorHandling('google', 'provider.google.token.refresh.error', async () => {
+    const { access_token: accessToken } = await getOauthClient().post('token', { responseType: 'json', form: { refresh_token: theRefreshToken, grant_type: 'refresh_token', client_id: clientId, client_secret: clientSecret } }).json()
+    return { accessToken }
+  })
+}
+
+async function logout({ token }) {
+  return withGoogleErrorHandling('google', 'provider.google.logout.error', async () => {
+    await got.post('https://accounts.google.com/o/oauth2/revoke', {
+      searchParams: { token },
+      responseType: 'json',
+    })
+
+    return { revoked: true }
+  })
+}
+
+module.exports = {
+  refreshToken,
+  logout,
+}

+ 1 - 0
packages/@uppy/core/src/locale.ts

@@ -62,5 +62,6 @@ export default {
     },
     additionalRestrictionsFailed:
       '%{count} additional restrictions were not fulfilled',
+    unnamed: 'Unnamed',
   },
 }

+ 3 - 4
packages/@uppy/dashboard/src/utils/copyToClipboard.ts

@@ -34,8 +34,7 @@ export default function copyToClipboard(
     document.body.appendChild(textArea)
     textArea.select()
 
-    // eslint-disable-next-line @typescript-eslint/no-unused-vars
-    const magicCopyFailed = (cause?: unknown) => {
+    const magicCopyFailed = () => {
       document.body.removeChild(textArea)
       // eslint-disable-next-line no-alert
       window.prompt(fallbackString, textToCopy)
@@ -45,13 +44,13 @@ export default function copyToClipboard(
     try {
       const successful = document.execCommand('copy')
       if (!successful) {
-        return magicCopyFailed('copy command unavailable')
+        return magicCopyFailed()
       }
       document.body.removeChild(textArea)
       return resolve()
     } catch (err) {
       document.body.removeChild(textArea)
-      return magicCopyFailed(err)
+      return magicCopyFailed()
     }
   })
 }

+ 1 - 0
packages/@uppy/dropbox/src/Dropbox.tsx

@@ -86,6 +86,7 @@ export default class Dropbox<M extends Meta, B extends Body> extends UIPlugin<
     this.view = new ProviderViews(this, {
       provider: this.provider,
       loadAllFiles: true,
+      virtualList: true,
     })
 
     const { target } = this.opts

+ 1 - 0
packages/@uppy/google-drive/src/GoogleDrive.tsx

@@ -105,6 +105,7 @@ export default class GoogleDrive<
     this.view = new DriveProviderViews(this, {
       provider: this.provider,
       loadAllFiles: true,
+      virtualList: true,
     })
 
     const { target } = this.opts

+ 1 - 0
packages/@uppy/google-photos/.npmignore

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

+ 8 - 0
packages/@uppy/google-photos/CHANGELOG.md

@@ -0,0 +1,8 @@
+# @uppy/google-photos
+
+## 0.1.0
+
+Released: 2024-06-18
+Included in: Uppy v3.27.0
+
+- @uppy/google-photos: add plugin (Mikael Finstad / #5061)

+ 21 - 0
packages/@uppy/google-photos/LICENSE

@@ -0,0 +1,21 @@
+The MIT License (MIT)
+
+Copyright (c) 2024 Transloadit
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.

+ 51 - 0
packages/@uppy/google-photos/README.md

@@ -0,0 +1,51 @@
+# @uppy/google-photos
+
+<img src="https://uppy.io/img/logo.svg" width="120" alt="Uppy logo: a smiling puppy above a pink upwards arrow" align="right">
+
+[![npm version](https://img.shields.io/npm/v/@uppy/google-photos.svg?style=flat-square)](https://www.npmjs.com/package/@uppy/google-photos)
+![CI status for Uppy tests](https://github.com/transloadit/uppy/workflows/Tests/badge.svg)
+![CI status for Companion tests](https://github.com/transloadit/uppy/workflows/Companion/badge.svg)
+![CI status for browser tests](https://github.com/transloadit/uppy/workflows/End-to-end%20tests/badge.svg)
+
+The Google Photos plugin for Uppy lets users import photos from their Google
+Photos account.
+
+A Companion instance is required for the GooglePhotos plugin to work. Companion
+handles authentication with Google, downloads photos from Google Photos and
+uploads them to the destination. This saves the user bandwidth, especially
+helpful if they are on a mobile connection.
+
+Uppy is being developed by the folks at [Transloadit](https://transloadit.com),
+a versatile file encoding service.
+
+## Example
+
+```js
+import Uppy from '@uppy/core'
+import GooglePhotos from '@uppy/google-photos'
+
+const uppy = new Uppy()
+uppy.use(GooglePhotos, {
+  // Options
+})
+```
+
+## Installation
+
+```bash
+$ npm install @uppy/google-photos
+```
+
+Alternatively, you can also use this plugin in a pre-built bundle from
+Transloadit’s CDN: Edgly. In that case `Uppy` will attach itself to the global
+`window.Uppy` object. See the
+[main Uppy documentation](https://uppy.io/docs/#Installation) for instructions.
+
+## Documentation
+
+Documentation for this plugin can be found on the
+[Uppy website](https://uppy.io/docs/google-photos).
+
+## License
+
+The [MIT License](./LICENSE).

+ 32 - 0
packages/@uppy/google-photos/package.json

@@ -0,0 +1,32 @@
+{
+  "name": "@uppy/google-photos",
+  "description": "The Google Photos plugin for Uppy lets users import photos from their Google Photos account",
+  "version": "0.1.0",
+  "license": "MIT",
+  "main": "lib/index.js",
+  "type": "module",
+  "keywords": [
+    "file uploader",
+    "google photos",
+    "cloud storage",
+    "uppy",
+    "uppy-plugin"
+  ],
+  "homepage": "https://uppy.io",
+  "bugs": {
+    "url": "https://github.com/transloadit/uppy/issues"
+  },
+  "repository": {
+    "type": "git",
+    "url": "git+https://github.com/transloadit/uppy.git"
+  },
+  "dependencies": {
+    "@uppy/companion-client": "workspace:^",
+    "@uppy/provider-views": "workspace:^",
+    "@uppy/utils": "workspace:^",
+    "preact": "^10.5.13"
+  },
+  "peerDependencies": {
+    "@uppy/core": "workspace:^"
+  }
+}

+ 137 - 0
packages/@uppy/google-photos/src/GooglePhotos.tsx

@@ -0,0 +1,137 @@
+import { UIPlugin, Uppy } from '@uppy/core'
+import { ProviderViews } from '@uppy/provider-views'
+import {
+  Provider,
+  tokenStorage,
+  getAllowedHosts,
+  type CompanionPluginOptions,
+} from '@uppy/companion-client'
+import { h, type ComponentChild } from 'preact'
+
+import type { UppyFile, Body, Meta } from '@uppy/utils/lib/UppyFile'
+import type { UnknownProviderPluginState } from '@uppy/core/lib/Uppy.ts'
+
+// eslint-disable-next-line @typescript-eslint/ban-ts-comment
+// @ts-ignore We don't want TS to generate types for the package.json
+import packageJson from '../package.json'
+import locale from './locale.ts'
+
+export type GooglePhotosOptions = CompanionPluginOptions
+
+export default class GooglePhotos<
+  M extends Meta,
+  B extends Body,
+> extends UIPlugin<GooglePhotosOptions, M, B, UnknownProviderPluginState> {
+  static VERSION = packageJson.version
+
+  icon: () => h.JSX.Element
+
+  provider: Provider<M, B>
+
+  view: ProviderViews<M, B>
+
+  storage: typeof tokenStorage
+
+  files: UppyFile<M, B>[]
+
+  constructor(uppy: Uppy<M, B>, opts: GooglePhotosOptions) {
+    super(uppy, opts)
+    this.type = 'acquirer'
+    this.storage = this.opts.storage || tokenStorage
+    this.files = []
+    this.id = this.opts.id || 'GooglePhotos'
+    this.icon = () => (
+      <svg
+        aria-hidden="true"
+        focusable="false"
+        width="32"
+        height="32"
+        viewBox="-7 -7 73 73"
+      >
+        <g fill="none" fill-rule="evenodd">
+          <path d="M-3-3h64v64H-3z" />
+          <g fill-rule="nonzero">
+            <path
+              fill="#FBBC04"
+              d="M14.8 13.4c8.1 0 14.7 6.6 14.7 14.8v1.3H1.3c-.7 0-1.3-.6-1.3-1.3C0 20 6.6 13.4 14.8 13.4z"
+            />
+            <path
+              fill="#EA4335"
+              d="M45.6 14.8c0 8.1-6.6 14.7-14.8 14.7h-1.3V1.3c0-.7.6-1.3 1.3-1.3C39 0 45.6 6.6 45.6 14.8z"
+            />
+            <path
+              fill="#4285F4"
+              d="M44.3 45.6c-8.2 0-14.8-6.6-14.8-14.8v-1.3h28.2c.7 0 1.3.6 1.3 1.3 0 8.2-6.6 14.8-14.8 14.8z"
+            />
+            <path
+              fill="#34A853"
+              d="M13.4 44.3c0-8.2 6.6-14.8 14.8-14.8h1.3v28.2c0 .7-.6 1.3-1.3 1.3-8.2 0-14.8-6.6-14.8-14.8z"
+            />
+          </g>
+        </g>
+      </svg>
+    )
+
+    this.opts.companionAllowedHosts = getAllowedHosts(
+      this.opts.companionAllowedHosts,
+      this.opts.companionUrl,
+    )
+    this.provider = new Provider(uppy, {
+      companionUrl: this.opts.companionUrl,
+      companionHeaders: this.opts.companionHeaders,
+      companionKeysParams: this.opts.companionKeysParams,
+      companionCookiesRule: this.opts.companionCookiesRule,
+      provider: 'googlephotos',
+      pluginId: this.id,
+      supportsRefreshToken: true,
+    })
+
+    this.defaultLocale = locale
+
+    this.i18nInit()
+    this.title = this.i18n('pluginNameGooglePhotos')
+
+    this.onFirstRender = this.onFirstRender.bind(this)
+    this.render = this.render.bind(this)
+  }
+
+  install(): void {
+    // eslint-disable-next-line
+    // @ts-ignore TODO: fix this
+    this.view = new ProviderViews(this, {
+      provider: this.provider,
+      loadAllFiles: true,
+    })
+
+    const { target } = this.opts
+    if (target) {
+      this.mount(target, this)
+    }
+  }
+
+  uninstall(): void {
+    this.view.tearDown()
+    this.unmount()
+  }
+
+  async onFirstRender(): Promise<void> {
+    await Promise.all([
+      this.provider.fetchPreAuthToken(),
+      this.view.getFolder(),
+    ])
+  }
+
+  render(state: unknown): ComponentChild {
+    if (
+      this.getPluginState().files.length &&
+      !this.getPluginState().folders.length
+    ) {
+      return this.view.render(state, {
+        viewType: 'grid',
+        showFilter: false,
+        showTitles: false,
+      })
+    }
+    return this.view.render(state)
+  }
+}

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

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

+ 5 - 0
packages/@uppy/google-photos/src/locale.ts

@@ -0,0 +1,5 @@
+export default {
+  strings: {
+    pluginNameGooglePhotos: 'Google Photos',
+  },
+}

+ 35 - 0
packages/@uppy/google-photos/tsconfig.build.json

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

+ 31 - 0
packages/@uppy/google-photos/tsconfig.json

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

+ 1 - 0
packages/@uppy/onedrive/src/OneDrive.tsx

@@ -98,6 +98,7 @@ export default class OneDrive<M extends Meta, B extends Body> extends UIPlugin<
     this.view = new ProviderViews(this, {
       provider: this.provider,
       loadAllFiles: true,
+      virtualList: true,
     })
 
     const { target } = this.opts

+ 5 - 4
packages/@uppy/provider-views/src/Browser.tsx

@@ -74,7 +74,8 @@ function ListItem<M extends Meta, B extends Body>(props: ListItemProps<M, B>) {
     id: f.id,
     title: f.name,
     author: f.author,
-    getItemIcon: () => f.icon,
+    getItemIcon: () =>
+      viewType === 'grid' && f.thumbnail ? f.thumbnail : f.icon,
     isChecked: isChecked(f),
     toggleCheckbox: (event: Event) => toggleCheckbox(event, f),
     isCheckboxDisabled: false,
@@ -115,7 +116,7 @@ type BrowserProps<M extends Meta, B extends Body> = {
   cancel: () => void
   done: () => void
   noResultsLabel: string
-  loadAllFiles?: boolean
+  virtualList?: boolean
 }
 
 function Browser<M extends Meta, B extends Body>(props: BrowserProps<M, B>) {
@@ -146,7 +147,7 @@ function Browser<M extends Meta, B extends Body>(props: BrowserProps<M, B>) {
     cancel,
     done,
     noResultsLabel,
-    loadAllFiles,
+    virtualList,
   } = props
 
   const selected = currentSelection.length
@@ -202,7 +203,7 @@ function Browser<M extends Meta, B extends Body>(props: BrowserProps<M, B>) {
           return <div className="uppy-Provider-empty">{noResultsLabel}</div>
         }
 
-        if (loadAllFiles) {
+        if (virtualList) {
           return (
             <div className="uppy-ProviderBrowser-body">
               <ul className="uppy-ProviderBrowser-list">

+ 3 - 1
packages/@uppy/provider-views/src/Item/components/ListItem.tsx

@@ -95,7 +95,9 @@ export default function ListItem<M extends Meta, B extends Body>(
             <div className="uppy-ProviderBrowserItem-iconWrap">
               {itemIconEl}
             </div>
-            {showTitles && <span>{title}</span>}
+            {showTitles && title ?
+              <span>{title}</span>
+            : i18n('unnamed')}
           </button>
 
       }

+ 3 - 0
packages/@uppy/provider-views/src/ProviderView/ProviderView.tsx

@@ -58,6 +58,7 @@ const defaultOptions = {
   showFilter: true,
   showBreadcrumbs: true,
   loadAllFiles: false,
+  virtualList: false,
 }
 
 export interface ProviderViewOptions<M extends Meta, B extends Body>
@@ -68,6 +69,7 @@ export interface ProviderViewOptions<M extends Meta, B extends Body>
     loading: boolean | string
     onAuth: (authFormData: unknown) => Promise<void>
   }) => h.JSX.Element
+  virtualList?: boolean
 }
 
 type Opts<M extends Meta, B extends Body> = DefinePluginOpts<
@@ -585,6 +587,7 @@ export default class ProviderView<M extends Meta, B extends Body> extends View<
       getNextFolder: this.getNextFolder,
       getFolder: this.getFolder,
       loadAllFiles: this.opts.loadAllFiles,
+      virtualList: this.opts.virtualList,
 
       // For SearchFilterInput component
       showSearchFilter: targetViewOptions.showFilter,

+ 1 - 6
packages/@uppy/provider-views/src/View.ts

@@ -4,8 +4,6 @@ import type {
 } from '@uppy/core/lib/Uppy'
 import type { Body, Meta, TagFile } from '@uppy/utils/lib/UppyFile'
 import type { CompanionFile } from '@uppy/utils/lib/CompanionFile'
-import getFileType from '@uppy/utils/lib/getFileType'
-import isPreviewSupported from '@uppy/utils/lib/isPreviewSupported'
 import remoteFileObjToLocal from '@uppy/utils/lib/remoteFileObjToLocal'
 
 type PluginType = 'Provider' | 'SearchProvider'
@@ -142,10 +140,7 @@ export default class View<
       },
     }
 
-    const fileType = getFileType(tagFile)
-
-    // TODO Should we just always use the thumbnail URL if it exists?
-    if (fileType && isPreviewSupported(fileType)) {
+    if (file.thumbnail) {
       tagFile.preview = file.thumbnail
     }
 

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

@@ -31,6 +31,7 @@
     "@uppy/dropbox": "workspace:^",
     "@uppy/facebook": "workspace:^",
     "@uppy/google-drive": "workspace:^",
+    "@uppy/google-photos": "workspace:^",
     "@uppy/instagram": "workspace:^",
     "@uppy/onedrive": "workspace:^",
     "@uppy/unsplash": "workspace:^",

+ 1 - 1
packages/@uppy/remote-sources/src/index.test.ts

@@ -47,7 +47,7 @@ describe('RemoteSources', () => {
         sources: ['Webcam'],
       })
     }).toThrow(
-      'Invalid plugin: "Webcam" is not one of: Box, Dropbox, Facebook, GoogleDrive, Instagram, OneDrive, Unsplash, Url, or Zoom.',
+      'Invalid plugin: "Webcam" is not one of: Box, Dropbox, Facebook, GoogleDrive, GooglePhotos, Instagram, OneDrive, Unsplash, Url, or Zoom.',
     )
   })
 })

+ 2 - 0
packages/@uppy/remote-sources/src/index.ts

@@ -6,6 +6,7 @@ import {
 } from '@uppy/core'
 import Dropbox from '@uppy/dropbox'
 import GoogleDrive from '@uppy/google-drive'
+import GooglePhotos from '@uppy/google-photos'
 import Instagram from '@uppy/instagram'
 import Facebook from '@uppy/facebook'
 import OneDrive from '@uppy/onedrive'
@@ -27,6 +28,7 @@ const availablePlugins = {
   Dropbox,
   Facebook,
   GoogleDrive,
+  GooglePhotos,
   Instagram,
   OneDrive,
   Unsplash,

+ 5 - 0
packages/@uppy/remote-sources/tsconfig.build.json

@@ -14,6 +14,8 @@
       "@uppy/facebook/lib/*": ["../facebook/src/*"],
       "@uppy/google-drive": ["../google-drive/src/index.js"],
       "@uppy/google-drive/lib/*": ["../google-drive/src/*"],
+      "@uppy/google-photos": ["../google-photos/src/index.js"],
+      "@uppy/google-photos/lib/*": ["../google-photos/src/*"],
       "@uppy/instagram": ["../instagram/src/index.js"],
       "@uppy/instagram/lib/*": ["../instagram/src/*"],
       "@uppy/onedrive": ["../onedrive/src/index.js"],
@@ -49,6 +51,9 @@
     {
       "path": "../google-drive/tsconfig.build.json"
     },
+    {
+      "path": "../google-photos/tsconfig.build.json"
+    },
     {
       "path": "../instagram/tsconfig.build.json"
     },

+ 5 - 0
packages/@uppy/remote-sources/tsconfig.json

@@ -14,6 +14,8 @@
       "@uppy/facebook/lib/*": ["../facebook/src/*"],
       "@uppy/google-drive": ["../google-drive/src/index.js"],
       "@uppy/google-drive/lib/*": ["../google-drive/src/*"],
+      "@uppy/google-photos": ["../google-photos/src/index.js"],
+      "@uppy/google-photos/lib/*": ["../google-photos/src/*"],
       "@uppy/instagram": ["../instagram/src/index.js"],
       "@uppy/instagram/lib/*": ["../instagram/src/*"],
       "@uppy/onedrive": ["../onedrive/src/index.js"],
@@ -45,6 +47,9 @@
     {
       "path": "../google-drive/tsconfig.build.json",
     },
+    {
+      "path": "../google-photos/tsconfig.build.json",
+    },
     {
       "path": "../instagram/tsconfig.build.json",
     },

+ 14 - 0
packages/@uppy/transloadit/CHANGELOG.md

@@ -21,6 +21,20 @@ Included in: Uppy v4.0.0-beta.1
 
 - @uppy/transloadit: migrate to TS (Merlijn Vos / #4987)
 
+## 3.8.0
+
+Released: 2024-06-18
+Included in: Uppy v3.27.0
+
+- @uppy/transloadit: also fix outdated assembly transloadit:result (Merlijn Vos / #5246)
+
+## 3.7.1
+
+Released: 2024-06-11
+Included in: Uppy v3.26.1
+
+- @uppy/transloadit: fix transloadit:result event (Merlijn Vos / #5231)
+
 ## 3.7.0
 
 Released: 2024-06-04

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

@@ -312,6 +312,7 @@ export default class Transloadit<
     addPluginVersion('Box', 'uppy-box')
     addPluginVersion('Facebook', 'uppy-facebook')
     addPluginVersion('GoogleDrive', 'uppy-google-drive')
+    addPluginVersion('GooglePhotos', 'uppy-google-photos')
     addPluginVersion('Instagram', 'uppy-instagram')
     addPluginVersion('OneDrive', 'uppy-onedrive')
     addPluginVersion('Zoom', 'uppy-zoom')

+ 1 - 0
packages/uppy/index.mjs

@@ -38,6 +38,7 @@ export { default as Box } from '@uppy/box'
 export { default as Dropbox } from '@uppy/dropbox'
 export { default as Facebook } from '@uppy/facebook'
 export { default as GoogleDrive } from '@uppy/google-drive'
+export { default as GooglePhotos } from '@uppy/google-photos'
 export { default as Instagram } from '@uppy/instagram'
 export { default as OneDrive } from '@uppy/onedrive'
 export { default as RemoteSources } from '@uppy/remote-sources'

+ 1 - 0
packages/uppy/package.json

@@ -46,6 +46,7 @@
     "@uppy/form": "workspace:^",
     "@uppy/golden-retriever": "workspace:^",
     "@uppy/google-drive": "workspace:^",
+    "@uppy/google-photos": "workspace:^",
     "@uppy/image-editor": "workspace:^",
     "@uppy/informer": "workspace:^",
     "@uppy/instagram": "workspace:^",

+ 1 - 1
private/dev/Dashboard.js

@@ -113,7 +113,7 @@ export default () => {
     // .use(Unsplash, { target: Dashboard, companionUrl: COMPANION_URL, companionAllowedHosts })
     .use(RemoteSources, {
       companionUrl: COMPANION_URL,
-      sources: ['Box', 'Dropbox', 'Facebook', 'Instagram', 'OneDrive', 'Unsplash', 'Zoom', 'Url'],
+      sources: ['GooglePhotos', 'Box', 'Dropbox', 'Facebook', 'Instagram', 'OneDrive', 'Unsplash', 'Zoom', 'Url'],
       companionAllowedHosts,
     })
     .use(Webcam, {

File diff suppressed because it is too large
+ 814 - 2
yarn.lock


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