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'
})