diff --git a/CHANGELOG.md b/CHANGELOG.md index 3e6ba4aa75..d70474b5d4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -61,6 +61,12 @@ and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0. - Added bubble nub and style options, by [@compulim](https://github.com/compulim), in PR [#2137](https://github.com/Microsoft/BotFramework-WebChat/pull/2137) - Fix [#1808](https://github.com/microsoft/BotFramework-WebChat/issues/1808). Added documentation on activity types, by [@corinagum](https://github.com/corinagum) in PR [#2228](https://github.com/microsoft/BotFramework-WebChat/pull/2228) +- Make thumbnails when uploading GIF/JPEG/PNG and store it in `channelData.attachmentThumbnails`, by [@compulim](https://github.com/compulim), in PR [#2206](https://github.com/microsoft/BotFramework-WebChat/pull/2206), requires modern browsers with following features: + - [Web Workers API](https://developer.mozilla.org/en-US/docs/Web/API/Web_Workers_API) + - [`createImageBitmap`](https://developer.mozilla.org/en-US/docs/Web/API/WindowOrWorkerGlobalScope/createImageBitmap) + - [`MessageChannel`](https://developer.mozilla.org/en-US/docs/Web/API/MessageChannel)/[`MessagePort`](https://developer.mozilla.org/en-US/docs/Web/API/MessagePort) + - [`OffscreenCanvas`](https://developer.mozilla.org/en-US/docs/Web/API/OffscreenCanvas) + - Specifically [`OffscreenCanvas.getContext('2d')`](https://developer.mozilla.org/en-US/docs/Web/API/OffscreenCanvas/getContext) ### Samples diff --git a/Dockerfile-selenium b/Dockerfile-selenium new file mode 100644 index 0000000000..9d03ac44d3 --- /dev/null +++ b/Dockerfile-selenium @@ -0,0 +1,6 @@ +# https://github.com/SeleniumHQ/docker-selenium +# https://hub.docker.com/r/selenium/standalone-chrome/tags/ + +FROM selenium/standalone-chrome:3.141.59-radium + +ADD __tests__/setup/local ~/Downloads diff --git a/__tests__/__image_snapshots__/chrome-docker/upload-js-upload-a-picture-1-snap.png b/__tests__/__image_snapshots__/chrome-docker/upload-js-upload-a-picture-1-snap.png new file mode 100644 index 0000000000..9eb248168d Binary files /dev/null and b/__tests__/__image_snapshots__/chrome-docker/upload-js-upload-a-picture-1-snap.png differ diff --git a/__tests__/__image_snapshots__/chrome-docker/upload-js-upload-a-picture-with-custom-thumbnail-disabled-1-snap.png b/__tests__/__image_snapshots__/chrome-docker/upload-js-upload-a-picture-with-custom-thumbnail-disabled-1-snap.png new file mode 100644 index 0000000000..39a5acab3c Binary files /dev/null and b/__tests__/__image_snapshots__/chrome-docker/upload-js-upload-a-picture-with-custom-thumbnail-disabled-1-snap.png differ diff --git a/__tests__/__image_snapshots__/chrome-docker/upload-js-upload-a-picture-with-custom-thumbnail-quality-1-snap.png b/__tests__/__image_snapshots__/chrome-docker/upload-js-upload-a-picture-with-custom-thumbnail-quality-1-snap.png new file mode 100644 index 0000000000..dc98de6bad Binary files /dev/null and b/__tests__/__image_snapshots__/chrome-docker/upload-js-upload-a-picture-with-custom-thumbnail-quality-1-snap.png differ diff --git a/__tests__/__image_snapshots__/chrome-docker/upload-js-upload-a-picture-with-custom-thumbnail-size-1-snap.png b/__tests__/__image_snapshots__/chrome-docker/upload-js-upload-a-picture-with-custom-thumbnail-size-1-snap.png new file mode 100644 index 0000000000..0ad694ebee Binary files /dev/null and b/__tests__/__image_snapshots__/chrome-docker/upload-js-upload-a-picture-with-custom-thumbnail-size-1-snap.png differ diff --git a/__tests__/__image_snapshots__/chrome-docker/upload-js-upload-a-picture-without-web-worker-1-snap.png b/__tests__/__image_snapshots__/chrome-docker/upload-js-upload-a-picture-without-web-worker-1-snap.png new file mode 100644 index 0000000000..79d89b2150 Binary files /dev/null and b/__tests__/__image_snapshots__/chrome-docker/upload-js-upload-a-picture-without-web-worker-1-snap.png differ diff --git a/__tests__/__image_snapshots__/chrome-docker/upload-js-upload-a-picture-without-web-worker-with-custom-thumbnail-quality-1-snap.png b/__tests__/__image_snapshots__/chrome-docker/upload-js-upload-a-picture-without-web-worker-with-custom-thumbnail-quality-1-snap.png new file mode 100644 index 0000000000..6e0a1dc3b6 Binary files /dev/null and b/__tests__/__image_snapshots__/chrome-docker/upload-js-upload-a-picture-without-web-worker-with-custom-thumbnail-quality-1-snap.png differ diff --git a/__tests__/__image_snapshots__/chrome-docker/upload-js-upload-a-picture-without-web-worker-with-custom-thumbnail-size-1-snap.png b/__tests__/__image_snapshots__/chrome-docker/upload-js-upload-a-picture-without-web-worker-with-custom-thumbnail-size-1-snap.png new file mode 100644 index 0000000000..e7fa72d491 Binary files /dev/null and b/__tests__/__image_snapshots__/chrome-docker/upload-js-upload-a-picture-without-web-worker-with-custom-thumbnail-size-1-snap.png differ diff --git a/__tests__/__image_snapshots__/chrome-docker/upload-js-upload-a-zip-file-1-snap.png b/__tests__/__image_snapshots__/chrome-docker/upload-js-upload-a-zip-file-1-snap.png new file mode 100644 index 0000000000..217bff1876 Binary files /dev/null and b/__tests__/__image_snapshots__/chrome-docker/upload-js-upload-a-zip-file-1-snap.png differ diff --git a/__tests__/setup/local/empty.mp3 b/__tests__/setup/local/empty.mp3 new file mode 100644 index 0000000000..3c4ce03422 Binary files /dev/null and b/__tests__/setup/local/empty.mp3 differ diff --git a/__tests__/setup/local/empty.pdf b/__tests__/setup/local/empty.pdf new file mode 100644 index 0000000000..40bc92e34b Binary files /dev/null and b/__tests__/setup/local/empty.pdf differ diff --git a/__tests__/setup/local/empty.zip b/__tests__/setup/local/empty.zip new file mode 100644 index 0000000000..37e10a926f Binary files /dev/null and b/__tests__/setup/local/empty.zip differ diff --git a/__tests__/setup/local/seaofthieves.jpg b/__tests__/setup/local/seaofthieves.jpg new file mode 100644 index 0000000000..9be01fe155 Binary files /dev/null and b/__tests__/setup/local/seaofthieves.jpg differ diff --git a/__tests__/setup/pageObjects/getActivityElements.js b/__tests__/setup/pageObjects/getActivityElements.js new file mode 100644 index 0000000000..d922de821d --- /dev/null +++ b/__tests__/setup/pageObjects/getActivityElements.js @@ -0,0 +1,5 @@ +import { By } from 'selenium-webdriver'; + +export default async function getActivityElements(driver) { + return await driver.findElements(By.css(`[role="listitem"]`)); +} diff --git a/__tests__/setup/pageObjects/getSendBoxTextBox.js b/__tests__/setup/pageObjects/getSendBoxTextBox.js index 9a53919451..8f8861c6dc 100644 --- a/__tests__/setup/pageObjects/getSendBoxTextBox.js +++ b/__tests__/setup/pageObjects/getSendBoxTextBox.js @@ -1,5 +1,5 @@ import { By } from 'selenium-webdriver'; -export default async function isRecognizingSpeech(driver) { +export default async function getSendBoxTextBox(driver) { return await driver.findElement(By.css('[role="form"] > * > form > input[type="text"]')); } diff --git a/__tests__/setup/pageObjects/getUploadButton.js b/__tests__/setup/pageObjects/getUploadButton.js new file mode 100644 index 0000000000..45343fe43f --- /dev/null +++ b/__tests__/setup/pageObjects/getUploadButton.js @@ -0,0 +1,5 @@ +import { By } from 'selenium-webdriver'; + +export default async function getUploadButton(driver) { + return await driver.findElement(By.css('input[type="file"]')); +} diff --git a/__tests__/setup/pageObjects/index.js b/__tests__/setup/pageObjects/index.js index 3620a543f0..edbea7ec4b 100644 --- a/__tests__/setup/pageObjects/index.js +++ b/__tests__/setup/pageObjects/index.js @@ -1,13 +1,16 @@ import dispatchAction from './dispatchAction'; import endSpeechSynthesize from './endSpeechSynthesize'; import executePromiseScript from './executePromiseScript'; +import getActivityElements from './getActivityElements'; import getMicrophoneButton from './getMicrophoneButton'; import getSendBoxTextBox from './getSendBoxTextBox'; import getStore from './getStore'; +import getUploadButton from './getUploadButton'; import hasPendingSpeechSynthesisUtterance from './hasPendingSpeechSynthesisUtterance'; import isRecognizingSpeech from './isRecognizingSpeech'; import pingBot from './pingBot'; import putSpeechRecognitionResult from './putSpeechRecognitionResult'; +import sendFile from './sendFile'; import sendMessageViaMicrophone from './sendMessageViaMicrophone'; import sendMessageViaSendBox from './sendMessageViaSendBox'; import startSpeechSynthesize from './startSpeechSynthesize'; @@ -26,13 +29,16 @@ export default function pageObjects(driver) { dispatchAction, endSpeechSynthesize, executePromiseScript, + getActivityElements, getMicrophoneButton, getSendBoxTextBox, getStore, + getUploadButton, hasPendingSpeechSynthesisUtterance, isRecognizingSpeech, pingBot, putSpeechRecognitionResult, + sendFile, sendMessageViaMicrophone, sendMessageViaSendBox, startSpeechSynthesize diff --git a/__tests__/setup/pageObjects/sendFile.js b/__tests__/setup/pageObjects/sendFile.js new file mode 100644 index 0000000000..7ef1418f41 --- /dev/null +++ b/__tests__/setup/pageObjects/sendFile.js @@ -0,0 +1,28 @@ +import { join, posix } from 'path'; +import { timeouts } from '../../constants.json'; +import allOutgoingActivitiesSent from '../conditions/allOutgoingActivitiesSent'; +import getActivityElements from './getActivityElements'; +import getUploadButton from './getUploadButton'; +import minNumActivitiesShown from '../conditions/minNumActivitiesShown.js'; + +function resolveDockerFile(filename) { + return posix.join('/~/Downloads', filename); +} + +function resolveLocalFile(filename) { + return join(__dirname, '../local', filename); +} + +export default async function sendFile(driver, filename, { waitForSend = true } = {}) { + const uploadButton = await getUploadButton(driver); + const isUnderDocker = !!(await driver.getCapabilities()).get('webdriver.remote.sessionid'); + + // The send file function is asynchronous, it doesn't send immediately until thumbnails are generated. + // We will save the numActivities, anticipate for numActivities + 1, then wait until everything is sent + const numActivities = (await getActivityElements(driver)).length; + + await uploadButton.sendKeys(isUnderDocker ? resolveDockerFile(filename) : resolveLocalFile(filename)); + + await driver.wait(minNumActivitiesShown(numActivities + 1)); + waitForSend && (await driver.wait(allOutgoingActivitiesSent(), timeouts.directLine)); +} diff --git a/__tests__/upload.js b/__tests__/upload.js new file mode 100644 index 0000000000..516a49e79c --- /dev/null +++ b/__tests__/upload.js @@ -0,0 +1,169 @@ +import { imageSnapshotOptions, timeouts } from './constants.json'; + +import allImagesLoaded from './setup/conditions/allImagesLoaded'; +import minNumActivitiesShown from './setup/conditions/minNumActivitiesShown'; +import uiConnected from './setup/conditions/uiConnected'; + +// selenium-webdriver API doc: +// https://seleniumhq.github.io/selenium/docs/api/javascript/module/selenium-webdriver/index_exports_WebDriver.html + +jest.setTimeout(timeouts.test); + +describe('upload a picture', () => { + test('', async () => { + const { driver, pageObjects } = await setupWebDriver(); + + await driver.wait(uiConnected(), timeouts.directLine); + + await pageObjects.sendFile('seaofthieves.jpg'); + await driver.wait(minNumActivitiesShown(2)); + await driver.wait(allImagesLoaded()); + + const base64PNG = await driver.takeScreenshot(); + + expect(base64PNG).toMatchImageSnapshot(imageSnapshotOptions); + }); + + test('with custom thumbnail size', async () => { + const { driver, pageObjects } = await setupWebDriver({ + props: { + styleOptions: { + uploadThumbnailContentType: 'image/png', + uploadThumbnailHeight: 60, + uploadThumbnailWidth: 120 + } + } + }); + + await driver.wait(uiConnected(), timeouts.directLine); + + await pageObjects.sendFile('seaofthieves.jpg'); + await driver.wait(minNumActivitiesShown(2)); + await driver.wait(allImagesLoaded()); + + const base64PNG = await driver.takeScreenshot(); + + expect(base64PNG).toMatchImageSnapshot(imageSnapshotOptions); + }); + + test('with custom thumbnail quality', async () => { + const { driver, pageObjects } = await setupWebDriver({ + props: { + styleOptions: { + uploadThumbnailQuality: 0.1 + } + } + }); + + await driver.wait(uiConnected(), timeouts.directLine); + + await pageObjects.sendFile('seaofthieves.jpg'); + await driver.wait(minNumActivitiesShown(2)); + await driver.wait(allImagesLoaded()); + + const base64PNG = await driver.takeScreenshot(); + + expect(base64PNG).toMatchImageSnapshot(imageSnapshotOptions); + }); + + test('with custom thumbnail disabled', async () => { + const { driver, pageObjects } = await setupWebDriver({ + props: { + styleOptions: { + enableUploadThumbnail: false + } + } + }); + + await driver.wait(uiConnected(), timeouts.directLine); + + await pageObjects.sendFile('seaofthieves.jpg'); + await driver.wait(minNumActivitiesShown(2)); + await driver.wait(allImagesLoaded()); + + const base64PNG = await driver.takeScreenshot(); + + expect(base64PNG).toMatchImageSnapshot(imageSnapshotOptions); + }); + + describe('without Web Worker', () => { + test('', async () => { + const { driver, pageObjects } = await setupWebDriver(); + + await driver.executeScript(() => { + window.Worker = undefined; + }); + await driver.wait(uiConnected(), timeouts.directLine); + + await pageObjects.sendFile('seaofthieves.jpg'); + await driver.wait(minNumActivitiesShown(2)); + await driver.wait(allImagesLoaded()); + + const base64PNG = await driver.takeScreenshot(); + + expect(base64PNG).toMatchImageSnapshot(imageSnapshotOptions); + }); + + test('with custom thumbnail size', async () => { + const { driver, pageObjects } = await setupWebDriver({ + props: { + styleOptions: { + uploadThumbnailContentType: 'image/png', + uploadThumbnailHeight: 60, + uploadThumbnailWidth: 120 + } + } + }); + + await driver.executeScript(() => { + window.Worker = undefined; + }); + await driver.wait(uiConnected(), timeouts.directLine); + + await pageObjects.sendFile('seaofthieves.jpg'); + await driver.wait(minNumActivitiesShown(2)); + await driver.wait(allImagesLoaded()); + + const base64PNG = await driver.takeScreenshot(); + + expect(base64PNG).toMatchImageSnapshot(imageSnapshotOptions); + }); + + test('with custom thumbnail quality', async () => { + const { driver, pageObjects } = await setupWebDriver({ + props: { + styleOptions: { + uploadThumbnailQuality: 0.1 + } + } + }); + + await driver.executeScript(() => { + window.Worker = undefined; + }); + await driver.wait(uiConnected(), timeouts.directLine); + + await pageObjects.sendFile('seaofthieves.jpg'); + await driver.wait(minNumActivitiesShown(2)); + await driver.wait(allImagesLoaded()); + + const base64PNG = await driver.takeScreenshot(); + + expect(base64PNG).toMatchImageSnapshot(imageSnapshotOptions); + }); + }); +}); + +test('upload a ZIP file', async () => { + const { driver, pageObjects } = await setupWebDriver(); + + await driver.wait(uiConnected(), timeouts.directLine); + + await pageObjects.sendFile('empty.zip'); + await driver.wait(minNumActivitiesShown(2)); + await driver.wait(allImagesLoaded()); + + const base64PNG = await driver.takeScreenshot(); + + expect(base64PNG).toMatchImageSnapshot(imageSnapshotOptions); +}); diff --git a/docker-compose.yml b/docker-compose.yml index 94faa5b38b..620d7a3bf4 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -4,9 +4,9 @@ services: # On Windows, run with COMPOSE_CONVERT_WINDOWS_PATHS=1 chrome: - # https://github.com/SeleniumHQ/docker-selenium - # https://hub.docker.com/r/selenium/standalone-chrome/tags/ - image: selenium/standalone-chrome:3.141.59-radium + build: + context: ./ + dockerfile: Dockerfile-selenium networks: - selenium depends_on: diff --git a/packages/bundle/src/index-es5.ts b/packages/bundle/src/index-es5.ts index a7c3b5a1a7..1d61212669 100644 --- a/packages/bundle/src/index-es5.ts +++ b/packages/bundle/src/index-es5.ts @@ -15,6 +15,7 @@ import 'core-js/modules/es.math.sign'; import 'core-js/modules/es.number.is-finite'; import 'core-js/modules/es.object.assign'; import 'core-js/modules/es.promise'; +import 'core-js/modules/es.promise.finally'; import 'core-js/modules/es.string.starts-with'; import 'core-js/modules/es.symbol'; import 'url-search-params-polyfill'; diff --git a/packages/component/.babelrc b/packages/component/.babelrc index 64059c7b1d..22d4839fce 100644 --- a/packages/component/.babelrc +++ b/packages/component/.babelrc @@ -1,11 +1,25 @@ { "env": { "test": { + "exclude": [ + "src/**/*.worker.js" + ], "plugins": [ "babel-plugin-istanbul" ] } }, + "overrides": [{ + "test": "src/**/*.worker.js", + "plugins": [], + "presets": [ + ["@babel/preset-env", { + "targets": { + "chrome": "69" + } + }] + ] + }], "plugins": [ "@babel/proposal-object-rest-spread", [ diff --git a/packages/component/src/Attachment/ImageAttachment.js b/packages/component/src/Attachment/ImageAttachment.js index 4a782fb8f4..a289e2f054 100644 --- a/packages/component/src/Attachment/ImageAttachment.js +++ b/packages/component/src/Attachment/ImageAttachment.js @@ -3,9 +3,24 @@ import React from 'react'; import ImageContent from './ImageContent'; -const ImageAttachment = ({ attachment }) => ; +const ImageAttachment = ({ activity, attachment }) => { + const { attachmentThumbnails } = activity.channelData || {}; + + if (attachmentThumbnails) { + const attachmentThumbnail = attachmentThumbnails[activity.attachments.indexOf(attachment)]; + + if (attachmentThumbnail) { + return ; + } + } + + return ; +}; ImageAttachment.propTypes = { + activity: PropTypes.shape({ + attachments: PropTypes.array.isRequired + }).isRequired, attachment: PropTypes.shape({ contentUrl: PropTypes.string.isRequired, name: PropTypes.string diff --git a/packages/component/src/Dictation.js b/packages/component/src/Dictation.js index b0c4fa8fcc..527de52cce 100644 --- a/packages/component/src/Dictation.js +++ b/packages/component/src/Dictation.js @@ -6,7 +6,7 @@ import React from 'react'; import connectToWebChat from './connectToWebChat'; const { - DictateState: { DICTATING, IDLE, STARTING, STOPPING } + DictateState: { DICTATING, IDLE, STARTING } } = Constants; class Dictation extends React.Component { diff --git a/packages/component/src/SendBox/UploadButton.js b/packages/component/src/SendBox/UploadButton.js index 896195efba..b67d4c16b6 100644 --- a/packages/component/src/SendBox/UploadButton.js +++ b/packages/component/src/SendBox/UploadButton.js @@ -6,6 +6,7 @@ import React from 'react'; import { localize } from '../Localization/Localize'; import AttachmentIcon from './Assets/AttachmentIcon'; import connectToWebChat from '../connectToWebChat'; +import downscaleImageToDataURL from '../Utils/downscaleImageToDataURL'; import IconButton from './IconButton'; const ROOT_CSS = css({ @@ -22,22 +23,56 @@ const ROOT_CSS = css({ } }); +async function makeThumbnail(file, width, height, contentType, quality) { + if (/\.(gif|jpe?g|png)$/iu.test(file.name)) { + try { + return await downscaleImageToDataURL(file, width, height, contentType, quality); + } catch (error) { + console.warn(`Web Chat: Failed to downscale image due to ${error}.`); + } + } +} + const connectUploadButton = (...selectors) => connectToWebChat( - ({ disabled, language, sendFiles }) => ({ + ({ + disabled, + language, + sendFiles, + styleSet: { + options: { + enableUploadThumbnail, + uploadThumbnailContentType, + uploadThumbnailHeight, + uploadThumbnailQuality, + uploadThumbnailWidth + } + } + }) => ({ disabled, language, - sendFiles: files => { + sendFiles: async files => { if (files && files.length) { // TODO: [P3] We need to find revokeObjectURL on the UI side // Redux store should not know about the browser environment // One fix is to use ArrayBuffer instead of object URL, but that would requires change to DirectLineJS sendFiles( - [].map.call(files, file => ({ - name: file.name, - size: file.size, - url: window.URL.createObjectURL(file) - })) + await Promise.all( + [].map.call(files, async file => ({ + name: file.name, + size: file.size, + url: window.URL.createObjectURL(file), + ...(enableUploadThumbnail && { + thumbnail: await makeThumbnail( + file, + uploadThumbnailWidth, + uploadThumbnailHeight, + uploadThumbnailContentType, + uploadThumbnailQuality + ) + }) + })) + ) ); } } @@ -105,6 +140,13 @@ UploadButton.propTypes = { language: PropTypes.string.isRequired, sendFiles: PropTypes.func.isRequired, styleSet: PropTypes.shape({ + options: PropTypes.shape({ + enableUploadThumbnail: PropTypes.bool.isRequired, + uploadThumbnailContentType: PropTypes.string.isRequired, + uploadThumbnailHeight: PropTypes.number.isRequired, + uploadThumbnailQuality: PropTypes.number.isRequired, + uploadThumbnailWidth: PropTypes.number.isRequired + }).isRequired, uploadButton: PropTypes.any.isRequired }).isRequired }; diff --git a/packages/component/src/Styles/defaultStyleOptions.js b/packages/component/src/Styles/defaultStyleOptions.js index 84f2e76f92..45d5cc95f2 100644 --- a/packages/component/src/Styles/defaultStyleOptions.js +++ b/packages/component/src/Styles/defaultStyleOptions.js @@ -127,7 +127,13 @@ const DEFAULT_OPTIONS = { spinnerAnimationBackgroundImage: null, spinnerAnimationHeight: 16, spinnerAnimationWidth: 16, - spinnerAnimationPaddingRight: 12 + spinnerAnimationPaddingRight: 12, + + enableUploadThumbnail: true, + uploadThumbnailContentType: 'image/jpeg', + uploadThumbnailHeight: 360, + uploadThumbnailQuality: 0.6, + uploadThumbnailWidth: 720 }; export default DEFAULT_OPTIONS; diff --git a/packages/component/src/Utils/blobToArrayBuffer.js b/packages/component/src/Utils/blobToArrayBuffer.js new file mode 100644 index 0000000000..044283f71c --- /dev/null +++ b/packages/component/src/Utils/blobToArrayBuffer.js @@ -0,0 +1,9 @@ +export default function blobToArrayBuffer(file) { + return new Promise((resolve, reject) => { + const reader = new FileReader(); + + reader.onerror = ({ error, message }) => reject(error || new Error(message)); + reader.onloadend = () => resolve(reader.result); + reader.readAsArrayBuffer(file); + }); +} diff --git a/packages/component/src/Utils/downscaleImageToDataURL.js b/packages/component/src/Utils/downscaleImageToDataURL.js new file mode 100644 index 0000000000..d3b20c45c9 --- /dev/null +++ b/packages/component/src/Utils/downscaleImageToDataURL.js @@ -0,0 +1,12 @@ +import downscaleImageToDataURLUsingBrowser from './downscaleImageToDataURLUsingBrowser'; +import downscaleImageToDataURLUsingWorker, { + checkSupport as supportWorker +} from './downscaleImageToDataURLUsingWorker'; + +export default function downscaleImageToDataURL(blob, maxWidth, maxHeight, type, quality) { + if (supportWorker()) { + return downscaleImageToDataURLUsingWorker(blob, maxWidth, maxHeight, type, quality); + } + + return downscaleImageToDataURLUsingBrowser(blob, maxWidth, maxHeight, type, quality); +} diff --git a/packages/component/src/Utils/downscaleImageToDataURLUsingBrowser.js b/packages/component/src/Utils/downscaleImageToDataURLUsingBrowser.js new file mode 100644 index 0000000000..ef69f6c701 --- /dev/null +++ b/packages/component/src/Utils/downscaleImageToDataURLUsingBrowser.js @@ -0,0 +1,59 @@ +function keepAspectRatio(width, height, maxWidth, maxHeight) { + if (width < maxWidth && height < maxHeight) { + // Photo is smaller than both maximum dimensions, take it as-is + return { + height, + width + }; + } + + const aspectRatio = width / height; + + if (aspectRatio > maxWidth / maxHeight) { + // Photo is wider than maximum dimension, downscale it based on maxWidth. + return { + height: maxWidth / aspectRatio, + width: maxWidth + }; + } + + // Photo is taller than maximum dimension, downscale it based on maxHeight. + return { + height: maxHeight, + width: maxHeight * aspectRatio + }; +} + +function createCanvas(width, height) { + const canvas = document.createElement('canvas'); + + canvas.height = height; + canvas.width = width; + + return canvas; +} + +function loadImageFromBlob(blob) { + const blobURL = URL.createObjectURL(blob); + + return new Promise((resolve, reject) => { + const image = document.createElement('img'); + + image.addEventListener('error', ({ error }) => reject(error)); + image.addEventListener('load', () => resolve(image)); + image.setAttribute('src', blobURL); + }).finally(() => { + URL.revokeObjectURL(blobURL); + }); +} + +export default async function downscaleImageToDataURLUsingBrowser(blob, maxWidth, maxHeight, type, quality) { + const image = await loadImageFromBlob(blob); + const { height, width } = keepAspectRatio(image.width, image.height, maxWidth, maxHeight); + const canvas = createCanvas(width, height); + const context = canvas.getContext('2d'); + + context.drawImage(image, 0, 0, width, height); + + return canvas.toDataURL(type, quality); +} diff --git a/packages/component/src/Utils/downscaleImageToDataURLUsingWorker.js b/packages/component/src/Utils/downscaleImageToDataURLUsingWorker.js new file mode 100644 index 0000000000..f5d52fc263 --- /dev/null +++ b/packages/component/src/Utils/downscaleImageToDataURLUsingWorker.js @@ -0,0 +1,94 @@ +import blobToArrayBuffer from './blobToArrayBuffer'; +import memoizeOne from 'memoize-one'; +import worker from './downscaleImageToDataURLUsingWorker.worker'; + +function createWorker() { + const blob = new Blob([`(${worker})()`], { type: 'text/javascript' }); + const url = window.URL.createObjectURL(blob); + + return new Promise((resolve, reject) => { + const worker = new Worker(url); + + worker.onerror = ({ error, message }) => reject(error || new Error(message)); + worker.onmessage = ({ data }) => data === 'ready' && resolve(worker); + }).finally(() => { + window.URL.revokeObjectURL(url); + }); +} + +let workerPromise; + +async function getWorker() { + let worker; + + if (workerPromise) { + worker = await workerPromise; + } else { + workerPromise = createWorker(); + + worker = await workerPromise; + worker.addEventListener('error', () => { + // Current worker errored out, will create a new worker next time. + workerPromise = null; + worker.terminate(); + }); + } + + return worker; +} + +// We are using a lazy-check because: +// 1. OffscreenCanvas.getContext has a toll +// 2. Developers could bring polyfills + +const checkSupport = memoizeOne(() => { + const hasOffscreenCanvas = + typeof window.OffscreenCanvas !== 'undefined' && + (typeof window.OffscreenCanvas.prototype.convertToBlob !== 'undefined' || + typeof window.OffscreenCanvas.prototype.toBlob !== 'undefined'); + let isOffscreenCanvasSupportGetContext2D; + + if (hasOffscreenCanvas) { + try { + new OffscreenCanvas(1, 1).getContext('2d'); + isOffscreenCanvasSupportGetContext2D = true; + } catch (err) { + isOffscreenCanvasSupportGetContext2D = false; + } + } + + return ( + typeof window.createImageBitmap !== 'undefined' && + typeof window.MessageChannel !== 'undefined' && + hasOffscreenCanvas && + isOffscreenCanvasSupportGetContext2D && + typeof window.Worker !== 'undefined' + ); +}); + +export default function downscaleImageToDataURLUsingWorker(blob, maxWidth, maxHeight, type, quality) { + return new Promise((resolve, reject) => { + const { port1, port2 } = new MessageChannel(); + + port1.onmessage = ({ data: { error, result } }) => { + if (error) { + const err = new Error(error.message); + + err.stack = error.stack; + + reject(err); + } else { + resolve(result); + } + + port1.close(); + port2.close(); + }; + + Promise.all([blobToArrayBuffer(blob), getWorker()]).then(([arrayBuffer, worker]) => + worker.postMessage({ arrayBuffer, maxHeight, maxWidth, quality, type }, [arrayBuffer, port2]) + ); + }); +} + +export { checkSupport }; diff --git a/packages/component/src/Utils/downscaleImageToDataURLUsingWorker.worker.js b/packages/component/src/Utils/downscaleImageToDataURLUsingWorker.worker.js new file mode 100644 index 0000000000..302d7fbd36 --- /dev/null +++ b/packages/component/src/Utils/downscaleImageToDataURLUsingWorker.worker.js @@ -0,0 +1,99 @@ +/* eslint object-shorthand: "off" */ +/* eslint prefer-destructuring: "off" */ +/* eslint prefer-arrow-callback: "off" */ + +// This file is the entrypoint of Web Worker and is minimally transpiled through Babel. +// Do not include any dependencies here because they will not be bundled. + +// This file will also get loaded by IE11, please make sure you hand-transpile it correctly. + +export default function() { + function blobToDataURL(blob) { + return new Promise(function(resolve, reject) { + const reader = new FileReader(); + + reader.onerror = function(event) { + reject(event.error || new Error(event.message)); + }; + + reader.onloadend = function() { + resolve(reader.result); + }; + + reader.readAsDataURL(blob); + }); + } + + function keepAspectRatio(width, height, maxWidth, maxHeight) { + if (width < maxWidth && height < maxHeight) { + // Photo is smaller than both maximum dimensions, take it as-is + return { + height: height, + width: width + }; + } + + const aspectRatio = width / height; + + if (aspectRatio > maxWidth / maxHeight) { + // Photo is wider than maximum dimension, downscale it based on maxWidth. + return { + height: maxWidth / aspectRatio, + width: maxWidth + }; + } + + // Photo is taller than maximum dimension, downscale it based on maxHeight. + return { + height: maxHeight, + width: maxHeight * aspectRatio + }; + } + + onmessage = function(event) { + const data = event.data; + const arrayBuffer = data.arrayBuffer; + const maxHeight = data.maxHeight; + const maxWidth = data.maxWidth; + const type = data.type; + const quality = data.quality; + const port = event.ports[0]; + + return Promise.resolve() + .then(function() { + return createImageBitmap(new Blob([arrayBuffer], { resizeQuality: 'high' })); + }) + .then(function(imageBitmap) { + const dimension = keepAspectRatio(imageBitmap.width, imageBitmap.height, maxWidth, maxHeight); + const height = dimension.height; + const width = dimension.width; + const offscreenCanvas = new OffscreenCanvas(width, height); + const context = offscreenCanvas.getContext('2d'); + + context.drawImage(imageBitmap, 0, 0, width, height); + + // Firefox quirks: 68.0.1 call named OffscreenCanvas.convertToBlob as OffscreenCanvas.toBlob. + const convertToBlob = (offscreenCanvas.convertToBlob || offscreenCanvas.toBlob).bind(offscreenCanvas); + + return convertToBlob({ type: type, quality: quality }); + }) + .then(function(blob) { + return blobToDataURL(blob); + }) + .then(function(dataURL) { + return port.postMessage({ result: dataURL }); + }) + .catch(function(err) { + console.error(err); + + port.postMessage({ + error: { + message: err.message, + stack: err.stack + } + }); + }); + }; + + postMessage('ready'); +} diff --git a/packages/core/src/sagas/sendFilesToPostActivitySaga.js b/packages/core/src/sagas/sendFilesToPostActivitySaga.js index 61638f142c..59305f7bfc 100644 --- a/packages/core/src/sagas/sendFilesToPostActivitySaga.js +++ b/packages/core/src/sagas/sendFilesToPostActivitySaga.js @@ -16,7 +16,8 @@ function* postActivityWithFiles({ payload: { files } }) { name })), channelData: { - attachmentSizes: [].map.call(files, ({ size }) => size) + attachmentSizes: [].map.call(files, ({ size }) => size), + attachmentThumbnails: [].map.call(files, ({ thumbnail }) => thumbnail) }, type: 'message' })