Option to send photos as high quality

This commit is contained in:
Josh Perez 2021-06-25 12:08:16 -04:00 committed by GitHub
parent 6c56d5a5f1
commit 01eabf9ec6
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
44 changed files with 1263 additions and 363 deletions

View File

@ -719,30 +719,6 @@ Signal Desktop makes use of the following open source projects.
See the License for the specific language governing permissions and
limitations under the License.
## blueimp-canvas-to-blob
MIT License
Copyright © 2012 Sebastian Tschan, https://blueimp.net
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
## blueimp-load-image
MIT License

View File

@ -5605,5 +5605,29 @@
"ConversationDetailsHeader--add-group-description": {
"message": "Add group description...",
"description": "Placeholder text in the details header for those that can edit the group description"
},
"MediaQualitySelector--button": {
"message": "Select media quality",
"description": "aria-label for the media quality selector button"
},
"MediaQualitySelector--title": {
"message": "Media Quality",
"description": "Popup selector title"
},
"MediaQualitySelector--standard-quality-title": {
"message": "Standard",
"description": "Title for option for standard quality"
},
"MediaQualitySelector--standard-quality-description": {
"message": "Faster, less data",
"description": "Description of standard quality selector"
},
"MediaQualitySelector--high-quality-title": {
"message": "High",
"description": "Title for option for high quality"
},
"MediaQualitySelector--high-quality-description": {
"message": "Slower, more data",
"description": "Description of high quality selector"
}
}

View File

@ -0,0 +1 @@
<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path d="m11.2 15.8 3-3.8 3.8 5.2h-12l3-3.9z"/><path d="m19.7 3.5a.9.9 0 0 1 .8.8v15.4a.9.9 0 0 1 -.8.8h-15.4a.9.9 0 0 1 -.8-.8v-15.4a.9.9 0 0 1 .8-.8zm0-1.5h-15.4a2.3 2.3 0 0 0 -2.3 2.3v15.4a2.3 2.3 0 0 0 2.3 2.3h15.4a2.3 2.3 0 0 0 2.3-2.3v-15.4a2.3 2.3 0 0 0 -2.3-2.3z"/></svg>

After

Width:  |  Height:  |  Size: 339 B

View File

@ -0,0 +1 @@
<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path d="m19.7 2h-15.4a2.3 2.3 0 0 0 -2.3 2.3v15.4a2.3 2.3 0 0 0 2.3 2.3h15.4a2.3 2.3 0 0 0 2.3-2.3v-15.4a2.3 2.3 0 0 0 -2.3-2.3zm-14.4 15.5 3.3-4.4 2.4 2.9 3.4-4.3 4.3 5.8z"/></svg>

After

Width:  |  Height:  |  Size: 242 B

View File

@ -0,0 +1 @@
<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><circle cx="21.2" cy="7.4" r="1"/><circle cx="3" cy="3" r="1"/><circle cx="3" cy="7.5" r="1"/><circle cx="7.5" cy="3" r="1"/><circle cx="12" cy="3" r="1"/><circle cx="16.5" cy="3" r="1"/><circle cx="21" cy="3" r="1"/><circle cx="21" cy="12" r="1"/><circle cx="21" cy="16.5" r="1"/><circle cx="21" cy="21" r="1"/><circle cx="16.5" cy="21" r="1"/><path d="m12.8 10.5h-10a.7.7 0 0 0 -.8.7v10a.7.7 0 0 0 .8.8h10a.7.7 0 0 0 .7-.8v-10a.7.7 0 0 0 -.7-.7zm-9.6 7.8 2.3-3.1 1.6 2.1 2.3-3.2 3 4.2z"/></svg>

After

Width:  |  Height:  |  Size: 556 B

View File

@ -1,43 +0,0 @@
// Copyright 2018-2020 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
const loadImage = require('blueimp-load-image');
const DEFAULT_JPEG_QUALITY = 0.85;
// File | Blob | URLString -> LoadImageOptions -> Promise<DataURLString>
//
// Documentation for `options` (`LoadImageOptions`):
// https://github.com/blueimp/JavaScript-Load-Image/tree/v2.18.0#options
exports.autoOrientImage = (fileOrBlobOrURL, options = {}) => {
const optionsWithDefaults = {
type: 'image/jpeg',
quality: DEFAULT_JPEG_QUALITY,
...options,
canvas: true,
orientation: true,
};
return new Promise((resolve, reject) => {
loadImage(
fileOrBlobOrURL,
canvasOrError => {
if (canvasOrError.type === 'error') {
const error = new Error('autoOrientImage: Failed to process image');
error.originalError = canvasOrError;
reject(error);
return;
}
const canvas = canvasOrError;
const dataURL = canvas.toDataURL(
optionsWithDefaults.type,
optionsWithDefaults.quality
);
resolve(dataURL);
},
optionsWithDefaults
);
});
};

View File

