Selaa lähdekoodia

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

* 'main' of https://github.com/transloadit/uppy:
  Changelog for 1.31.0 and patches
  Strictly type uppy events (#3085)
  Create `onUnmount` in `UIPlugin` for plugins that require clean up (#3093)
  Companion improve logging (#3103)
  Fix `editFile` locale usage (#3108)
Murderlon 3 vuotta sitten
vanhempi
commit
980074c0a1

+ 74 - 1
CHANGELOG.md

@@ -14,7 +14,7 @@ In the current stage we aim to release a new version at least every month.
 
 ### next
 
-## July 2021
+## August 2021
 
 - [ ] robodog: finishing touches on Companion dynamic Oauth #2802 (@goto-bus-stop)
 - [ ] unsplash: Unsplash re-design (#2635 / @arturi, @nqst)
@@ -22,6 +22,79 @@ In the current stage we aim to release a new version at least every month.
 - [ ] plugin: audio/memo recording similar to Webcam #143 #198 (@arturi)
 - [ ] compressor: add to Uppy repo, add resizing (@arturi)
 
+## Companion Patch 2.12.2
+
+| Package | Version |
+|-|-|
+| @uppy/companion@2.12.2 | 2.12.2 |
+
+- @uppy/companion: Improve logging (#3103 / @mifi) 
+
+## 2.0.0-alpha.0
+
+| Package | Version | Package | Version |
+|-|-|-|-|
+| @uppy/angular | 0.2.0-alpha.0 | @uppy/provider-views | 2.0.0-alpha.0 |
+| @uppy/aws-s3-multipart | 2.0.0-alpha.0 | @uppy/react-native | 0.2.0-alpha.0 |
+| @uppy/aws-s3 | 2.0.0-alpha.0 | @uppy/react | 2.0.0-alpha.0 |
+| @uppy/box | 0.4.0-alpha.0 | @uppy/redux-dev-tools | 2.0.0-alpha.0 |
+| @uppy/companion-client | 2.0.0-alpha.0 | @uppy/robodog | 2.0.0-alpha.0 |
+| @uppy/companion | 2.13.0-alpha.0 | @uppy/screen-capture | 2.0.0-alpha.0 |
+| @uppy/core | 2.0.0-alpha.0 | @uppy/status-bar | 2.0.0-alpha.0 |
+| @uppy/dashboard | 2.0.0-alpha.0 | @uppy/store-default | 2.0.0-alpha.0 |
+| @uppy/drag-drop | 2.0.0-alpha.0 | @uppy/store-redux | 2.0.0-alpha.0 |
+| @uppy/drop-target | 1.0.0-alpha.0 | @uppy/svelte | 1.0.0-alpha.0 |
+| @uppy/dropbox | 2.0.0-alpha.0 | @uppy/thumbnail-generator | 2.0.0-alpha.0 |
+| @uppy/facebook | 2.0.0-alpha.0 | @uppy/transloadit | 2.0.0-alpha.0 |
+| @uppy/file-input | 2.0.0-alpha.0 | @uppy/tus | 2.0.0-alpha.0 |
+| @uppy/form | 2.0.0-alpha.0 | @uppy/unsplash | 0.2.0-alpha.0 |
+| @uppy/golden-retriever | 2.0.0-alpha.0 | @uppy/url | 2.0.0-alpha.0 |
+| @uppy/google-drive | 2.0.0-alpha.0 | @uppy/utils | 4.0.0-alpha.0 |
+| @uppy/image-editor | 1.0.0-alpha.0 | @uppy/vue | 0.3.0-alpha.0 |
+| @uppy/informer | 2.0.0-alpha.0 | @uppy/webcam | 2.0.0-alpha.0 |
+| @uppy/instagram | 2.0.0-alpha.0 | @uppy/xhr-upload | 2.0.0-alpha.0 |
+| @uppy/locales | 2.0.0-alpha.0 | @uppy/zoom | 0.2.0-alpha.0 |
+| @uppy/onedrive | 2.0.0-alpha.0 | remark-lint-uppy | 0.0.2 |
+| @uppy/progress-bar | 2.0.0-alpha.0 | uppy | 2.0.0-alpha.0 |
+
+## 1.31.0
+
+Released: 2021-07-29
+
+| Package | Version | Package | Version |
+|-|-|-|-|
+| @uppy/angular | 0.1.3 | @uppy/react | 1.12.1 |
+| @uppy/aws-s3 | 1.8.0 | @uppy/robodog | 1.11.0 |
+| @uppy/companion | 2.12.0 | @uppy/screen-capture | 1.1.0 |
+| @uppy/core | 1.20.0 | @uppy/svelte | 0.1.13 |
+| @uppy/dashboard | 1.21.0 | @uppy/transloadit | 1.7.0 |
+| @uppy/drag-drop | 1.4.31 | @uppy/vue | 0.2.6 |
+| @uppy/image-editor | 0.4.0 | @uppy/webcam | 1.8.13 |
+| @uppy/locales | 1.22.0 | uppy | 1.31.0 |
+
+- @uppy/companion: Fix invalid referrer crashing the process (a785f7deebe5ad75bb2e7ea0874198784c19fea1 / @juliangruber)
+- @uppy/companion: Fix typescript error (6dbaddc09d36308821b842ed13a847f5d655cbf4 / @juliangruber)
+- @uppy/angular: Fix broken packaging (#3007 / @ajkachnic)
+- @uppy/robodog: Add Robodog Types (#2989 / @Hawxy)
+- @uppy/core: Tighten duck type check for file objects (#3006 / @goto-bus-stop)
+- @uppy/core: tighten duck type check for file objects (#3006 / @goto-bus-stop)
+- @uppy/core: Set file size from progress data when null (#2778 / @mejiaej)
+- @uppy/core: Mark state as deprecated (#3044 / @aduh95)
+- @uppy/locales: Update de_DE.js (#3012 / @paescuj)
+- @uppy/dashboard: Rename Done to Cancel, add Save to Image Editor (#3033 / @arturi)
+- @uppy/box: Add Box (#3004 / @mifi)
+- @uppy/dashboard: Add required option to metaFields (#2896 / @aduh95)
+- build: Fix package.json imports to be inlined by Babel (#3047 / @aduh95)
+- docs: Add instagram development notes (#2984 / @mifi)
+- docs: Update CONTRIBUTING.md (#3011 / @aduh95)
+- website: fix linter errors in JS code snippets inside blog posts (#2991 / @aduh95)
+
+### Patch release
+
+| Package | Version | Package | Version |
+|-|-|-|-|
+| @uppy/angular | 0.1.2 | @uppy/companion | 2.11.1 |
+
 ## June 2021
 
 ## 1.30.0

+ 52 - 49
packages/@uppy/companion/src/server/logger.js

@@ -1,5 +1,6 @@
 const chalk = require('chalk')
 const escapeStringRegexp = require('escape-string-regexp')
+const util = require('util')
 
 const valuesToMask = []
 /**
@@ -16,6 +17,57 @@ exports.setMaskables = (maskables) => {
   Object.freeze(valuesToMask)
 }
 
+/**
+ * Mask the secret content of a message
+ *
+ * @param {string} msg the message whose content should be masked
+ * @returns {string}
+ */
+function maskMessage (msg) {
+  let out = msg
+  for (const toBeMasked of valuesToMask) {
+    const toBeReplaced = new RegExp(toBeMasked, 'gi')
+    out = out.replace(toBeReplaced, '******')
+  }
+  return out
+}
+
+/**
+ * message log
+ *
+ * @param {string | Error} msg the message to log
+ * @param {string} tag a unique tag to easily search for this message
+ * @param {string} level error | info | debug
+ * @param {string=} id a unique id to easily trace logs tied to a request
+ * @param {Function=} color function to display the log in appropriate color
+ * @param {boolean=} shouldLogStackTrace when set to true, errors will be logged with their stack trace
+ */
+const log = (msg, tag = '', level, id = '', color = (message) => message, shouldLogStackTrace) => {
+  const time = new Date().toISOString()
+  const whitespace = tag && id ? ' ' : ''
+
+  function logMsg (msg2) {
+    let msgString = typeof msg2 === 'string' ? msg2 : util.inspect(msg2)
+    msgString = maskMessage(msgString)
+    // eslint-disable-next-line no-console
+    console.log(color(`companion: ${time} [${level}] ${id}${whitespace}${tag}`), color(msgString))
+  }
+
+  if (msg instanceof Error) {
+    // Not sure why it only logs the stack without the message, but this is how the code was originally
+    if (shouldLogStackTrace && typeof msg.stack === 'string') {
+      logMsg(msg.stack)
+      return
+    }
+
+    // We don't want to log stack trace (this is how the code was originally)
+    logMsg(String(msg))
+    return
+  }
+
+  logMsg(msg)
+}
+
 /**
  * INFO level log
  *
@@ -66,52 +118,3 @@ exports.debug = (msg, tag, traceId) => {
     log(msg, tag, 'debug', traceId, chalk.bold.blue)
   }
 }
-
-/**
- * message log
- *
- * @param {string | Error} msg the message to log
- * @param {string} tag a unique tag to easily search for this message
- * @param {string} level error | info | debug
- * @param {Function=} color function to display the log in appropriate color
- * @param {string=} id a unique id to easily trace logs tied to a request
- * @param {boolean=} shouldLogStackTrace when set to true, errors will be logged with their stack trace
- */
-const log = (msg, tag, level, id, color, shouldLogStackTrace) => {
-  const time = new Date().toISOString()
-  tag = tag || ''
-  id = id || ''
-  const whitespace = tag && id ? ' ' : ''
-  color = color || ((message) => message)
-  if (typeof msg === 'string') {
-    msg = maskMessage(msg)
-  } else if (msg && typeof msg.message === 'string') {
-    msg.message = maskMessage(msg.message)
-  }
-
-  if (shouldLogStackTrace && msg instanceof Error && typeof msg.stack === 'string') {
-    msg.stack = maskMessage(msg.stack)
-    // exclude msg from template string so values such as error objects
-    // can be well formatted
-    console.log(color(`companion: ${time} [${level}] ${id}${whitespace}${tag}`), color(msg.stack))
-    return
-  }
-
-  // exclude msg from template string so values such as error objects
-  // can be well formatted
-  console.log(color(`companion: ${time} [${level}] ${id}${whitespace}${tag}`), color(msg))
-}
-
-/**
- * Mask the secret content of a message
- *
- * @param {string} msg the message whose content should be masked
- * @returns {string}
- */
-const maskMessage = (msg) => {
-  for (const toBeMasked of valuesToMask) {
-    const toBeReplaced = new RegExp(toBeMasked, 'gi')
-    msg = msg.replace(toBeReplaced, '******')
-  }
-  return msg
-}

+ 44 - 59
packages/@uppy/companion/test/__tests__/logger.js

@@ -2,24 +2,36 @@
 const chalk = require('chalk')
 const logger = require('../../src/server/logger')
 
-describe('Test Logger secret mask', () => {
-  beforeAll(() => {
-    logger.setMaskables(['ToBeMasked1', 'toBeMasked2', 'toBeMasked(And)?Escaped'])
-  })
+const maskables = ['ToBeMasked1', 'toBeMasked2', 'toBeMasked(And)?Escaped']
 
-  test('masks secret values present in log.info messages', () => {
-    let loggedMessage = null
+function captureConsoleLog (log) {
+  let loggedMessage = null
+
+  // override the default console.log to capture the logged message
+  const defaultConsoleLog = console.log
 
-    // override the default console.log to capture the logged message
-    const defaultConsoleLog = console.log
+  try {
     console.log = (logPrefix, message) => {
       loggedMessage = message
       defaultConsoleLog(logPrefix, message)
     }
-
-    logger.info('this info has ToBeMasked1 and toBeMasked2 and case-insensitive TOBEMasKED2')
+  } finally {
+    log()
     // restore the default console.log before using "expect" to avoid weird log behaviors
     console.log = defaultConsoleLog
+  }
+  return loggedMessage
+}
+
+describe('Test Logger secret mask', () => {
+  beforeAll(() => {
+    logger.setMaskables(maskables)
+  })
+
+  test('masks secret values present in log.info messages', () => {
+    const loggedMessage = captureConsoleLog(() => {
+      logger.info('this info has ToBeMasked1 and toBeMasked2 and case-insensitive TOBEMasKED2')
+    })
 
     const exptectedMsg = 'this info has ****** and ****** and case-insensitive ******'
 
@@ -28,18 +40,9 @@ describe('Test Logger secret mask', () => {
   })
 
   test('masks secret values present in log.warn messages', () => {
-    let loggedMessage = null
-
-    // override the default console.log to capture the logged message
-    const defaultConsoleLog = console.log
-    console.log = (logPrefix, message) => {
-      loggedMessage = message
-      defaultConsoleLog(logPrefix, message)
-    }
-
-    logger.warn('this warning has ToBeMasked1 and toBeMasked2 and case-insensitive TOBEMasKED2')
-    // restore the default console.log before using "expect" to avoid weird log behaviors
-    console.log = defaultConsoleLog
+    const loggedMessage = captureConsoleLog(() => {
+      logger.warn('this warning has ToBeMasked1 and toBeMasked2 and case-insensitive TOBEMasKED2')
+    })
 
     const exptectedMsg = chalk.bold.yellow('this warning has ****** and ****** and case-insensitive ******')
 
@@ -48,18 +51,9 @@ describe('Test Logger secret mask', () => {
   })
 
   test('masks secret values present in log.error messages', () => {
-    let loggedMessage = null
-
-    // override the default console.log to capture the logged message
-    const defaultConsoleLog = console.log
-    console.log = (logPrefix, message) => {
-      loggedMessage = message
-      defaultConsoleLog(logPrefix, message)
-    }
-
-    logger.error(new Error('this error has ToBeMasked1 and toBeMasked2 and case-insensitive TOBEMasKED2'))
-    // restore the default console.log before using "expect" to avoid weird log behaviors
-    console.log = defaultConsoleLog
+    const loggedMessage = captureConsoleLog(() => {
+      logger.error(new Error('this error has ToBeMasked1 and toBeMasked2 and case-insensitive TOBEMasKED2'))
+    })
 
     const exptectedMsg = chalk.bold.red('Error: this error has ****** and ****** and case-insensitive ******')
 
@@ -68,19 +62,10 @@ describe('Test Logger secret mask', () => {
   })
 
   test('masks secret values present in log.error stack trace', () => {
-    let loggedMessage = null
-
-    // override the default console.log to capture the logged message
-    const defaultConsoleLog = console.log
-    console.log = (logPrefix, message) => {
-      loggedMessage = message
-      defaultConsoleLog(logPrefix, message)
-    }
-
-    const err = new Error('this error has ToBeMasked1 and toBeMasked2 and case-insensitive TOBEMasKED2')
-    logger.error(err, '', '', true)
-    // restore the default console.log before using "expect" to avoid weird log behaviors
-    console.log = defaultConsoleLog
+    const loggedMessage = captureConsoleLog(() => {
+      const err = new Error('this error has ToBeMasked1 and toBeMasked2 and case-insensitive TOBEMasKED2')
+      logger.error(err, '', '', true)
+    })
 
     const exptectedMsg = chalk.bold.red('Error: this error has ****** and ****** and case-insensitive ******')
 
@@ -92,22 +77,22 @@ describe('Test Logger secret mask', () => {
   })
 
   test('escape regex characters from secret values before masking them', () => {
-    let loggedMessage = null
-
-    // override the default console.log to capture the logged message
-    const defaultConsoleLog = console.log
-    console.log = (logPrefix, message) => {
-      loggedMessage = message
-      defaultConsoleLog(logPrefix, message)
-    }
-
-    logger.warn('this warning has ToBeMasked(And)?Escaped but not toBeMaskedEscaped ')
-    // restore the default console.log before using "expect" to avoid weird log behaviors
-    console.log = defaultConsoleLog
+    const loggedMessage = captureConsoleLog(() => {
+      logger.warn('this warning has ToBeMasked(And)?Escaped but not toBeMaskedEscaped ')
+    })
 
     const exptectedMsg = chalk.bold.yellow('this warning has ****** but not toBeMaskedEscaped ')
 
     expect(loggedMessage).toBeTruthy()
     expect(loggedMessage).toBe(exptectedMsg)
   })
+
+  test('masks inside object', () => {
+    const loggedMessage = captureConsoleLog(() => {
+      logger.warn({ a: 1, deep: { secret: 'there is a ToBeMasked1 hiding here' } })
+    })
+
+    expect(loggedMessage).toBeTruthy()
+    expect(!maskables.some((maskable) => loggedMessage.includes(maskable))).toBeTruthy()
+  })
 })

+ 0 - 3
packages/@uppy/core/src/BasePlugin.js

@@ -75,9 +75,6 @@ module.exports = class BasePlugin {
     throw new Error('Extend the render method to add your plugin to a DOM element')
   }
 
-  // eslint-disable-next-line class-methods-use-this
-  onMount () {}
-
   // eslint-disable-next-line class-methods-use-this
   update () {}
 

+ 7 - 0
packages/@uppy/core/src/UIPlugin.js

@@ -134,7 +134,14 @@ class UIPlugin extends BasePlugin {
     if (this.isTargetDOMEl) {
       this.el?.remove()
     }
+    this.onUnmount()
   }
+
+  // eslint-disable-next-line class-methods-use-this
+  onMount () {}
+
+  // eslint-disable-next-line class-methods-use-this
+  onUnmount () {}
 }
 
 module.exports = UIPlugin

+ 50 - 42
packages/@uppy/core/types/index.d.ts

@@ -14,7 +14,9 @@ export interface IndexedObject<T> {
 export type UppyFile<
   TMeta extends IndexedObject<any> = Record<string, unknown>,
   TBody extends IndexedObject<any> = Record<string, unknown>
-> = UppyUtils.UppyFile<TMeta, TBody>
+  > = UppyUtils.UppyFile<TMeta, TBody>
+
+export type FileProgress = UppyUtils.FileProgress;
 
 // Replace the `meta` property type with one that allows omitting internal metadata addFile() will add that
 type UppyFileWithoutMeta<TMeta, TBody> = OmitKey<
@@ -28,28 +30,6 @@ type LocaleStrings<TNames extends string> = {
 
 type LogLevel = 'info' | 'warning' | 'error'
 
-// This hack accepts _any_ string for `Event`, but also tricks VSCode and friends into providing autocompletions
-// for the names listed. https://github.com/microsoft/TypeScript/issues/29729#issuecomment-505826972
-// eslint-disable-next-line no-use-before-define
-type LiteralUnion<T extends U, U = string> = T | (U & Record<never, never>)
-
-type Event = LiteralUnion<
-  | 'file-added'
-  | 'file-removed'
-  | 'upload'
-  | 'upload-progress'
-  | 'upload-success'
-  | 'complete'
-  | 'error'
-  | 'upload-error'
-  | 'upload-retry'
-  | 'info-visible'
-  | 'info-hidden'
-  | 'cancel-all'
-  | 'restriction-failed'
-  | 'reset-progress'
->
-
 export type Store = UppyUtils.Store
 
 export type InternalMetadata = UppyUtils.InternalMetadata
@@ -65,7 +45,7 @@ export interface FailedUppyFile<TMeta, TBody> extends UppyFile<TMeta, TBody> {
 export interface AddFileOptions<
   TMeta = IndexedObject<any>,
   TBody = IndexedObject<any>
-> extends Partial<UppyFileWithoutMeta<TMeta, TBody>> {
+  > extends Partial<UppyFileWithoutMeta<TMeta, TBody>> {
   // `.data` is the only required property here.
   data: Blob | File
   meta?: Partial<InternalMetadata> & TMeta
@@ -122,6 +102,10 @@ export class UIPlugin<TOptions extends PluginOptions = DefaultPluginOptions> ext
   addTarget<TPlugin extends UIPlugin>(plugin: TPlugin): void
 
   unmount(): void
+
+  onMount(): void
+
+  onUnmount(): void
 }
 
 export type PluginTarget =
@@ -173,7 +157,7 @@ export interface UppyOptions<TMeta extends IndexedObject<any> = Record<string, u
 export interface UploadResult<
   TMeta extends IndexedObject<any> = Record<string, unknown>,
   TBody extends IndexedObject<any> = Record<string, unknown>
-> {
+  > {
   successful: UploadedUppyFile<TMeta, TBody>[]
   failed: FailedUppyFile<TMeta, TBody>[]
 }
@@ -181,14 +165,14 @@ export interface UploadResult<
 export interface State<
   TMeta extends IndexedObject<any> = Record<string, unknown>,
   TBody extends IndexedObject<any> = Record<string, unknown>
-> extends IndexedObject<any> {
+  > extends IndexedObject<any> {
   capabilities?: { resumableUploads?: boolean }
   currentUploads: Record<string, unknown>
   error?: string
   files: {
     [key: string]:
-      | UploadedUppyFile<TMeta, TBody>
-      | FailedUppyFile<TMeta, TBody>
+    | UploadedUppyFile<TMeta, TBody>
+    | FailedUppyFile<TMeta, TBody>
   }
   info?: {
     isHidden: boolean
@@ -200,32 +184,56 @@ export interface State<
   totalProgress: number
 }
 
-type UploadSuccessCallback<T> = (file: UppyFile<T>, body: any, uploadURL: string) => void
-type UploadCompleteCallback<T> = (result: UploadResult<T>) => void
+export type GenericEventCallback = () => void;
+export type FileAddedCallback<TMeta> = (file: UppyFile<TMeta>) => void;
+export type FilesAddedCallback<TMeta> = (files: UppyFile<TMeta>[]) => void;
+export type FileRemovedCallback<TMeta> = (file: UppyFile<TMeta>, reason: 'removed-by-user' | 'cancel-all') => void;
+export type UploadCallback = (data: {id: string, fileIDs: string[]}) => void;
+export type ProgressCallback = (progress: number) => void;
+export type UploadProgressCallback<TMeta> = (file: UppyFile<TMeta>, progress: FileProgress) => void;
+export type UploadSuccessCallback<TMeta> = (file: UploadedUppyFile<TMeta, unknown>, body: unknown, uploadURL: string) => void
+export type UploadCompleteCallback<TMeta> = (result: UploadResult<TMeta>) => void
+export type ErrorCallback = (error: Error) => void;
+export type UploadErrorCallback<TMeta> = (file: FailedUppyFile<TMeta, unknown>, error: Error, response: unknown) => void;
+export type UploadRetryCallback = (fileID: string) => void;
+export type RestrictionFailedCallback<TMeta> = (file: UppyFile<TMeta>, error: Error) => void;
+
+export interface UppyEventMap<TMeta = Record<string, unknown>> {
+  'file-added': FileAddedCallback<TMeta>
+  'files-added': FilesAddedCallback<TMeta>
+  'file-removed': FileRemovedCallback<TMeta>
+  'upload': UploadCallback
+  'progress': ProgressCallback
+  'upload-progress': UploadProgressCallback<TMeta>
+  'upload-success': UploadSuccessCallback<TMeta>
+  'complete': UploadCompleteCallback<TMeta>
+  'error': ErrorCallback
+  'upload-error': UploadErrorCallback<TMeta>
+  'upload-retry': UploadRetryCallback
+  'info-visible': GenericEventCallback
+  'info-hidden': GenericEventCallback
+  'cancel-all': GenericEventCallback
+  'restriction-failed': RestrictionFailedCallback<TMeta>
+  'reset-progress': GenericEventCallback
+}
 
 export class Uppy {
   constructor(opts?: UppyOptions)
 
-  on<TMeta extends IndexedObject<any> = Record<string, unknown>>(event: 'upload-success', callback: UploadSuccessCallback<TMeta>): this
-
-  on<TMeta extends IndexedObject<any> = Record<string, unknown>>(event: 'complete', callback: UploadCompleteCallback<TMeta>): this
-
-  on(event: Event, callback: (...args: any[]) => void): this
-
-  once<TMeta extends IndexedObject<any> = Record<string, unknown>>(event: 'upload-success', callback: UploadSuccessCallback<TMeta>): this
+  on<K extends keyof UppyEventMap>(event: K, callback: UppyEventMap[K]): this
 
-  once<TMeta extends IndexedObject<any> = Record<string, unknown>>(event: 'complete', callback: UploadCompleteCallback<TMeta>): this
+  on<K extends keyof UppyEventMap, TMeta extends IndexedObject<any>>(event: K, callback: UppyEventMap<TMeta>[K]): this
 
-  once(event: Event, callback: (...args: any[]) => void): this
+  once<K extends keyof UppyEventMap>(event: K, callback: UppyEventMap[K]): this
 
-  off(event: Event, callback: (...args: any[]) => void): this
+  once<K extends keyof UppyEventMap, TMeta extends IndexedObject<any>>(event: K, callback: UppyEventMap<TMeta>[K]): this
 
-  off(event: Event, callback: (...args: any[]) => void): this
+  off<K extends keyof UppyEventMap>(event: K, callback: UppyEventMap[K]): this
 
   /**
    * For use by plugins only.
    */
-  emit(event: Event, ...args: any[]): void
+  emit(event: string, ...args: any[]): void
 
   updateAll(state: Record<string, unknown>): void
 

+ 11 - 4
packages/@uppy/core/types/index.test-d.ts

@@ -87,11 +87,18 @@ type anyObject = Record<string, unknown>
   uppy.once('upload', () => {})
   uppy.once('complete', () => {})
   uppy.once('error', () => {})
-
-  // can register listeners on custom events
-  uppy.on('dashboard:modal-closed', () => {})
-  uppy.once('dashboard:modal-closed', () => {})
   /* eslint-enable @typescript-eslint/no-empty-function */
+
+  // Normal event signature
+  uppy.on('complete', (result) => {
+    const successResults = result.successful
+  })
+
+  // Meta signature
+  type Meta = {myCustomMetadata: string}
+  uppy.on<'complete', Meta>('complete', (result) => {
+    const meta = result.successful[0].meta.myCustomMetadata
+  })
 }
 
 {

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

@@ -17,8 +17,8 @@ function EditButton ({
       <button
         className="uppy-u-reset uppy-Dashboard-Item-action uppy-Dashboard-Item-action--edit"
         type="button"
-        aria-label={i18n('editFile', { file: file.meta.name })}
-        title={i18n('editFile', { file: file.meta.name })}
+        aria-label={i18n('editFileWithFilename', { file: file.meta.name })}
+        title={i18n('editFileWithFilename', { file: file.meta.name })}
         onClick={() => onClick()}
       >
         <svg aria-hidden="true" focusable="false" className="uppy-c-icon" width="14" height="14" viewBox="0 0 14 14">

+ 7 - 6
packages/@uppy/dashboard/src/index.js

@@ -62,7 +62,8 @@ module.exports = class Dashboard extends UIPlugin {
         back: 'Back',
         addMore: 'Add more',
         removeFile: 'Remove file %{file}',
-        editFile: 'Edit file %{file}',
+        editFile: 'Edit file',
+        editFileWithFilename: 'Edit file %{file}',
         editing: 'Editing %{file}',
         finishEditingFile: 'Finish editing file',
         save: 'Save',
@@ -205,6 +206,7 @@ module.exports = class Dashboard extends UIPlugin {
   }
 
   hideAllPanels = () => {
+    const state = this.getPluginState()
     const update = {
       activePickerPanel: false,
       showAddFilesPanel: false,
@@ -213,11 +215,10 @@ module.exports = class Dashboard extends UIPlugin {
       showFileEditor: false,
     }
 
-    const current = this.getPluginState()
-    if (current.activePickerPanel === update.activePickerPanel
-        && current.showAddFilesPanel === update.showAddFilesPanel
-        && current.showFileEditor === update.showFileEditor
-        && current.activeOverlayType === update.activeOverlayType) {
+    if (state.activePickerPanel === update.activePickerPanel
+        && state.showAddFilesPanel === update.showAddFilesPanel
+        && state.showFileEditor === update.showFileEditor
+        && state.activeOverlayType === update.activeOverlayType) {
       // avoid doing a state update if nothing changed
       return
     }

+ 14 - 1
packages/@uppy/dashboard/types/index.d.ts

@@ -1,4 +1,4 @@
-import type { PluginOptions, UIPlugin, PluginTarget, UppyFile } from '@uppy/core'
+import type { PluginOptions, UIPlugin, PluginTarget, UppyFile, GenericEventCallback } from '@uppy/core'
 import type { StatusBarLocale } from '@uppy/status-bar'
 import DashboardLocale from './generatedLocale'
 
@@ -75,3 +75,16 @@ declare class Dashboard extends UIPlugin<DashboardOptions> {
 }
 
 export default Dashboard
+
+// Events
+
+export type DashboardFileEditStartCallback<TMeta> = (file: UppyFile<TMeta>) => void;
+export type DashboardFileEditCompleteCallback<TMeta> = (file: UppyFile<TMeta>) => void;
+declare module '@uppy/core' {
+  export interface UppyEventMap<TMeta> {
+    'dashboard:modal-open': GenericEventCallback
+    'dashboard:modal-closed': GenericEventCallback
+    'dashboard:file-edit-state': DashboardFileEditStartCallback<TMeta>
+    'dashboard:file-edit-complete': DashboardFileEditCompleteCallback<TMeta>
+  }
+}

+ 4 - 0
packages/@uppy/dashboard/types/index.test-d.ts

@@ -48,6 +48,10 @@ import Dashboard from '..'
       },
     ],
   })
+
+  uppy.on('dashboard:file-edit-state', (file) => {
+    const fileName = file.name
+  })
 }
 
 {

+ 3 - 3
packages/@uppy/drag-drop/types/index.d.ts

@@ -9,9 +9,9 @@ export interface DragDropOptions extends PluginOptions {
   height?: string | number
   note?: string
   locale?: DragDropLocale
-  onDragOver?: (event: MouseEvent) => void
-  onDragLeave?: (event: MouseEvent) => void
-  onDrop?: (event: MouseEvent) => void
+  onDragOver?: (event: DragEvent) => void
+  onDragLeave?: (event: DragEvent) => void
+  onDrop?: (event: DragEvent) => void
 }
 
 declare class DragDrop extends UIPlugin<DragDropOptions> {}

+ 13 - 1
packages/@uppy/image-editor/types/index.d.ts

@@ -1,4 +1,4 @@
-import type { PluginOptions, UIPlugin, PluginTarget } from '@uppy/core'
+import type { PluginOptions, UIPlugin, PluginTarget, UppyFile } from '@uppy/core'
 import type Cropper from 'cropperjs'
 import ImageEditorLocale from './generatedLocale'
 
@@ -29,3 +29,15 @@ export interface ImageEditorOptions extends PluginOptions {
 declare class ImageEditor extends UIPlugin<ImageEditorOptions> {}
 
 export default ImageEditor
+
+// Events
+
+export type FileEditorStartCallback<TMeta> = (file: UppyFile<TMeta>) => void;
+export type FileEditorCompleteCallback<TMeta> = (updatedFile: UppyFile<TMeta>) => void;
+
+declare module '@uppy/core' {
+  export interface UppyEventMap<TMeta> {
+    'file-editor:start' : FileEditorStartCallback<TMeta>
+    'file-editor:complete': FileEditorCompleteCallback<TMeta>
+  }
+}

+ 16 - 1
packages/@uppy/image-editor/types/index.test-d.ts

@@ -1,2 +1,17 @@
-// import ImageEditor from '..'
 // TODO implement
+
+import Uppy from '@uppy/core'
+import ImageEditor from '..'
+
+{
+  const uppy = new Uppy()
+
+  uppy.use(ImageEditor)
+
+  uppy.on('file-editor:start', (file) => {
+    const fileName = file.name
+  })
+  uppy.on('file-editor:complete', (file) => {
+    const fileName = file.name
+  })
+}

+ 2 - 1
packages/@uppy/locales/src/en_US.js

@@ -47,7 +47,8 @@ en_US.strings = {
   dropPasteImportBoth: 'Drop files here, %{browseFiles}, %{browseFolders} or import from:',
   dropPasteImportFiles: 'Drop files here, %{browseFiles} or import from:',
   dropPasteImportFolders: 'Drop files here, %{browseFolders} or import from:',
-  editFile: 'Edit file %{file}',
+  editFile: 'Edit file',
+  editFileWithFilename: 'Edit file %{file}',
   editing: 'Editing %{file}',
   emptyFolderAdded: 'No files were added from empty folder',
   encoding: 'Encoding...',

+ 2 - 1
packages/@uppy/locales/src/fr_FR.js

@@ -41,7 +41,8 @@ fr_FR.strings = {
   dropPasteImportBoth: 'Déposer les fichiers ici, coller, %{browse} ou importer de',
   dropPasteImportFiles: 'Déposer les fichiers ici, coller, %{browse} ou importer de',
   dropPasteImportFolders: 'Déposer les fichiers ici, coller, %{browse} ou importer de',
-  editFile: 'Modifier le fichier %{file}',
+  editFile: 'Modifier le fichier',
+  editFileWithFilename: 'Modifier le fichier %{file}',
   editing: 'Modification en cours de %{file}',
   emptyFolderAdded: 'Aucun fichier n\'a été ajouté depuis un dossier vide',
   encoding: 'Traitement...',

+ 2 - 1
packages/@uppy/locales/src/nl_NL.js

@@ -35,7 +35,8 @@ nl_NL.strings = {
   dropPasteImportBoth: 'Sleep hier je bestanden naartoe, plak, %{browse} of importeer vanuit',
   dropPasteImportFiles: 'Sleep hier je bestanden naartoe, plak, %{browse} of importeer vanuit',
   dropPasteImportFolders: 'Sleep hier je bestanden naartoe, plak, %{browse} of importeer vanuit',
-  editFile: 'Bestand aanpassen %{file}',
+  editFile: 'Bestand aanpassen',
+  editFileWithFilename: 'Bestand aanpassen %{file}',
   editing: 'Bezig %{file} aan te passen',
   emptyFolderAdded: 'Er werden geen bestanden toegevoegd uit de lege map',
   encoding: 'Coderen...',

+ 2 - 2
packages/@uppy/svelte/src/components/DragDrop.svelte

@@ -1,6 +1,6 @@
 <script lang="ts">
   import { onMount, onDestroy } from 'svelte'
-  import type { Uppy, Plugin } from '@uppy/core';
+  import type { Uppy } from '@uppy/core';
   import DragDropPlugin from '@uppy/drag-drop'
 
   let container: HTMLElement;
@@ -36,4 +36,4 @@
     uppy.setOptions(options)
   }
 </script>
-<div bind:this={container} />
+<div bind:this={container} />

+ 12 - 2
packages/@uppy/thumbnail-generator/types/index.d.ts

@@ -1,4 +1,4 @@
-import type { PluginOptions, UIPlugin } from '@uppy/core'
+import type { PluginOptions, UIPlugin, UppyFile } from '@uppy/core'
 
 import ThumbnailGeneratorLocale from './generatedLocale'
 
@@ -11,6 +11,16 @@ interface ThumbnailGeneratorOptions extends PluginOptions {
     locale?: ThumbnailGeneratorLocale,
 }
 
-declare class ThumbnailGenerator extends UIPlugin<ThumbnailGeneratorOptions> {}
+declare class ThumbnailGenerator extends UIPlugin<ThumbnailGeneratorOptions> { }
 
 export default ThumbnailGenerator
+
+// Events
+
+export type ThumbnailGeneratedCallback<TMeta> = (file: UppyFile<TMeta>, preview: string) => void;
+
+declare module '@uppy/core' {
+    export interface UppyEventMap<TMeta> {
+        'thumbnail:generated' : ThumbnailGeneratedCallback<TMeta>
+    }
+}

+ 7 - 0
packages/@uppy/thumbnail-generator/types/index.test-d.ts

@@ -15,4 +15,11 @@ import ThumbnailGenerator from '..'
       },
     },
   })
+
+  uppy.on('thumbnail:generated', (file, preview) => {
+    const img = document.createElement('img')
+    img.src = preview
+    img.width = 100
+    document.body.appendChild(img)
+  })
 }

+ 21 - 2
packages/@uppy/transloadit/types/index.d.ts

@@ -1,7 +1,7 @@
 import type { PluginOptions, UppyFile, BasePlugin } from '@uppy/core'
 import TransloaditLocale from './generatedLocale'
 
-  interface FileInfo {
+export interface FileInfo {
     id: string,
     name: string,
     basename: string,
@@ -27,7 +27,8 @@ export interface Result extends FileInfo {
     cost: number,
     execTime: number,
     queue: string,
-    queueTime: number
+    queueTime: number,
+    localId: string | null
   }
 
 export interface Assembly {
@@ -127,3 +128,21 @@ declare class Transloadit extends BasePlugin<TransloaditOptions> {
 }
 
 export default Transloadit
+
+// Events
+
+export type TransloaditAssemblyCreatedCallback = (assembly: Assembly, fileIDs: string[]) => void;
+export type TransloaditUploadedCallback = (file: FileInfo, assembly: Assembly) => void;
+export type TransloaditAssemblyExecutingCallback = (assembly: Assembly) => void;
+export type TransloaditResultCallback = (stepName: string, result: Result, assembly: Assembly) => void;
+export type TransloaditCompleteCallback = (assembly: Assembly) => void;
+
+declare module '@uppy/core' {
+  export interface UppyEventMap {
+    'transloadit:assembly-created': TransloaditAssemblyCreatedCallback
+    'transloadit:upload': TransloaditUploadedCallback
+    'transloadit:assembly-executing': TransloaditAssemblyExecutingCallback
+    'transloadit:result': TransloaditResultCallback
+    'transloadit:complete': TransloaditCompleteCallback
+  }
+}

+ 8 - 0
packages/@uppy/transloadit/types/index.test-d.ts

@@ -25,6 +25,14 @@ const validParams = {
       steps: {},
     },
   })
+  // Access to both transloadit events and core events
+  uppy.on('transloadit:assembly-created', (assembly, fileIDs) => {
+    const status = assembly.ok
+  })
+
+  uppy.on('complete', (result) => {
+    const success = result.successful
+  })
 }
 
 {

+ 8 - 7
packages/@uppy/utils/types/index.d.ts

@@ -250,6 +250,13 @@ declare module '@uppy/utils' {
     [key: number]: T
   }
   export type InternalMetadata = { name: string; type?: string }
+  export interface FileProgress  {
+    uploadStarted: number | null
+    uploadComplete: boolean
+    percentage: number
+    bytesUploaded: number
+    bytesTotal: number
+  }
   export interface UppyFile<
     TMeta = IndexedObject<any>,
     TBody = IndexedObject<any>
@@ -262,13 +269,7 @@ declare module '@uppy/utils' {
     meta: InternalMetadata & TMeta
     name: string
     preview?: string
-    progress?: {
-      uploadStarted: number | null
-      uploadComplete: boolean
-      percentage: number
-      bytesUploaded: number
-      bytesTotal: number
-    }
+    progress?: FileProgress
     remote?: {
       host: string
       url: string

+ 26 - 11
packages/@uppy/webcam/src/index.js

@@ -1,6 +1,5 @@
 const { h } = require('preact')
 const { UIPlugin } = require('@uppy/core')
-const Translator = require('@uppy/utils/lib/Translator')
 const getFileTypeExtension = require('@uppy/utils/lib/getFileTypeExtension')
 const mimeTypes = require('@uppy/utils/lib/mimeTypes')
 const canvasToBlob = require('@uppy/utils/lib/canvasToBlob')
@@ -393,19 +392,34 @@ module.exports = class Webcam extends UIPlugin {
     }
   }
 
-  stop () {
+  async stop () {
     if (this.stream) {
-      this.stream.getAudioTracks().forEach((track) => {
-        track.stop()
-      })
-      this.stream.getVideoTracks().forEach((track) => {
-        track.stop()
+      const audioTracks = this.stream.getAudioTracks()
+      const videoTracks = this.stream.getVideoTracks()
+
+      audioTracks.concat(videoTracks).forEach((track) => track.stop())
+    }
+
+    if (this.recorder) {
+      await new Promise((resolve) => {
+        this.recorder.addEventListener('stop', resolve, { once: true })
+        this.recorder.stop()
+
+        if (this.opts.showRecordingLength) {
+          clearInterval(this.recordingLengthTimer)
+        }
       })
     }
+
+    this.recordingChunks = null
+    this.recorder = null
     this.webcamActive = false
     this.stream = null
+
     this.setPluginState({
       recordedVideo: null,
+      isRecording: false,
+      recordingLengthSeconds: 0,
     })
   }
 
@@ -624,10 +638,11 @@ module.exports = class Webcam extends UIPlugin {
   }
 
   uninstall () {
-    if (this.stream) {
-      this.stop()
-    }
-
+    this.stop()
     this.unmount()
   }
+
+  onUnmount () {
+    this.stop()
+  }
 }

+ 46 - 33
website/src/docs/writing-plugins.md

@@ -17,14 +17,18 @@ See a [full example of a plugin](#Example-of-a-custom-plugin) below.
 
 ## Creating A Plugin
 
-Plugins are classes that extend from Uppy's `Plugin` class. Each plugin has an `id` and a `type`. `id`s are used to uniquely identify plugins. A `type` can be anything—some plugins use `type`s to determine whether to do something to some other plugin. For example, when targeting plugins at the built-in `Dashboard` plugin, the Dashboard uses the `type` to figure out where to mount different UI elements. `'acquirer'`-type plugins are mounted into the tab bar, while `'progressindicator'`-type plugins are mounted into the progress bar area.
+Uppy has two classes to create plugins with. `BasePlugin` for plugins that don't require an user interface, and `UIPlugin` for onces that do.
+Each plugin has an `id` and a `type`. `id`s are used to uniquely identify plugins.
+A `type` can be anything—some plugins use `type`s to determine whether to do something to some other plugin.
+For example, when targeting plugins at the built-in `Dashboard` plugin, the Dashboard uses the `type` to figure out where to mount different UI elements.
+`'acquirer'`-type plugins are mounted into the tab bar, while `'progressindicator'`-type plugins are mounted into the progress bar area.
 
 The plugin constructor receives the Uppy instance in the first parameter, and any options passed to `uppy.use()` in the second parameter.
 
 ```js
-import { UIPlugin } from '@uppy/core'
+import { BasePlugin } from '@uppy/core'
 
-export default class MyPlugin extends UIPlugin {
+export default class MyPlugin extends BasePlugin {
   constructor (uppy, opts) {
     super(uppy, opts)
     this.id = opts.id || 'MyPlugin'
@@ -39,7 +43,9 @@ Plugins can implement methods in order to execute certain tasks. The most import
 
 All of the below methods are optional! Only implement the methods you need.
 
-### `install()`
+### `BasePlugin`
+
+#### `install()`
 
 Called when the plugin is `.use`d. Do any setup work here, like attaching events or adding [upload hooks](#Upload-Hooks).
 
@@ -53,7 +59,7 @@ export default class MyPlugin extends UIPlugin {
 }
 ```
 
-### `uninstall()`
+#### `uninstall()`
 
 Called when the plugin is removed, or the Uppy instance is closed. This should undo all of the work done in the `install()` method.
 
@@ -67,10 +73,41 @@ export default class MyPlugin extends UIPlugin {
 }
 ```
 
-### `update(state)`
+#### `afterUpdate()`
+
+Called after every state update with a debounce, after everything has mounted.
+
+#### `addTarget()`
+
+Use this to add your plugin to another plugin's target. This is what `@uppy/dashboard` uses to add other plugins to its UI.
+
+### `UIPlugin`
+
+`UIPlugin` extends the `BasePlugin` class so it will also contain all the above methods.
+
+#### `mount(target)`
+
+Mount this plugin to the `target` element. `target` can be a CSS query selector, a DOM element, or another Plugin. If `target` is a Plugin, the source (current) plugin will register with the target plugin, and the latter can decide how and where to render the source plugin.
+
+This method can be overridden to support for different render engines.
+
+#### `render()`
+
+Render this plugin's UI. Uppy uses [Preact](https://preactjs.com) as its view engine, so `render()` should return a Preact element.
+`render` is automatically called by Uppy on each state change.
+
+#### `onMount()`
+
+Called after Preact has rendered the components of the plugin. Can be used to perform additional side-effects.
+
+#### `update(state)`
 
 Called on each state update. You will rarely need to use this, it is mostly handy if you want to build a UI plugin using something other than Preact.
 
+#### `onUnmount()`
+
+Called after the elements have been removed from the DOM. Can be used to perform additional (clean up) side-effects.
+
 ## Upload Hooks
 
 When creating an upload, Uppy runs files through an upload pipeline. The pipeline consists of three parts, each of which can be hooked into: Preprocessing, Uploading, and Postprocessing. Preprocessors can be used to configure uploader plugins, encrypt files, resize images, etc., before uploading them. Uploaders do the actual uploading work, such as creating an XMLHttpRequest object and sending the file. Postprocessors do their work after files have been uploaded completely. This could be anything from waiting for a file to propagate across a CDN, to sending another request to relate some metadata to the file.
@@ -82,7 +119,7 @@ Additionally, upload hooks can fire events to signal progress.
 When adding hooks, make sure to bind the hook `fn` beforehand! Otherwise, it will be impossible to remove. For example:
 
 ```js
-class MyPlugin extends UIPlugin {
+class MyPlugin extends BasePlugin {
   constructor (uppy, opts) {
     super(uppy, opts)
     this.id = opts.id || 'MyPlugin'
@@ -164,35 +201,11 @@ When `mode` is `'determinate'`, also add the `value` property:
 
  `err` is an `Error` object. `fileID` can optionally which file fails to inform the user.
 
-## UI Plugins
-
-UI Plugins can be used to show a user interface. Uppy plugins use [preact](https://preactjs.com) v8.2.9 for rendering. preact is a very small React-like library that works really well with Uppy's state architecture. Uppy implements preact rendering in the `mount(target)` and `update()` plugin methods, so if you want to write a custom UI plugin using some other library, you can override those methods.
-
-> **Only** `preact@8.2.9` can be used for Uppy plugins. In Uppy 2.0, the restriction will be changed to a newer range of preact versions. For now, specify the dependency with a fixed version number:
-> ```json
-> "dependencies": {
->   "preact": "8.2.9"
-> }
-> ```
-
-Plugins can implement certain methods to do so, that will be called by Uppy when necessary:
-
-### `mount(target)`
-
-Mount this plugin to the `target` element. `target` can be a CSS query selector, a DOM element, or another Plugin. If `target` is a Plugin, the source (current) plugin will register with the target plugin, and the latter can decide how and where to render the source plugin.
-
-This method can be overridden to support for different render engines.
-
-### `render()`
-
-Render this plugin's UI. Uppy uses [Preact](https://preactjs.com) as its view engine, so `render()` should return a Preact element.
-`render` is automatically called by Uppy on each state change.
-
-Note that we are looking into ways to make Uppy's render engine agnostic, so that plugins can choose their own favourite library—whether it's Preact, Choo, jQuery, or anything else. This means that the `render()` API may change in the future, but we will detail exactly what you need to do on the [blog](https://uppy.io/blog) if and when that happens.
 
 ### JSX
 
-Since Uppy uses Preact and not React, the default Babel configuration for JSX elements does not work. You have to import the Preact `h` function and tell Babel to use it by adding a `/** @jsx h */` comment at the top of the file.
+Since Uppy uses Preact and not React, the default Babel configuration for JSX elements does not work.
+You have to import the Preact `h` function and tell Babel to use it by adding a `/** @jsx h */` comment at the top of the file.
 
 See the Preact [Getting Started Guide](https://preactjs.com/guide/getting-started) for more on Babel and JSX.