@ -3,16 +3,12 @@
const is = require('@sindresorhus/is');
const {
arrayBufferToBlob,
blobToArrayBuffer,
dataURLToBlob,
} = require('blob-util');
const { arrayBufferToBlob, blobToArrayBuffer } = require('blob-util');
const AttachmentTS = require('../../../ts/types/Attachment');
const GoogleChrome = require('../../../ts/util/GoogleChrome');
const MIME = require('../../../ts/types/MIME');
const { toLogFormat } = require('./errors');
const { autoOrientImage } = require('../auto_orient_image');
const { scaleImageToLevel } = require('../../../ts/util/scaleImageToLevel');
const {
migrateDataToFileSystem,
} = require('./attachment/migrate_data_to_file_system');
@ -54,7 +50,7 @@ exports.isValid = rawAttachment => {
// Upgrade steps
// NOTE: This step strips all EXIF metadata from JPEG images as
// part of re-encoding the image:
exports.autoOrientJPEG = async attachment => {
exports.autoOrientJPEG = async (attachment, _, message) => {
if (!MIME.isJPEG(attachment.contentType)) {
return attachment;
}
@ -68,24 +64,27 @@ exports.autoOrientJPEG = async attachment => {
attachment.data,
attachment.contentType
);
const newDataBlob = await dataURLToBlob(await autoOrientImage(dataBlob));
const newDataArrayBuffer = await blobToArrayBuffer(newDataBlob);
const xcodedDataBlob = await scaleImageToLevel(
dataBlob,
message.sendHQImages
);
const xcodedDataArrayBuffer = await blobToArrayBuffer(xcodedDataBlob);
// IMPORTANT: We overwrite the existing `data` `ArrayBuffer` losing the original
// image data. Ideally, wed preserve the original image data for users who want to
// retain it but due to reports of data loss, we dont want to overburden IndexedDB
// by potentially doubling stored image data.
// See: https://github.com/signalapp/Signal-Desktop/issues/1589
const newAttachment = {
const xcodedAttachment = {
...attachment,
data: newDataArrayBuffer,
size: newDataArrayBuffer.byteLength,
data: xcodedDataArrayBuffer,
size: xcodedDataArrayBuffer.byteLength,
};
// `digest` is no longer valid for auto-oriented image data, so we discard it:
delete newAttachment.digest;
delete xcodedAttachment.digest;
return newAttachment;
return xcodedAttachment;
};
const UNICODE_LEFT_TO_RIGHT_OVERRIDE = '\u202D';

View File

@ -170,7 +170,7 @@ exports._withSchemaVersion = ({ schemaVersion, upgrade }) => {
// Promise Message
exports._mapAttachments = upgradeAttachment => async (message, context) => {
const upgradeWithContext = attachment =>
upgradeAttachment(attachment, context);
upgradeAttachment(attachment, context, message);
const attachments = await Promise.all(
(message.attachments || []).map(upgradeWithContext)
);

View File

@ -1,15 +1,15 @@
// Copyright 2018-2020 Signal Messenger, LLC
// Copyright 2018-2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
/* global document, URL, Blob */
const loadImage = require('blueimp-load-image');
const dataURLToBlobSync = require('blueimp-canvas-to-blob');
const { blobToArrayBuffer } = require('blob-util');
const { toLogFormat } = require('./errors');
const {
arrayBufferToObjectURL,
} = require('../../../ts/util/arrayBufferToObjectURL');
const { canvasToBlob } = require('../../../ts/util/canvasToBlob');
exports.blobToArrayBuffer = blobToArrayBuffer;
@ -40,7 +40,7 @@ exports.makeImageThumbnail = ({
new Promise((resolve, reject) => {
const image = document.createElement('img');
image.addEventListener('load', () => {
image.addEventListener('load', async () => {
// using components/blueimp-load-image
// first, make the correct size
@ -63,9 +63,12 @@ exports.makeImageThumbnail = ({
minHeight: size,
});
const blob = dataURLToBlobSync(canvas.toDataURL(contentType));
resolve(blob);
try {
const blob = await canvasToBlob(canvas, contentType);
resolve(blob);
} catch (err) {
reject(err);
}
});
image.addEventListener('error', error => {
@ -88,7 +91,7 @@ exports.makeVideoScreenshot = ({
video.currentTime = 1.0;
}
function capture() {
async function capture() {
const canvas = document.createElement('canvas');
canvas.width = video.videoWidth;
canvas.height = video.videoHeight;
@ -96,12 +99,15 @@ exports.makeVideoScreenshot = ({
.getContext('2d')
.drawImage(video, 0, 0, canvas.width, canvas.height);
const image = dataURLToBlobSync(canvas.toDataURL(contentType));
video.addEventListener('loadeddata', seek);
video.removeEventListener('seeked', capture);
resolve(image);
try {
const image = canvasToBlob(canvas, contentType);
resolve(image);
} catch (err) {
reject(err);
}
}
video.addEventListener('loadeddata', seek);

View File

@ -78,7 +78,6 @@
"backbone": "1.4.0",
"better-sqlite3": "https://github.com/signalapp/better-sqlite3#2fa02d2484e9f9a10df5ac7ea4617fb2dff30006",
"blob-util": "1.3.0",
"blueimp-canvas-to-blob": "3.14.0",
"blueimp-load-image": "5.14.0",
"blurhash": "1.1.3",
"classnames": "2.2.5",

View File

@ -0,0 +1,25 @@
diff --git a/node_modules/@types/blueimp-load-image/index.d.ts b/node_modules/@types/blueimp-load-image/index.d.ts
index 285505b..da92b91 100644
--- a/node_modules/@types/blueimp-load-image/index.d.ts
+++ b/node_modules/@types/blueimp-load-image/index.d.ts
@@ -9,7 +9,7 @@
declare namespace loadImage {
type LoadImageCallback = (eventOrImage: Event | HTMLCanvasElement | HTMLImageElement, data?: MetaData) => void;
type LoadImageResult = MetaData & {
- image: HTMLImageElement | FileReader | false;
+ image: HTMLImageElement | HTMLCanvasElement;
};
type ParseMetaDataCallback = (data: MetaData) => void;
@@ -122,6 +122,11 @@ interface LoadImage {
) => void;
blobSlice: (this: Blob, start?: number, end?: number) => Blob;
+
+ scale: (
+ img: HTMLImageElement | HTMLCanvasElement,
+ options?: loadImage.LoadImageOptions
+ ) => HTMLImageElement | HTMLCanvasElement;
}
declare const loadImage: LoadImage;

View File

@ -484,14 +484,11 @@ try {
window.nodeSetImmediate(() => {});
}, 1000);
const { autoOrientImage } = require('./js/modules/auto_orient_image');
const { imageToBlurHash } = require('./ts/util/imageToBlurHash');
const { isGroupCallingEnabled } = require('./ts/util/isGroupCallingEnabled');
const { isValidGuid } = require('./ts/util/isValidGuid');
const { ActiveWindowService } = require('./ts/services/ActiveWindowService');
window.autoOrientImage = autoOrientImage;
window.dataURLToBlobSync = require('blueimp-canvas-to-blob');
window.imageToBlurHash = imageToBlurHash;
window.emojiData = require('emoji-datasource');
window.libphonenumber = require('google-libphonenumber').PhoneNumberUtil.getInstance();

View File

@ -10575,6 +10575,11 @@ $contact-modal-padding: 18px;
}
}
.react-contextmenu-item--disabled.react-contextmenu-item--selected {
background-color: inherit;
cursor: inherit;
}
.react-contextmenu-item.react-contextmenu-item--active.react-contextmenu-item--checked:before,
.react-contextmenu-item.react-contextmenu-item--selected.react-contextmenu-item--checked:before {
color: $color-black;

View File

@ -0,0 +1,116 @@
// Copyright 2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
.MediaQualitySelector {
&__popper {
@extend %module-composition-popper;
padding: 12px 16px;
width: auto;
}
&__title {
@include font-body-1-bold;
margin-bottom: 12px;
}
&__button {
@include button-reset();
align-items: center;
border-radius: 16px;
display: flex;
height: 32px;
justify-content: center;
opacity: 0.5;
width: 32px;
&::after {
content: '';
display: block;
flex-shrink: 0;
height: 24px;
width: 24px;
@include light-theme {
@include color-svg('../images/icons/v2/sq-24.svg', $color-gray-75);
}
@include dark-theme {
@include color-svg('../images/icons/v2/sq-24.svg', $color-gray-15);
}
}
&--hq {
&::after {
@include light-theme {
@include color-svg(
'../images/icons/v2/hq-outline-24.svg',
$color-gray-75
);
}
@include dark-theme {
@include color-svg(
'../images/icons/v2/hq-solid-24.svg',
$color-gray-15
);
}
}
}
&--active {
opacity: 1;
@include light-theme() {
background-color: $color-gray-05;
}
@include dark-theme() {
background-color: $color-gray-75;
}
}
}
&__option {
@include button-reset();
align-items: center;
border-radius: 6px;
display: flex;
height: 42px;
margin: 2px 0;
min-width: 200px;
&--checkmark {
height: 12px;
margin: 0 6px;
width: 16px;
}
&--selected {
@include color-svg('../images/icons/v2/check-24.svg', $color-ultramarine);
}
&--title {
@include font-body-2;
}
&--description {
@include font-subtitle;
}
&:hover {
@include light-theme() {
background-color: $color-gray-05;
}
@include dark-theme() {
background-color: $color-gray-65;
}
}
&:focus,
&:active {
border-radius: 6px;
box-shadow: 0 0 1px 1px $color-ultramarine;
outline: none;
}
}
}

View File

@ -49,6 +49,7 @@
@import './components/GroupDescription.scss';
@import './components/GroupDialog.scss';
@import './components/GroupInput.scss';
@import './components/MediaQualitySelector.scss';
@import './components/MessageAudio.scss';
@import './components/Modal.scss';
@import './components/SafetyNumberChangeDialog.scss';

View File

@ -10,6 +10,7 @@ export type ConfigKeyType =
| 'desktop.groupCalling'
| 'desktop.gv2'
| 'desktop.mandatoryProfileSharing'
| 'desktop.mediaQuality.levels'
| 'desktop.messageRequests'
| 'desktop.retryReceiptLifespan'
| 'desktop.retryRespondMaxAge'

View File

@ -984,6 +984,7 @@ export async function startApp(): Promise<void> {
store.dispatch
),
calling: bindActionCreators(actionCreators.calling, store.dispatch),
composer: bindActionCreators(actionCreators.composer, store.dispatch),
conversations: bindActionCreators(
actionCreators.conversations,
store.dispatch

View File

@ -32,6 +32,25 @@ const createProps = (overrideProps: Partial<Props> = {}): Props => ({
i18n,
micCellEl,
onChooseAttachment: action('onChooseAttachment'),
// AttachmentList
draftAttachments: [],
onAddAttachment: action('onAddAttachment'),
onClearAttachments: action('onClearAttachments'),
onClickAttachment: action('onClickAttachment'),
onCloseAttachment: action('onCloseAttachment'),
// StagedLinkPreview
linkPreviewLoading: Boolean(overrideProps.linkPreviewLoading),
linkPreviewResult: overrideProps.linkPreviewResult,
onCloseLinkPreview: action('onCloseLinkPreview'),
// Quote
quotedMessageProps: overrideProps.quotedMessageProps,
onClickQuotedMessage: action('onClickQuotedMessage'),
setQuotedMessage: action('setQuotedMessage'),
// MediaQualitySelector
onSelectMediaQuality: action('onSelectMediaQuality'),
shouldSendHighQualityAttachments: Boolean(
overrideProps.shouldSendHighQualityAttachments
),
// CompositionInput
onSubmit: action('onSubmit'),
onEditorStateChange: action('onEditorStateChange'),

View File

@ -31,6 +31,12 @@ import { MandatoryProfileSharingActions } from './conversation/MandatoryProfileS
import { countStickers } from './stickers/lib';
import { LocalizerType } from '../types/Util';
import { EmojiPickDataType } from './emoji/EmojiPicker';
import { AttachmentType, isImageAttachment } from '../types/Attachment';
import { AttachmentList } from './conversation/AttachmentList';
import { MediaQualitySelector } from './MediaQualitySelector';
import { Quote, Props as QuoteProps } from './conversation/Quote';
import { StagedLinkPreview } from './conversation/StagedLinkPreview';
import { LinkPreviewWithDomain } from '../types/LinkPreview';
export type OwnProps = {
readonly i18n: LocalizerType;
@ -50,14 +56,24 @@ export type OwnProps = {
setDisabled: (disabled: boolean) => void;
setShowMic: (showMic: boolean) => void;
setMicActive: (micActive: boolean) => void;
attSlotRef: React.RefObject<HTMLDivElement>;
reset: InputApi['reset'];
resetEmojiResults: InputApi['resetEmojiResults'];
}>;
readonly micCellEl?: HTMLElement;
readonly attCellEl?: HTMLElement;
readonly attachmentListEl?: HTMLElement;
readonly draftAttachments: Array<AttachmentType>;
readonly shouldSendHighQualityAttachments: boolean;
onChooseAttachment(): unknown;
onAddAttachment(): unknown;
onClickAttachment(): unknown;
onCloseAttachment(): unknown;
onClearAttachments(): unknown;
onSelectMediaQuality(isHQ: boolean): unknown;
readonly quotedMessageProps?: QuoteProps;
onClickQuotedMessage(): unknown;
setQuotedMessage(message: undefined): unknown;
linkPreviewLoading: boolean;
linkPreviewResult?: LinkPreviewWithDomain;
onCloseLinkPreview(): unknown;
};
export type Props = Pick<
@ -103,9 +119,25 @@ const emptyElement = (el: HTMLElement) => {
export const CompositionArea = ({
i18n,
attachmentListEl,
micCellEl,
onChooseAttachment,
// AttachmentList
draftAttachments,
onAddAttachment,
onClearAttachments,
onClickAttachment,
onCloseAttachment,
// StagedLinkPreview
linkPreviewLoading,
linkPreviewResult,
onCloseLinkPreview,
// Quote
quotedMessageProps,
onClickQuotedMessage,
setQuotedMessage,
// MediaQualitySelector
onSelectMediaQuality,
shouldSendHighQualityAttachments,
// CompositionInput
onSubmit,
compositionApi,
@ -198,9 +230,6 @@ export const CompositionArea = ({
receivedPacks,
}) > 0;
// A ref to grab a slot where backbone can insert link previews and attachments
const attSlotRef = React.useRef<HTMLDivElement>(null);
if (compositionApi) {
// Using a React.MutableRefObject, so we need to reassign this prop.
// eslint-disable-next-line no-param-reassign
@ -210,7 +239,6 @@ export const CompositionArea = ({
setDisabled,
setShowMic,
setMicActive,
attSlotRef,
reset: () => {
if (inputApiRef.current) {
inputApiRef.current.reset();
@ -251,27 +279,31 @@ export const CompositionArea = ({
return noop;
}, [micCellRef, micCellEl, large, dirty, showMic]);
React.useLayoutEffect(() => {
const { current: attSlot } = attSlotRef;
if (attSlot && attachmentListEl) {
attSlot.appendChild(attachmentListEl);
}
const showMediaQualitySelector = draftAttachments.some(isImageAttachment);
return noop;
}, [attSlotRef, attachmentListEl]);
const emojiButtonFragment = (
<div className="module-composition-area__button-cell">
<EmojiButton
i18n={i18n}
doSend={handleForceSend}
onPickEmoji={insertEmoji}
onClose={focusInput}
recentEmojis={recentEmojis}
skinTone={skinTone}
onSetSkinTone={onSetSkinTone}
/>
</div>
const leftHandSideButtonsFragment = (
<>
<div className="module-composition-area__button-cell">
<EmojiButton
i18n={i18n}
doSend={handleForceSend}
onPickEmoji={insertEmoji}
onClose={focusInput}
recentEmojis={recentEmojis}
skinTone={skinTone}
onSetSkinTone={onSetSkinTone}
/>
</div>
{showMediaQualitySelector ? (
<div className="module-composition-area__button-cell">
<MediaQualitySelector
i18n={i18n}
isHighQuality={shouldSendHighQualityAttachments}
onSelectQuality={onSelectMediaQuality}
/>
</div>
) : null}
</>
);
const micButtonFragment = showMic ? (
@ -480,15 +512,52 @@ export const CompositionArea = ({
'module-composition-area__row',
'module-composition-area__row--column'
)}
ref={attSlotRef}
/>
>
{quotedMessageProps && (
<div className="quote-wrapper">
<Quote
{...quotedMessageProps}
i18n={i18n}
onClick={onClickQuotedMessage}
onClose={() => {
// This one is for redux...
setQuotedMessage(undefined);
// and this is for conversation_view.
clearQuotedMessage();
}}
withContentAbove
/>
</div>
)}
{linkPreviewLoading && (
<div className="preview-wrapper">
<StagedLinkPreview
{...(linkPreviewResult || {})}
i18n={i18n}
onClose={onCloseLinkPreview}
/>
</div>
)}
{draftAttachments.length ? (
<div className="module-composition-area__attachment-list">
<AttachmentList
attachments={draftAttachments}
i18n={i18n}
onAddAttachment={onAddAttachment}
onClickAttachment={onClickAttachment}
onClose={onClearAttachments}
onCloseAttachment={onCloseAttachment}
/>
</div>
) : null}
</div>
<div
className={classNames(
'module-composition-area__row',
large ? 'module-composition-area__row--padded' : null
)}
>
{!large ? emojiButtonFragment : null}
{!large ? leftHandSideButtonsFragment : null}
<div className="module-composition-area__input">
<CompositionInput
i18n={i18n}
@ -523,7 +592,7 @@ export const CompositionArea = ({
'module-composition-area__row--control-row'
)}
>
{emojiButtonFragment}
{leftHandSideButtonsFragment}
{stickerButtonFragment}
{attButton}
{!dirty ? micButtonFragment : null}

View File

@ -269,7 +269,6 @@ export const ForwardMessageModal: FunctionComponent<PropsType> = ({
domain={linkPreview.url}
i18n={i18n}
image={linkPreview.image}
isLoaded
onClose={() => removeLinkPreview()}
title={linkPreview.title}
/>

View File

@ -0,0 +1,34 @@
// Copyright 2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import React from 'react';
import { storiesOf } from '@storybook/react';
import { action } from '@storybook/addon-actions';
import { boolean } from '@storybook/addon-knobs';
import enMessages from '../../_locales/en/messages.json';
import { MediaQualitySelector, PropsType } from './MediaQualitySelector';
import { setup as setupI18n } from '../../js/modules/i18n';
const story = storiesOf('Components/MediaQualitySelector', module);
const i18n = setupI18n('en', enMessages);
const createProps = (overrideProps: Partial<PropsType> = {}): PropsType => ({
i18n,
isHighQuality: boolean('isHighQuality', Boolean(overrideProps.isHighQuality)),
onSelectQuality: action('onSelectQuality'),
});
story.add('Standard Quality', () => (
<MediaQualitySelector {...createProps()} />
));
story.add('High Quality', () => (
<MediaQualitySelector
{...createProps({
isHighQuality: true,
})}
/>
));

View File

@ -0,0 +1,152 @@
// Copyright 2018-2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import React, { KeyboardEvent, useCallback, useEffect, useState } from 'react';
import { noop } from 'lodash';
import { createPortal } from 'react-dom';
import classNames from 'classnames';
import { Manager, Popper, Reference } from 'react-popper';
import { LocalizerType } from '../types/Util';
export type PropsType = {
i18n: LocalizerType;
isHighQuality: boolean;
onSelectQuality: (isHQ: boolean) => unknown;
};
export const MediaQualitySelector = ({
i18n,
isHighQuality,
onSelectQuality,
}: PropsType): JSX.Element => {
const [menuShowing, setMenuShowing] = useState(false);
const [popperRoot, setPopperRoot] = useState<HTMLElement | null>(null);
// We use regular MouseEvent below, and this one uses React.MouseEvent
const handleClick = (ev: KeyboardEvent | React.MouseEvent) => {
setMenuShowing(true);
ev.stopPropagation();
ev.preventDefault();
};
const handleClose = useCallback(() => {
setMenuShowing(false);
}, [setMenuShowing]);
useEffect(() => {
if (menuShowing) {
const root = document.createElement('div');
setPopperRoot(root);
document.body.appendChild(root);
const handleOutsideClick = (event: MouseEvent) => {
if (!root.contains(event.target as Node)) {
handleClose();
event.stopPropagation();
event.preventDefault();
}
};
document.addEventListener('click', handleOutsideClick);
return () => {
document.body.removeChild(root);
document.removeEventListener('click', handleOutsideClick);
setPopperRoot(null);
};
}
return noop;
}, [menuShowing, setPopperRoot, handleClose]);
return (
<Manager>
<Reference>
{({ ref }) => (
<button
aria-label={i18n('MediaQualitySelector--button')}
className={classNames({
MediaQualitySelector__button: true,
'MediaQualitySelector__button--hq': isHighQuality,
'MediaQualitySelector__button--active': menuShowing,
})}
onClick={handleClick}
ref={ref}
type="button"
/>
)}
</Reference>
{menuShowing && popperRoot
? createPortal(
<Popper placement="top-start" positionFixed>
{({ ref, style, placement }) => (
<div
className="MediaQualitySelector__popper"
data-placement={placement}
ref={ref}
style={style}
>
<div className="MediaQualitySelector__title">
{i18n('MediaQualitySelector--title')}
</div>
<button
aria-label={i18n(
'MediaQualitySelector--standard-quality-title'
)}
className="MediaQualitySelector__option"
type="button"
onClick={() => {
onSelectQuality(false);
setMenuShowing(false);
}}
>
<div
className={classNames({
'MediaQualitySelector__option--checkmark': true,
'MediaQualitySelector__option--selected': !isHighQuality,
})}
/>
<div>
<div className="MediaQualitySelector__option--title">
{i18n('MediaQualitySelector--standard-quality-title')}
</div>
<div className="MediaQualitySelector__option--description">
{i18n(
'MediaQualitySelector--standard-quality-description'
)}
</div>
</div>
</button>
<button
aria-label={i18n(
'MediaQualitySelector--high-quality-title'
)}
className="MediaQualitySelector__option"
type="button"
onClick={() => {
onSelectQuality(true);
setMenuShowing(false);
}}
>
<div
className={classNames({
'MediaQualitySelector__option--checkmark': true,
'MediaQualitySelector__option--selected': isHighQuality,
})}
/>
<div>
<div className="MediaQualitySelector__option--title">
{i18n('MediaQualitySelector--high-quality-title')}
</div>
<div className="MediaQualitySelector__option--description">
{i18n('MediaQualitySelector--high-quality-description')}
</div>
</div>
</button>
</div>
)}
</Popper>,
popperRoot
)
: null}
</Manager>
);
};

View File

@ -1,4 +1,4 @@
// Copyright 2020 Signal Messenger, LLC
// Copyright 2020-2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import * as React from 'react';
@ -6,7 +6,7 @@ import moment, { Moment } from 'moment';
import { isLinkPreviewDateValid } from '../../linkPreviews/isLinkPreviewDateValid';
type Props = {
date: null | number;
date?: null | number;
className?: string;
};

View File

@ -1,9 +1,9 @@
// Copyright 2020 Signal Messenger, LLC
// Copyright 2020-2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import * as React from 'react';
import { storiesOf } from '@storybook/react';
import { boolean, date, text, withKnobs } from '@storybook/addon-knobs';
import { date, text, withKnobs } from '@storybook/addon-knobs';
import { action } from '@storybook/addon-actions';
import { AttachmentType } from '../../types/Attachment';
@ -36,7 +36,6 @@ const createAttachment = (
});
const createProps = (overrideProps: Partial<Props> = {}): Props => ({
isLoaded: boolean('isLoaded', overrideProps.isLoaded !== false),
title: text(
'title',
typeof overrideProps.title === 'string'
@ -57,9 +56,7 @@ const createProps = (overrideProps: Partial<Props> = {}): Props => ({
});
story.add('Loading', () => {
const props = createProps({
isLoaded: false,
});
const props = createProps({ domain: '' });
return <StagedLinkPreview {...props} />;
});

View File

@ -1,4 +1,4 @@
// Copyright 2019-2020 Signal Messenger, LLC
// Copyright 2019-2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import React from 'react';
@ -11,11 +11,10 @@ import { AttachmentType, isImageAttachment } from '../../types/Attachment';
import { LocalizerType } from '../../types/Util';
export type Props = {
isLoaded: boolean;
title: string;
description: null | string;
date: null | number;
domain: string;
title?: string;
description?: null | string;
date?: null | number;
domain?: string;
image?: AttachmentType;
i18n: LocalizerType;
@ -23,7 +22,6 @@ export type Props = {
};
export const StagedLinkPreview: React.FC<Props> = ({
isLoaded,
onClose,
i18n,
title,
@ -33,6 +31,7 @@ export const StagedLinkPreview: React.FC<Props> = ({
domain,
}: Props) => {
const isImage = isImageAttachment(image);
const isLoaded = Boolean(domain);
return (
<div
@ -46,7 +45,7 @@ export const StagedLinkPreview: React.FC<Props> = ({
{i18n('loadingPreview')}
</div>
) : null}
{isLoaded && image && isImage ? (
{isLoaded && image && isImage && domain ? (
<div className="module-staged-link-preview__icon-container">
<Image
alt={i18n('stagedPreviewThumbnail', [domain])}

7
ts/model-types.d.ts vendored
View File

@ -56,12 +56,13 @@ export type QuotedMessageType = {
// `author` is an old attribute that holds the author's E164. We shouldn't use it for
// new messages, but old messages might have this attribute.
author?: string;
authorUuid: string;
bodyRanges: BodyRangesType;
authorUuid?: string;
bodyRanges?: BodyRangesType;
id: string;
referencedMessageNotFound: boolean;
isViewOnce: boolean;
text: string;
text?: string;
messageId: string;
};
export type RetryOptions = Readonly<{

View File

@ -6,12 +6,13 @@
import { ProfileKeyCredentialRequestContext } from 'zkgroup';
import { compact, sample } from 'lodash';
import {
MessageModelCollectionType,
WhatIsThis,
MessageAttributesType,
ReactionModelType,
ConversationAttributesType,
MessageAttributesType,
MessageModelCollectionType,
QuotedMessageType,
ReactionModelType,
VerificationOptions,
WhatIsThis,
} from '../model-types.d';
import { CallMode, CallHistoryDetailsType } from '../types/Calling';
import { CallbackResultType, GroupV2InfoType } from '../textsecure/SendMessage';
@ -40,7 +41,6 @@ import {
verifyAccessKey,
} from '../Crypto';
import * as Bytes from '../Bytes';
import { DataMessageClass } from '../textsecure.d';
import { BodyRangesType } from '../types/Util';
import { getTextWithMentions } from '../util';
import { migrateColor } from '../util/migrateColor';
@ -3083,7 +3083,7 @@ export class ConversationModel extends window.Backbone
async makeQuote(
quotedMessage: typeof window.Whisper.MessageType
): Promise<DataMessageClass.Quote> {
): Promise<QuotedMessageType> {
const { getName } = Contact;
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
const contact = quotedMessage.getContact()!;
@ -3100,13 +3100,15 @@ export class ConversationModel extends window.Backbone
return {
authorUuid: contact.get('uuid'),
bodyRanges: quotedMessage.get('bodyRanges'),
id: quotedMessage.get('sent_at'),
text: body || embeddedContactName,
isViewOnce: isTapToView(quotedMessage.attributes),
attachments: isTapToView(quotedMessage.attributes)
? [{ contentType: 'image/jpeg', fileName: null }]
: await this.getQuoteAttachment(attachments, preview, sticker),
bodyRanges: quotedMessage.get('bodyRanges'),
id: String(quotedMessage.get('sent_at')),
isViewOnce: isTapToView(quotedMessage.attributes),
messageId: quotedMessage.get('id'),
referencedMessageNotFound: false,
text: body || embeddedContactName,
};
}
@ -3476,10 +3478,13 @@ export class ConversationModel extends window.Backbone
mentions?: BodyRangesType,
{
dontClearDraft,
sendHQImages,
timestamp,
}: { dontClearDraft: boolean; timestamp?: number } = {
dontClearDraft: false,
}
}: {
dontClearDraft?: boolean;
sendHQImages?: boolean;
timestamp?: number;
} = {}
): void {
if (this.isGroupV1AndDisabled()) {
return;
@ -3530,6 +3535,7 @@ export class ConversationModel extends window.Backbone
recipients,
sticker,
bodyRanges: mentions,
sendHQImages,
});
if (isDirectConversation(this.attributes)) {

View File

@ -5,6 +5,7 @@ import { actions as accounts } from './ducks/accounts';
import { actions as app } from './ducks/app';
import { actions as audioPlayer } from './ducks/audioPlayer';
import { actions as calling } from './ducks/calling';
import { actions as composer } from './ducks/composer';
import { actions as conversations } from './ducks/conversations';
import { actions as emojis } from './ducks/emojis';
import { actions as expiration } from './ducks/expiration';
@ -24,6 +25,7 @@ export const actionCreators: ReduxActions = {
app,
audioPlayer,
calling,
composer,
conversations,
emojis,
expiration,
@ -43,6 +45,7 @@ export const mapDispatchToProps = {
...app,
...audioPlayer,
...calling,
...composer,
...conversations,
...emojis,
...expiration,

168
ts/state/ducks/composer.ts Normal file
View File

@ -0,0 +1,168 @@
// Copyright 2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import { AttachmentType } from '../../types/Attachment';
import { MessageAttributesType } from '../../model-types.d';
import { LinkPreviewWithDomain } from '../../types/LinkPreview';
// State
export type ComposerStateType = {
attachments: ReadonlyArray<AttachmentType>;
linkPreviewLoading: boolean;
linkPreviewResult?: LinkPreviewWithDomain;
quotedMessage?: Pick<MessageAttributesType, 'conversationId' | 'quote'>;
shouldSendHighQualityAttachments: boolean;
};
// Actions
const REPLACE_ATTACHMENTS = 'composer/REPLACE_ATTACHMENTS';
const RESET_COMPOSER = 'composer/RESET_COMPOSER';
const SET_HIGH_QUALITY_SETTING = 'composer/SET_HIGH_QUALITY_SETTING';
const SET_LINK_PREVIEW_RESULT = 'composer/SET_LINK_PREVIEW_RESULT';
const SET_QUOTED_MESSAGE = 'composer/SET_QUOTED_MESSAGE';
type ReplaceAttachmentsActionType = {
type: typeof REPLACE_ATTACHMENTS;
payload: ReadonlyArray<AttachmentType>;
};
type ResetComposerActionType = {
type: typeof RESET_COMPOSER;
};
type SetHighQualitySettingActionType = {
type: typeof SET_HIGH_QUALITY_SETTING;
payload: boolean;
};
type SetLinkPreviewResultActionType = {
type: typeof SET_LINK_PREVIEW_RESULT;
payload: {
isLoading: boolean;
linkPreview?: LinkPreviewWithDomain;
};
};
type SetQuotedMessageActionType = {
type: typeof SET_QUOTED_MESSAGE;
payload?: Pick<MessageAttributesType, 'conversationId' | 'quote'>;
};
type ComposerActionType =
| ReplaceAttachmentsActionType
| ResetComposerActionType
| SetHighQualitySettingActionType
| SetLinkPreviewResultActionType
| SetQuotedMessageActionType;
// Action Creators
export const actions = {
replaceAttachments,
resetComposer,
setLinkPreviewResult,
setMediaQualitySetting,
setQuotedMessage,
};
function replaceAttachments(
payload: ReadonlyArray<AttachmentType>
): ReplaceAttachmentsActionType {
return {
type: REPLACE_ATTACHMENTS,
payload,
};
}
function resetComposer(): ResetComposerActionType {
return {
type: RESET_COMPOSER,
};
}
function setLinkPreviewResult(
isLoading: boolean,
linkPreview?: LinkPreviewWithDomain
): SetLinkPreviewResultActionType {
return {
type: SET_LINK_PREVIEW_RESULT,
payload: {
isLoading,
linkPreview,
},
};
}
function setMediaQualitySetting(
payload: boolean
): SetHighQualitySettingActionType {
return {
type: SET_HIGH_QUALITY_SETTING,
payload,
};
}
function setQuotedMessage(
payload?: Pick<MessageAttributesType, 'conversationId' | 'quote'>
): SetQuotedMessageActionType {
return {
type: SET_QUOTED_MESSAGE,
payload,
};
}
// Reducer
export function getEmptyState(): ComposerStateType {
return {
attachments: [],
linkPreviewLoading: false,
shouldSendHighQualityAttachments: false,
};
}
export function reducer(
state: Readonly<ComposerStateType> = getEmptyState(),
action: Readonly<ComposerActionType>
): ComposerStateType {
if (action.type === RESET_COMPOSER) {
return getEmptyState();
}
if (action.type === REPLACE_ATTACHMENTS) {
const { payload: attachments } = action;
return {
...state,
attachments,
...(attachments.length
? {}
: { shouldSendHighQualityAttachments: false }),
};
}
if (action.type === SET_HIGH_QUALITY_SETTING) {
return {
...state,
shouldSendHighQualityAttachments: action.payload,
};
}
if (action.type === SET_QUOTED_MESSAGE) {
return {
...state,
quotedMessage: action.payload,
};
}
if (action.type === SET_LINK_PREVIEW_RESULT) {
return {
...state,
linkPreviewLoading: action.payload.isLoading,
linkPreviewResult: action.payload.linkPreview,
};
}
return state;
}

View File

@ -7,6 +7,7 @@ import { reducer as accounts } from './ducks/accounts';
import { reducer as app } from './ducks/app';
import { reducer as audioPlayer } from './ducks/audioPlayer';
import { reducer as calling } from './ducks/calling';
import { reducer as composer } from './ducks/composer';
import { reducer as conversations } from './ducks/conversations';
import { reducer as emojis } from './ducks/emojis';
import { reducer as expiration } from './ducks/expiration';
@ -25,6 +26,7 @@ export const reducer = combineReducers({
app,
audioPlayer,
calling,
composer,
conversations,
emojis,
expiration,

View File

@ -9,11 +9,12 @@ import { StateType } from '../reducer';
import { isConversationSMSOnly } from '../../util/isConversationSMSOnly';
import { selectRecentEmojis } from '../selectors/emojis';
import { getIntl } from '../selectors/user';
import { getIntl, getUserConversationId } from '../selectors/user';
import {
getConversationSelector,
isMissingRequiredProfileSharing,
} from '../selectors/conversations';
import { getPropsForQuote } from '../selectors/message';
import {
getBlessedStickerPacks,
getInstalledStickerPacks,
@ -25,12 +26,14 @@ import {
type ExternalProps = {
id: string;
onClickQuotedMessage: (id?: string) => unknown;
};
const mapStateToProps = (state: StateType, props: ExternalProps) => {
const { id } = props;
const { id, onClickQuotedMessage } = props;
const conversation = getConversationSelector(state)(id);
const conversationSelector = getConversationSelector(state);
const conversation = conversationSelector(id);
if (!conversation) {
throw new Error(`Conversation id ${id} not found!`);
}
@ -54,6 +57,14 @@ const mapStateToProps = (state: StateType, props: ExternalProps) => {
get(state.items, ['showStickerPickerHint'], false) &&
receivedPacks.length > 0;
const {
attachments: draftAttachments,
linkPreviewLoading,
linkPreviewResult,
quotedMessage,
shouldSendHighQualityAttachments,
} = state.composer;
const recentEmojis = selectRecentEmojis(state);
return {
@ -61,6 +72,23 @@ const mapStateToProps = (state: StateType, props: ExternalProps) => {
i18n: getIntl(state),
draftText,
draftBodyRanges,
// AttachmentsList
draftAttachments,
// MediaQualitySelector
shouldSendHighQualityAttachments,
// StagedLinkPreview
linkPreviewLoading,
linkPreviewResult,
// Quote
quotedMessageProps: quotedMessage
? getPropsForQuote(
quotedMessage,
conversationSelector,
getUserConversationId(state)
)
: undefined,
onClickQuotedMessage: () =>
onClickQuotedMessage(quotedMessage?.quote?.messageId),
// Emojis
recentEmojis,
skinTone: get(state, ['items', 'skinTone'], 0),

View File

@ -5,6 +5,7 @@ import { actions as accounts } from './ducks/accounts';
import { actions as app } from './ducks/app';
import { actions as audioPlayer } from './ducks/audioPlayer';
import { actions as calling } from './ducks/calling';
import { actions as composer } from './ducks/composer';
import { actions as conversations } from './ducks/conversations';
import { actions as emojis } from './ducks/emojis';
import { actions as expiration } from './ducks/expiration';
@ -23,6 +24,7 @@ export type ReduxActions = {
app: typeof app;
audioPlayer: typeof audioPlayer;
calling: typeof calling;
composer: typeof composer;
conversations: typeof conversations;
emojis: typeof emojis;
expiration: typeof expiration;

View File

@ -0,0 +1,120 @@
// Copyright 2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import { assert } from 'chai';
import { actions, getEmptyState, reducer } from '../../../state/ducks/composer';
import { IMAGE_JPEG } from '../../../types/MIME';
import { AttachmentType } from '../../../types/Attachment';
describe('both/state/ducks/composer', () => {
const QUOTED_MESSAGE = {
conversationId: '123',
quote: {
attachments: [],
id: '456',
isViewOnce: false,
messageId: '789',
referencedMessageNotFound: false,
},
};
describe('replaceAttachments', () => {
it('replaces the attachments state', () => {
const { replaceAttachments } = actions;
const state = getEmptyState();
const attachments: Array<AttachmentType> = [{ contentType: IMAGE_JPEG }];
const nextState = reducer(state, replaceAttachments(attachments));
assert.deepEqual(nextState.attachments, attachments);
});
it('sets the high quality setting to false when there are no attachments', () => {
const { replaceAttachments } = actions;
const state = getEmptyState();
const attachments: Array<AttachmentType> = [];
const nextState = reducer(
{ ...state, shouldSendHighQualityAttachments: true },
replaceAttachments(attachments)
);
assert.deepEqual(nextState.attachments, attachments);
assert.isFalse(nextState.shouldSendHighQualityAttachments);
});
});
describe('resetComposer', () => {
it('returns composer back to empty state', () => {
const { resetComposer } = actions;
const nextState = reducer(
{
attachments: [],
linkPreviewLoading: true,
quotedMessage: QUOTED_MESSAGE,
shouldSendHighQualityAttachments: true,
},
resetComposer()
);
assert.deepEqual(nextState, getEmptyState());
});
});
describe('setLinkPreviewResult', () => {
it('sets loading state when loading', () => {
const { setLinkPreviewResult } = actions;
const state = getEmptyState();
const nextState = reducer(state, setLinkPreviewResult(true));
assert.isTrue(nextState.linkPreviewLoading);
});
it('sets the link preview result', () => {
const { setLinkPreviewResult } = actions;
const state = getEmptyState();
const nextState = reducer(
state,
setLinkPreviewResult(false, {
domain: 'https://www.signal.org/',
title: 'Signal >> Careers',
url: 'https://www.signal.org/workworkwork',
description:
'Join an organization that empowers users by making private communication simple.',
date: null,
})
);
assert.isFalse(nextState.linkPreviewLoading);
assert.equal(nextState.linkPreviewResult?.title, 'Signal >> Careers');
});
});
describe('setMediaQualitySetting', () => {
it('toggles the media quality setting', () => {
const { setMediaQualitySetting } = actions;
const state = getEmptyState();
assert.isFalse(state.shouldSendHighQualityAttachments);
const nextState = reducer(state, setMediaQualitySetting(true));
assert.isTrue(nextState.shouldSendHighQualityAttachments);
const nextNextState = reducer(nextState, setMediaQualitySetting(false));
assert.isFalse(nextNextState.shouldSendHighQualityAttachments);
});
});
describe('setQuotedMessage', () => {
it('sets the quoted message', () => {
const { setQuotedMessage } = actions;
const state = getEmptyState();
const nextState = reducer(state, setQuotedMessage(QUOTED_MESSAGE));
assert.equal(nextState.quotedMessage?.conversationId, '123');
assert.equal(nextState.quotedMessage?.quote?.id, '456');
});
});
});

View File

@ -0,0 +1,27 @@
// Copyright 2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import { assert } from 'chai';
import { canvasToBlob } from '../../util/canvasToBlob';
describe('canvasToBlob', () => {
it('converts a canvas to an Blob', async () => {
const canvas = document.createElement('canvas');
canvas.width = 100;
canvas.height = 200;
const context = canvas.getContext('2d');
if (!context) {
throw new Error('Test setup error: cannot get canvas rendering context');
}
context.fillStyle = '#ff9900';
context.fillRect(10, 10, 20, 20);
const result = await canvasToBlob(canvas);
// These are just smoke tests.
assert.instanceOf(result, Blob);
assert.isAtLeast(result.size, 50);
});
});

View File

@ -43,6 +43,7 @@ export type AttachmentType = {
contentType: MIME.MIMEType;
path: string;
};
screenshotPath?: string;
flags?: number;
thumbnail?: ThumbnailType;
isCorrupted?: boolean;
@ -52,6 +53,29 @@ export type AttachmentType = {
cdnKey?: string;
};
type BaseAttachmentDraftType = {
blurHash?: string;
contentType: MIME.MIMEType;
fileName: string;
screenshotContentType?: string;
screenshotSize?: number;
size: number;
};
export type InMemoryAttachmentDraftType = {
data?: ArrayBuffer;
screenshotData?: ArrayBuffer;
} & BaseAttachmentDraftType;
export type OnDiskAttachmentDraftType = {
path?: string;
screenshotPath?: string;
} & BaseAttachmentDraftType;
export type AttachmentDraftType = {
url: string;
} & BaseAttachmentDraftType;
export type ThumbnailType = {
height: number;
width: number;

20
ts/types/LinkPreview.ts Normal file
View File

@ -0,0 +1,20 @@
// Copyright 2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import { AttachmentType } from './Attachment';
export type LinkPreviewImage = AttachmentType & {
data: ArrayBuffer;
};
export type LinkPreviewResult = {
title: string;
url: string;
image?: LinkPreviewImage;
description: string | null;
date: number | null;
};
export type LinkPreviewWithDomain = {
domain: string;
} & LinkPreviewResult;

View File

@ -0,0 +1,31 @@
// Copyright 2018-2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import loadImage, { LoadImageOptions } from 'blueimp-load-image';
import { IMAGE_JPEG } from '../types/MIME';
import { canvasToBlob } from './canvasToBlob';
const DEFAULT_JPEG_QUALITY = 0.85;
export async function autoOrientImage(blob: Blob): Promise<Blob> {
const options: LoadImageOptions = {
canvas: true,
orientation: true,
};
try {
const data = await loadImage(blob, options);
const { image } = data;
if (image instanceof HTMLCanvasElement) {
// We `return await`, instead of just `return`, so we capture the rejection in this
// try/catch block. See [this blog post][0] for more background.
// [0]: https://jakearchibald.com/2017/await-vs-return-vs-return-await/
return await canvasToBlob(image, IMAGE_JPEG, DEFAULT_JPEG_QUALITY);
}
throw new Error('image not a canvas');
} catch (err) {
const error = new Error('autoOrientImage: Failed to process image');
error.originalError = err;
throw error;
}
}

View File

@ -1,17 +1,11 @@
// Copyright 2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import { canvasToBlob } from './canvasToBlob';
export async function canvasToArrayBuffer(
canvas: HTMLCanvasElement
): Promise<ArrayBuffer> {
const blob: Blob = await new Promise<Blob>((resolve, reject) => {
canvas.toBlob(result => {
if (result) {
resolve(result);
} else {
reject(new Error("Couldn't convert the canvas to a Blob"));
}
}, 'image/webp');
});
const blob = await canvasToBlob(canvas);
return blob.arrayBuffer();
}

29
ts/util/canvasToBlob.ts Normal file
View File

@ -0,0 +1,29 @@
// Copyright 2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import { IMAGE_JPEG } from '../types/MIME';
/**
* Similar to [the built-in `toBlob` method][0], but returns a Promise.
*
* [0]: https://developer.mozilla.org/en-US/docs/Web/API/HTMLCanvasElement/toBlob
*/
export async function canvasToBlob(
canvas: HTMLCanvasElement,
mimeType = IMAGE_JPEG,
quality?: number
): Promise<Blob> {
return new Promise((resolve, reject) =>
canvas.toBlob(
result => {
if (result) {
resolve(result);
} else {
reject(new Error("Couldn't convert the canvas to a Blob"));
}
},
mimeType,
quality
)
);
}

View File

@ -1913,19 +1913,6 @@
"reasonCategory": "falseMatch",
"updated": "2018-09-19T18:13:29.628Z"
},
{
"rule": "jQuery-append(",
"path": "node_modules/blueimp-canvas-to-blob/js/canvas-to-blob.js",
"line": " bb.append(arrayBuffer)",
"reasonCategory": "falseMatch",
"updated": "2018-09-19T18:13:29.628Z"
},
{
"rule": "jQuery-append(",
"path": "node_modules/blueimp-canvas-to-blob/js/canvas-to-blob.min.js",
"reasonCategory": "falseMatch",
"updated": "2018-09-19T18:13:29.628Z"
},
{
"rule": "jQuery-wrap(",
"path": "node_modules/boom/lib/index.js",
@ -13436,14 +13423,6 @@
"updated": "2020-10-26T19:12:24.410Z",
"reasonDetail": "Doesn't refer to a DOM element."
},
{
"rule": "React-useRef",
"path": "ts/components/CompositionArea.js",
"line": " const attSlotRef = React.useRef(null);",
"reasonCategory": "usageTrusted",
"updated": "2020-10-26T19:12:24.410Z",
"reasonDetail": "Needed for the composition area."
},
{
"rule": "React-useRef",
"path": "ts/components/CompositionArea.js",

View File

@ -0,0 +1,145 @@
// Copyright 2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import loadImage from 'blueimp-load-image';
import { IMAGE_JPEG } from '../types/MIME';
import { canvasToBlob } from './canvasToBlob';
import { getValue } from '../RemoteConfig';
enum MediaQualityLevels {
One = 1,
Two = 2,
Three = 3,
}
const DEFAULT_LEVEL = MediaQualityLevels.One;
const MiB = 1024 * 1024;
const DEFAULT_LEVEL_DATA = {
maxDimensions: 1600,
quality: 0.7,
size: MiB,
};
const MEDIA_QUALITY_LEVEL_DATA = new Map([
[MediaQualityLevels.One, DEFAULT_LEVEL_DATA],
[
MediaQualityLevels.Two,
{
maxDimensions: 2048,
quality: 0.75,
size: MiB * 1.5,
},
],
[
MediaQualityLevels.Three,
{
maxDimensions: 4096,
quality: 0.75,
size: MiB * 3,
},
],
]);
const SCALABLE_DIMENSIONS = [3072, 2048, 1600, 1024, 768];
const MIN_DIMENSIONS = 512;
function parseCountryValues(values: string): Map<string, MediaQualityLevels> {
const map = new Map<string, MediaQualityLevels>();
values.split(',').forEach(value => {
const [countryCode, level] = value.split(':');
map.set(
countryCode,
Number(level) === 2 ? MediaQualityLevels.Two : MediaQualityLevels.One
);
});
return map;
}
function getMediaQualityLevel(): MediaQualityLevels {
const values = getValue('desktop.mediaQuality.levels');
if (!values) {
return DEFAULT_LEVEL;
}
const countryValues = parseCountryValues(values);
const e164 = window.textsecure.storage.user.getNumber();
if (!e164) {
return DEFAULT_LEVEL;
}
const parsedPhoneNumber = window.libphonenumber.util.parseNumber(e164);
if (!parsedPhoneNumber.isValidNumber) {
return DEFAULT_LEVEL;
}
const level = countryValues.get(parsedPhoneNumber.countryCode);
if (level) {
return level;
}
return countryValues.get('*') || DEFAULT_LEVEL;
}
async function getCanvasBlob(
image: HTMLCanvasElement,
dimensions: number,
quality: number
): Promise<Blob> {
const canvas = loadImage.scale(image, {
canvas: true,
maxHeight: dimensions,
maxWidth: dimensions,
});
if (!(canvas instanceof HTMLCanvasElement)) {
throw new Error('image not a canvas');
}
return canvasToBlob(canvas, IMAGE_JPEG, quality);
}
export async function scaleImageToLevel(
fileOrBlobOrURL: File | Blob,
sendAsHighQuality?: boolean
): Promise<Blob> {
let image: HTMLCanvasElement;
try {
const data = await loadImage(fileOrBlobOrURL, {
canvas: true,
orientation: true,
});
if (!(data.image instanceof HTMLCanvasElement)) {
throw new Error('image not a canvas');
}
({ image } = data);
if (!(image instanceof HTMLCanvasElement)) {
throw new Error('image not a canvas');
}
} catch (err) {
const error = new Error('scaleImageToLevel: Failed to process image');
error.originalError = err;
throw error;
}
const level = sendAsHighQuality
? MediaQualityLevels.Three
: getMediaQualityLevel();
const { maxDimensions, quality, size } =
MEDIA_QUALITY_LEVEL_DATA.get(level) || DEFAULT_LEVEL_DATA;
for (let i = 0; i < SCALABLE_DIMENSIONS.length; i += 1) {
const scalableDimensions = SCALABLE_DIMENSIONS[i];
if (maxDimensions < scalableDimensions) {
continue;
}
// We need these operations to be in serial
// eslint-disable-next-line no-await-in-loop
const blob = await getCanvasBlob(image, scalableDimensions, quality);
if (blob.size <= size) {
return blob;
}
}
return getCanvasBlob(image, MIN_DIMENSIONS, quality);
}

View File

@ -3,7 +3,13 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import { AttachmentType } from '../types/Attachment';
import {
AttachmentDraftType,
AttachmentType,
InMemoryAttachmentDraftType,
OnDiskAttachmentDraftType,
} from '../types/Attachment';
import { IMAGE_JPEG } from '../types/MIME';
import { ConversationModel } from '../models/conversations';
import {
GroupV2PendingMemberType,
@ -28,30 +34,19 @@ import * as Bytes from '../Bytes';
import {
canReply,
getAttachmentsForMessage,
getPropsForQuote,
isOutgoing,
isTapToView,
} from '../state/selectors/message';
import { getMessagesByConversation } from '../state/selectors/conversations';
import { ConversationDetailsMembershipList } from '../components/conversation/conversation-details/ConversationDetailsMembershipList';
import { showSafetyNumberChangeDialog } from '../shims/showSafetyNumberChangeDialog';
type GetLinkPreviewImageResult = {
data: ArrayBuffer;
size: number;
contentType: string;
width?: number;
height?: number;
blurHash: string;
};
type GetLinkPreviewResult = {
title: string;
url: string;
image?: GetLinkPreviewImageResult;
description: string | null;
date: number | null;
};
import { autoOrientImage } from '../util/autoOrientImage';
import { canvasToBlob } from '../util/canvasToBlob';
import {
LinkPreviewImage,
LinkPreviewResult,
LinkPreviewWithDomain,
} from '../types/LinkPreview';
type AttachmentOptions = {
messageId: string;
@ -421,21 +416,12 @@ Whisper.ConversationView = Whisper.View.extend({
this.loadingScreen.render();
this.loadingScreen.$el.prependTo(this.$('.discussion-container'));
const attachmentListEl = $(
'<div class="module-composition-area__attachment-list"></div>'
);
this.attachmentListView = new Whisper.ReactWrapperView({
el: attachmentListEl,
Component: window.Signal.Components.AttachmentList,
props: this.getPropsForAttachmentList(),
});
this.setupHeader();
this.setupTimeline();
this.setupCompositionArea({ attachmentListEl: attachmentListEl[0] });
this.setupCompositionArea();
this.linkPreviewAbortController = null;
this.updateAttachmentsView();
},
events: {
@ -615,7 +601,9 @@ Whisper.ConversationView = Whisper.View.extend({
window.reduxActions.conversations.setSelectedConversationHeaderTitle();
},
setupCompositionArea({ attachmentListEl }: any) {
setupCompositionArea() {
window.reduxActions.composer.resetComposer();
const { model }: { model: ConversationModel } = this;
const compositionApi = { current: null };
@ -650,7 +638,6 @@ Whisper.ConversationView = Whisper.View.extend({
getQuotedMessage: () => model.get('quotedMessageId'),
clearQuotedMessage: () => this.setQuoteMessage(null),
micCellEl,
attachmentListEl,
onAccept: () => {
this.syncMessageRequestResponse(
'onAccept',
@ -698,6 +685,21 @@ Whisper.ConversationView = Whisper.View.extend({
},
});
},
onAddAttachment: this.onChooseAttachment.bind(this),
onClickAttachment: this.onClickAttachment.bind(this),
onCloseAttachment: this.onCloseAttachment.bind(this),
onClearAttachments: this.clearAttachments.bind(this),
onSelectMediaQuality: (isHQ: boolean) => {
window.reduxActions.composer.setMediaQualitySetting(isHQ);
},
onClickQuotedMessage: (id?: string) => this.scrollToMessage(id),
onCloseLinkPreview: () => {
this.disableLinkPreviews = true;
this.removeLinkPreview();
},
};
this.compositionAreaView = new Whisper.ReactWrapperView({
@ -1444,9 +1446,6 @@ Whisper.ConversationView = Whisper.View.extend({
this.timelineView.remove();
this.compositionAreaView.remove();
if (this.attachmentListView) {
this.attachmentListView.remove();
}
if (this.captionEditorView) {
this.captionEditorView.remove();
}
@ -1468,9 +1467,6 @@ Whisper.ConversationView = Whisper.View.extend({
if (this.scrollDownButton) {
this.scrollDownButton.remove();
}
if (this.quoteView) {
this.quoteView.remove();
}
if (this.lightboxView) {
this.lightboxView.remove();
}
@ -1587,37 +1583,6 @@ Whisper.ConversationView = Whisper.View.extend({
});
},
getPropsForAttachmentList() {
const { model }: { model: ConversationModel } = this;
const draftAttachments = model.get('draftAttachments') || [];
return {
// In conversation model/redux
attachments: draftAttachments.map(attachment => {
let url = '';
if (attachment.screenshotPath) {
url = getAbsoluteDraftPath(attachment.screenshotPath);
} else if (attachment.path) {
url = getAbsoluteDraftPath(attachment.path);
} else {
window.log.warn(
'getPropsForAttachmentList: Attachment was missing both screenshotPath and path fields'
);
}
return {
...attachment,
url,
};
}),
// Passed in from ConversationView
onAddAttachment: this.onChooseAttachment.bind(this),
onClickAttachment: this.onClickAttachment.bind(this),
onCloseAttachment: this.onCloseAttachment.bind(this),
onClose: this.clearAttachments.bind(this),
};
},
onClickAttachment(attachment: any) {
const getProps = () => ({
url: attachment.url,
@ -1663,9 +1628,7 @@ Whisper.ConversationView = Whisper.View.extend({
window.Signal.Backbone.Views.Lightbox.show(this.captionEditorView.el);
},
async deleteDraftAttachment(
attachment: Readonly<{ screenshotPath?: string; path?: string }>
) {
async deleteDraftAttachment(attachment: AttachmentType) {
if (attachment.screenshotPath) {
await deleteDraftFile(attachment.screenshotPath);
}
@ -1679,7 +1642,7 @@ Whisper.ConversationView = Whisper.View.extend({
window.Signal.Data.updateConversation(model.attributes);
},
async addAttachment(attachment: any) {
async addAttachment(attachment: InMemoryAttachmentDraftType) {
const { model }: { model: ConversationModel } = this;
const onDisk = await this.writeDraftAttachment(attachment);
@ -1692,6 +1655,26 @@ Whisper.ConversationView = Whisper.View.extend({
await this.saveModel();
},
resolveOnDiskAttachment(
attachment: OnDiskAttachmentDraftType
): AttachmentDraftType {
let url = '';
if (attachment.screenshotPath) {
url = getAbsoluteDraftPath(attachment.screenshotPath);
} else if (attachment.path) {
url = getAbsoluteDraftPath(attachment.path);
} else {
window.log.warn(
'resolveOnDiskAttachment: Attachment was missing both screenshotPath and path fields'
);
}
return {
...attachment,
url,
};
},
async onCloseAttachment(attachment: any) {
const { model }: { model: ConversationModel } = this;
const draftAttachments = model.get('draftAttachments') || [];
@ -1801,14 +1784,21 @@ Whisper.ConversationView = Whisper.View.extend({
},
updateAttachmentsView() {
this.attachmentListView.update(this.getPropsForAttachmentList());
const draftAttachments = this.model.get('draftAttachments') || [];
window.reduxActions.composer.replaceAttachments(
draftAttachments.map((att: AttachmentType) =>
this.resolveOnDiskAttachment(att)
)
);
this.toggleMicrophone();
if (this.hasFiles()) {
this.removeLinkPreview();
}
},
async writeDraftAttachment(attachment: any) {
async writeDraftAttachment(
attachment: InMemoryAttachmentDraftType
): Promise<OnDiskAttachmentDraftType> {
let toWrite = attachment;
if (toWrite.data) {
@ -1869,7 +1859,7 @@ Whisper.ConversationView = Whisper.View.extend({
return;
}
let attachment;
let attachment: InMemoryAttachmentDraftType;
try {
if (window.Signal.Util.GoogleChrome.isImageTypeSupported(file.type)) {
@ -1949,7 +1939,7 @@ Whisper.ConversationView = Whisper.View.extend({
return true;
},
async handleVideoAttachment(file: any) {
async handleVideoAttachment(file: any): Promise<InMemoryAttachmentDraftType> {
const objectUrl = URL.createObjectURL(file);
if (!objectUrl) {
throw new Error('Failed to create object url for video!');
@ -1980,11 +1970,10 @@ Whisper.ConversationView = Whisper.View.extend({
}
},
async handleImageAttachment(file: any) {
async handleImageAttachment(file: any): Promise<InMemoryAttachmentDraftType> {
const blurHash = await window.imageToBlurHash(file);
if (MIME.isJPEG(file.type)) {
const rotatedDataUrl = await window.autoOrientImage(file);
const rotatedBlob = window.dataURLToBlobSync(rotatedDataUrl);
const rotatedBlob = await autoOrientImage(file);
const { contentType, file: resizedBlob, fileName } = await this.autoScale(
{
contentType: file.type,
@ -1992,7 +1981,7 @@ Whisper.ConversationView = Whisper.View.extend({
file: rotatedBlob,
}
);
const data = await await VisualAttachment.blobToArrayBuffer(resizedBlob);
const data = await VisualAttachment.blobToArrayBuffer(resizedBlob);
return {
fileName: fileName || file.name,
@ -2008,7 +1997,7 @@ Whisper.ConversationView = Whisper.View.extend({
fileName: file.name,
file,
});
const data = await await VisualAttachment.blobToArrayBuffer(resizedBlob);
const data = await VisualAttachment.blobToArrayBuffer(resizedBlob);
return {
fileName: fileName || file.name,
contentType,
@ -2028,7 +2017,7 @@ Whisper.ConversationView = Whisper.View.extend({
return new Promise((resolve, reject) => {
const url = URL.createObjectURL(file);
const img = document.createElement('img');
img.onload = () => {
img.onload = async () => {
URL.revokeObjectURL(url);
const maxSize = 6000 * 1024;
@ -2054,7 +2043,7 @@ Whisper.ConversationView = Whisper.View.extend({
return;
}
const targetContentType = 'image/jpeg';
const targetContentType = IMAGE_JPEG;
const canvas = window.loadImage.scale(img, {
canvas: true,
maxWidth,
@ -2066,9 +2055,9 @@ Whisper.ConversationView = Whisper.View.extend({
let blob;
do {
i -= 1;
blob = window.dataURLToBlobSync(
canvas.toDataURL(targetContentType, quality)
);
// We want to do these operations in serial.
// eslint-disable-next-line no-await-in-loop
blob = await canvasToBlob(canvas, targetContentType, quality);
quality = (quality * maxSize) / blob.size;
// NOTE: During testing with a large image, we observed the
// `quality` value being > 1. Should we clamp it to [0.5, 1.0]?
@ -3780,11 +3769,6 @@ Whisper.ConversationView = Whisper.View.extend({
await this.saveModel();
}
if (this.quoteView) {
this.quoteView.remove();
this.quoteView = null;
}
if (message) {
const quotedMessage = window.MessageController.register(
message.id,
@ -3806,47 +3790,15 @@ Whisper.ConversationView = Whisper.View.extend({
renderQuotedMessage() {
const { model }: { model: ConversationModel } = this;
if (this.quoteView) {
this.quoteView.remove();
this.quoteView = null;
}
if (!this.quotedMessage) {
window.reduxActions.composer.setQuotedMessage(undefined);
return;
}
const props = getPropsForQuote(
{
conversationId: model.id,
quote: this.quote,
},
findAndFormatContact,
window.ConversationController.getOurConversationIdOrThrow()
);
const contact = this.quotedMessage.getContact();
this.quoteView = new Whisper.ReactWrapperView({
className: 'quote-wrapper',
Component: window.Signal.Components.Quote,
elCallback: (el: any) =>
this.$(this.compositionApi.current.attSlotRef.current).prepend(el),
props: {
...props,
withContentAbove: true,
onClick: () => this.scrollToMessage(this.quotedMessage.id),
onClose: () => {
// This can't be the normal 'onClose' because that is always run when this
// view is removed from the DOM, and would clear the draft quote.
this.setQuoteMessage(null);
},
},
window.reduxActions.composer.setQuotedMessage({
conversationId: model.id,
quote: this.quote,
});
if (contact) {
this.quoteView.listenTo(contact, 'change', () => {
this.renderQuotedMessage();
});
}
},
showInvalidMessageToast(messageText?: string): boolean {
@ -3939,7 +3891,13 @@ Whisper.ConversationView = Whisper.View.extend({
this.quote,
this.getLinkPreview(),
undefined, // sticker
mentions
mentions,
{
sendHQImages:
window.reduxStore &&
window.reduxStore.getState().composer
.shouldSendHighQualityAttachments,
}
);
this.compositionApi.current.reset();
@ -3947,6 +3905,7 @@ Whisper.ConversationView = Whisper.View.extend({
this.setQuoteMessage(null);
this.resetLinkPreview();
this.clearAttachments();
window.reduxActions.composer.resetComposer();
} catch (error) {
window.log.error(
'Error pulling attached files before send',
@ -4068,7 +4027,7 @@ Whisper.ConversationView = Whisper.View.extend({
async getStickerPackPreview(
url: string,
abortSignal: Readonly<AbortSignal>
): Promise<null | GetLinkPreviewResult> {
): Promise<null | LinkPreviewResult> {
const isPackDownloaded = (pack: any) =>
pack && (pack.status === 'downloaded' || pack.status === 'installed');
const isPackValid = (pack: any) =>
@ -4144,7 +4103,7 @@ Whisper.ConversationView = Whisper.View.extend({
async getGroupPreview(
url: string,
abortSignal: Readonly<AbortSignal>
): Promise<null | GetLinkPreviewResult> {
): Promise<null | LinkPreviewResult> {
const urlObject = maybeParseUrl(url);
if (!urlObject) {
return null;
@ -4187,7 +4146,7 @@ Whisper.ConversationView = Whisper.View.extend({
: window.i18n('GroupV2--join--member-count--multiple', {
count: result.memberCount.toString(),
});
let image: undefined | GetLinkPreviewImageResult;
let image: undefined | LinkPreviewImage;
if (result.avatar) {
try {
@ -4198,10 +4157,10 @@ Whisper.ConversationView = Whisper.View.extend({
image = {
data,
size: data.byteLength,
contentType: 'image/jpeg',
contentType: IMAGE_JPEG,
blurHash: await window.imageToBlurHash(
new Blob([data], {
type: 'image/jpeg',
type: IMAGE_JPEG,
})
),
};
@ -4229,7 +4188,7 @@ Whisper.ConversationView = Whisper.View.extend({
async getPreview(
url: string,
abortSignal: Readonly<AbortSignal>
): Promise<null | GetLinkPreviewResult> {
): Promise<null | LinkPreviewResult> {
if (window.Signal.LinkPreviews.isStickerPack(url)) {
return this.getStickerPackPreview(url, abortSignal);
}
@ -4410,32 +4369,10 @@ Whisper.ConversationView = Whisper.View.extend({
if (this.forwardMessageModal) {
return;
}
if (this.previewView) {
this.previewView.remove();
this.previewView = null;
}
if (!this.currentlyMatchedLink) {
return;
}
const first = (this.preview && this.preview[0]) || null;
const props = {
...first,
domain: first && window.Signal.LinkPreviews.getDomain(first.url),
isLoaded: Boolean(first),
onClose: () => {
this.disableLinkPreviews = true;
this.removeLinkPreview();
},
};
this.previewView = new Whisper.ReactWrapperView({
className: 'preview-wrapper',
Component: window.Signal.Components.StagedLinkPreview,
elCallback: (el: any) =>
this.$(this.compositionApi.current.attSlotRef.current).prepend(el),
props,
});
window.reduxActions.composer.setLinkPreviewResult(
Boolean(this.currentlyMatchedLink),
this.getLinkPreviewWithDomain()
);
},
getLinkPreview() {
@ -4461,6 +4398,18 @@ Whisper.ConversationView = Whisper.View.extend({
});
},
getLinkPreviewWithDomain(): LinkPreviewWithDomain | undefined {
if (!this.preview || !this.preview.length) {
return undefined;
}
const [preview] = this.preview;
return {
...preview,
domain: window.Signal.LinkPreviews.getDomain(preview.url),
};
},
// Called whenever the user changes the message composition field. But only
// fires if there's content in the message field after the change.
maybeBumpTyping(messageText: string) {

4
ts/window.d.ts vendored
View File

@ -150,8 +150,6 @@ declare global {
moment: typeof moment;
imageToBlurHash: typeof imageToBlurHash;
autoOrientImage: any;
dataURLToBlobSync: any;
loadImage: any;
isBehindProxy: () => boolean;
getAutoLaunch: () => boolean;
@ -220,7 +218,7 @@ declare global {
getRegionCodeForNumber: (number: string) => string;
parseNumber: (
e164: string,
defaultRegionCode: string
defaultRegionCode?: string
) =>
| { isValidNumber: false; error: unknown }
| {

View File

@ -4728,11 +4728,6 @@ bluebird@^3.3.5, bluebird@^3.5.4, bluebird@^3.5.5:
resolved "https://registry.yarnpkg.com/bluebird/-/bluebird-3.5.5.tgz#a8d0afd73251effbbd5fe384a77d73003c17a71f"
integrity sha512-5am6HnnfN+urzt4yfg7IgTbotDjIT/u8AJpEt0sIU9FtXfVeezXAPKswrG+xKUCOYAINpSdgZVDU6QFh+cuH3w==
blueimp-canvas-to-blob@3.14.0:
version "3.14.0"
resolved "https://registry.yarnpkg.com/blueimp-canvas-to-blob/-/blueimp-canvas-to-blob-3.14.0.tgz#ea075ffbfb1436607b0c75e951fb1ceb3ca0288e"
integrity sha512-i6I2CiX1VR8YwUNYBo+dM8tg89ns4TTHxSpWjaDeHKcYS3yFalpLCwDaY21/EsJMufLy2tnG4j0JN5L8OVNkKQ==
blueimp-load-image@5.14.0:
version "5.14.0"
resolved "https://registry.yarnpkg.com/blueimp-load-image/-/blueimp-load-image-5.14.0.tgz#e8086415e580df802c33ff0da6b37a8d20205cc6"