From 01eabf9ec676b022b4bb11ce4c78523359f70cb3 Mon Sep 17 00:00:00 2001 From: Josh Perez <60019601+josh-signal@users.noreply.github.com> Date: Fri, 25 Jun 2021 12:08:16 -0400 Subject: [PATCH] Option to send photos as high quality --- ACKNOWLEDGMENTS.md | 24 -- _locales/en/messages.json | 24 ++ images/icons/v2/hq-outline-24.svg | 1 + images/icons/v2/hq-solid-24.svg | 1 + images/icons/v2/sq-24.svg | 1 + js/modules/auto_orient_image.js | 43 --- js/modules/types/attachment.js | 27 +- js/modules/types/message.js | 2 +- js/modules/types/visual_attachment.js | 26 +- package.json | 1 - .../@types+blueimp-load-image+5.14.1.patch | 25 ++ preload.js | 3 - stylesheets/_modules.scss | 5 + .../components/MediaQualitySelector.scss | 116 ++++++++ stylesheets/manifest.scss | 1 + ts/RemoteConfig.ts | 1 + ts/background.ts | 1 + ts/components/CompositionArea.stories.tsx | 19 ++ ts/components/CompositionArea.tsx | 133 ++++++--- ts/components/ForwardMessageModal.tsx | 1 - .../MediaQualitySelector.stories.tsx | 34 +++ ts/components/MediaQualitySelector.tsx | 152 ++++++++++ .../conversation/LinkPreviewDate.tsx | 4 +- .../StagedLinkPreview.stories.tsx | 9 +- .../conversation/StagedLinkPreview.tsx | 15 +- ts/model-types.d.ts | 7 +- ts/models/conversations.ts | 32 ++- ts/state/actions.ts | 3 + ts/state/ducks/composer.ts | 168 +++++++++++ ts/state/reducer.ts | 2 + ts/state/smart/CompositionArea.tsx | 34 ++- ts/state/types.ts | 2 + ts/test-both/state/ducks/composer_test.ts | 120 ++++++++ ts/test-electron/util/canvasToBlob_test.ts | 27 ++ ts/types/Attachment.ts | 24 ++ ts/types/LinkPreview.ts | 20 ++ ts/util/autoOrientImage.ts | 31 ++ ts/util/canvasToArrayBuffer.ts | 12 +- ts/util/canvasToBlob.ts | 29 ++ ts/util/lint/exceptions.json | 21 -- ts/util/scaleImageToLevel.ts | 145 ++++++++++ ts/views/conversation_view.ts | 271 +++++++----------- ts/window.d.ts | 4 +- yarn.lock | 5 - 44 files changed, 1263 insertions(+), 363 deletions(-) create mode 100644 images/icons/v2/hq-outline-24.svg create mode 100644 images/icons/v2/hq-solid-24.svg create mode 100644 images/icons/v2/sq-24.svg delete mode 100644 js/modules/auto_orient_image.js create mode 100644 patches/@types+blueimp-load-image+5.14.1.patch create mode 100644 stylesheets/components/MediaQualitySelector.scss create mode 100644 ts/components/MediaQualitySelector.stories.tsx create mode 100644 ts/components/MediaQualitySelector.tsx create mode 100644 ts/state/ducks/composer.ts create mode 100644 ts/test-both/state/ducks/composer_test.ts create mode 100644 ts/test-electron/util/canvasToBlob_test.ts create mode 100644 ts/types/LinkPreview.ts create mode 100644 ts/util/autoOrientImage.ts create mode 100644 ts/util/canvasToBlob.ts create mode 100644 ts/util/scaleImageToLevel.ts diff --git a/ACKNOWLEDGMENTS.md b/ACKNOWLEDGMENTS.md index 3b6cd80a4a..619b6b8083 100644 --- a/ACKNOWLEDGMENTS.md +++ b/ACKNOWLEDGMENTS.md @@ -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 diff --git a/_locales/en/messages.json b/_locales/en/messages.json index 4eb06c3f86..4212f5a63e 100644 --- a/_locales/en/messages.json +++ b/_locales/en/messages.json @@ -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" } } diff --git a/images/icons/v2/hq-outline-24.svg b/images/icons/v2/hq-outline-24.svg new file mode 100644 index 0000000000..d3362e7896 --- /dev/null +++ b/images/icons/v2/hq-outline-24.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/images/icons/v2/hq-solid-24.svg b/images/icons/v2/hq-solid-24.svg new file mode 100644 index 0000000000..bc739c86f2 --- /dev/null +++ b/images/icons/v2/hq-solid-24.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/images/icons/v2/sq-24.svg b/images/icons/v2/sq-24.svg new file mode 100644 index 0000000000..8dcd838343 --- /dev/null +++ b/images/icons/v2/sq-24.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/js/modules/auto_orient_image.js b/js/modules/auto_orient_image.js deleted file mode 100644 index 3f567f780f..0000000000 --- a/js/modules/auto_orient_image.js +++ /dev/null @@ -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 -// -// 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 - ); - }); -}; diff --git a/js/modules/types/attachment.js b/js/modules/types/attachment.js index c1a2638618..0a674934a9 100644 --- a/js/modules/types/attachment.js +++ b/js/modules/types/attachment.js @@ -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, we’d preserve the original image data for users who want to // retain it but due to reports of data loss, we don’t 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'; diff --git a/js/modules/types/message.js b/js/modules/types/message.js index 4f68c7ab98..958cb9c126 100644 --- a/js/modules/types/message.js +++ b/js/modules/types/message.js @@ -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) ); diff --git a/js/modules/types/visual_attachment.js b/js/modules/types/visual_attachment.js index 8b30ca6fe4..491d09ae85 100644 --- a/js/modules/types/visual_attachment.js +++ b/js/modules/types/visual_attachment.js @@ -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); diff --git a/package.json b/package.json index f30f66360c..4a443a4436 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/patches/@types+blueimp-load-image+5.14.1.patch b/patches/@types+blueimp-load-image+5.14.1.patch new file mode 100644 index 0000000000..c199a97439 --- /dev/null +++ b/patches/@types+blueimp-load-image+5.14.1.patch @@ -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; diff --git a/preload.js b/preload.js index 3e639914a0..01ab1f173e 100644 --- a/preload.js +++ b/preload.js @@ -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(); diff --git a/stylesheets/_modules.scss b/stylesheets/_modules.scss index 5d2da3dec1..94e4e9ea57 100644 --- a/stylesheets/_modules.scss +++ b/stylesheets/_modules.scss @@ -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; diff --git a/stylesheets/components/MediaQualitySelector.scss b/stylesheets/components/MediaQualitySelector.scss new file mode 100644 index 0000000000..12152d6296 --- /dev/null +++ b/stylesheets/components/MediaQualitySelector.scss @@ -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; + } + } +} diff --git a/stylesheets/manifest.scss b/stylesheets/manifest.scss index 3544dbc6f3..dbce8e8ca4 100644 --- a/stylesheets/manifest.scss +++ b/stylesheets/manifest.scss @@ -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'; diff --git a/ts/RemoteConfig.ts b/ts/RemoteConfig.ts index 84ca90343c..b8c8c41567 100644 --- a/ts/RemoteConfig.ts +++ b/ts/RemoteConfig.ts @@ -10,6 +10,7 @@ export type ConfigKeyType = | 'desktop.groupCalling' | 'desktop.gv2' | 'desktop.mandatoryProfileSharing' + | 'desktop.mediaQuality.levels' | 'desktop.messageRequests' | 'desktop.retryReceiptLifespan' | 'desktop.retryRespondMaxAge' diff --git a/ts/background.ts b/ts/background.ts index c83ff29abc..9767fedd3f 100644 --- a/ts/background.ts +++ b/ts/background.ts @@ -984,6 +984,7 @@ export async function startApp(): Promise { store.dispatch ), calling: bindActionCreators(actionCreators.calling, store.dispatch), + composer: bindActionCreators(actionCreators.composer, store.dispatch), conversations: bindActionCreators( actionCreators.conversations, store.dispatch diff --git a/ts/components/CompositionArea.stories.tsx b/ts/components/CompositionArea.stories.tsx index 7baf2fb6a5..82e5d17945 100644 --- a/ts/components/CompositionArea.stories.tsx +++ b/ts/components/CompositionArea.stories.tsx @@ -32,6 +32,25 @@ const createProps = (overrideProps: Partial = {}): 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'), diff --git a/ts/components/CompositionArea.tsx b/ts/components/CompositionArea.tsx index 546b62cbf1..3e39b2ce27 100644 --- a/ts/components/CompositionArea.tsx +++ b/ts/components/CompositionArea.tsx @@ -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; reset: InputApi['reset']; resetEmojiResults: InputApi['resetEmojiResults']; }>; readonly micCellEl?: HTMLElement; - readonly attCellEl?: HTMLElement; - readonly attachmentListEl?: HTMLElement; + readonly draftAttachments: Array; + 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(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 = ( -
- -
+ const leftHandSideButtonsFragment = ( + <> +
+ +
+ {showMediaQualitySelector ? ( +
+ +
+ ) : null} + ); const micButtonFragment = showMic ? ( @@ -480,15 +512,52 @@ export const CompositionArea = ({ 'module-composition-area__row', 'module-composition-area__row--column' )} - ref={attSlotRef} - /> + > + {quotedMessageProps && ( +
+ { + // This one is for redux... + setQuotedMessage(undefined); + // and this is for conversation_view. + clearQuotedMessage(); + }} + withContentAbove + /> +
+ )} + {linkPreviewLoading && ( +
+ +
+ )} + {draftAttachments.length ? ( +
+ +
+ ) : null} +
- {!large ? emojiButtonFragment : null} + {!large ? leftHandSideButtonsFragment : null}
- {emojiButtonFragment} + {leftHandSideButtonsFragment} {stickerButtonFragment} {attButton} {!dirty ? micButtonFragment : null} diff --git a/ts/components/ForwardMessageModal.tsx b/ts/components/ForwardMessageModal.tsx index c8cc83cfc4..6f02fe1717 100644 --- a/ts/components/ForwardMessageModal.tsx +++ b/ts/components/ForwardMessageModal.tsx @@ -269,7 +269,6 @@ export const ForwardMessageModal: FunctionComponent = ({ domain={linkPreview.url} i18n={i18n} image={linkPreview.image} - isLoaded onClose={() => removeLinkPreview()} title={linkPreview.title} /> diff --git a/ts/components/MediaQualitySelector.stories.tsx b/ts/components/MediaQualitySelector.stories.tsx new file mode 100644 index 0000000000..9991a9eb43 --- /dev/null +++ b/ts/components/MediaQualitySelector.stories.tsx @@ -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 => ({ + i18n, + isHighQuality: boolean('isHighQuality', Boolean(overrideProps.isHighQuality)), + onSelectQuality: action('onSelectQuality'), +}); + +story.add('Standard Quality', () => ( + +)); + +story.add('High Quality', () => ( + +)); diff --git a/ts/components/MediaQualitySelector.tsx b/ts/components/MediaQualitySelector.tsx new file mode 100644 index 0000000000..838d6a76df --- /dev/null +++ b/ts/components/MediaQualitySelector.tsx @@ -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(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 ( + + + {({ ref }) => ( + + +
+ )} + , + popperRoot + ) + : null} + + ); +}; diff --git a/ts/components/conversation/LinkPreviewDate.tsx b/ts/components/conversation/LinkPreviewDate.tsx index 1d672166a5..277b85bf8f 100644 --- a/ts/components/conversation/LinkPreviewDate.tsx +++ b/ts/components/conversation/LinkPreviewDate.tsx @@ -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; }; diff --git a/ts/components/conversation/StagedLinkPreview.stories.tsx b/ts/components/conversation/StagedLinkPreview.stories.tsx index c055d03cb1..2548a0c685 100644 --- a/ts/components/conversation/StagedLinkPreview.stories.tsx +++ b/ts/components/conversation/StagedLinkPreview.stories.tsx @@ -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 => ({ - isLoaded: boolean('isLoaded', overrideProps.isLoaded !== false), title: text( 'title', typeof overrideProps.title === 'string' @@ -57,9 +56,7 @@ const createProps = (overrideProps: Partial = {}): Props => ({ }); story.add('Loading', () => { - const props = createProps({ - isLoaded: false, - }); + const props = createProps({ domain: '' }); return ; }); diff --git a/ts/components/conversation/StagedLinkPreview.tsx b/ts/components/conversation/StagedLinkPreview.tsx index 374a130957..815df29fee 100644 --- a/ts/components/conversation/StagedLinkPreview.tsx +++ b/ts/components/conversation/StagedLinkPreview.tsx @@ -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 = ({ - isLoaded, onClose, i18n, title, @@ -33,6 +31,7 @@ export const StagedLinkPreview: React.FC = ({ domain, }: Props) => { const isImage = isImageAttachment(image); + const isLoaded = Boolean(domain); return (
= ({ {i18n('loadingPreview')}
) : null} - {isLoaded && image && isImage ? ( + {isLoaded && image && isImage && domain ? (
{i18n('stagedPreviewThumbnail', { + ): Promise { 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)) { diff --git a/ts/state/actions.ts b/ts/state/actions.ts index 729fc43fc8..bc7f29bdaa 100644 --- a/ts/state/actions.ts +++ b/ts/state/actions.ts @@ -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, diff --git a/ts/state/ducks/composer.ts b/ts/state/ducks/composer.ts new file mode 100644 index 0000000000..97b9030017 --- /dev/null +++ b/ts/state/ducks/composer.ts @@ -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; + linkPreviewLoading: boolean; + linkPreviewResult?: LinkPreviewWithDomain; + quotedMessage?: Pick; + 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; +}; + +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; +}; + +type ComposerActionType = + | ReplaceAttachmentsActionType + | ResetComposerActionType + | SetHighQualitySettingActionType + | SetLinkPreviewResultActionType + | SetQuotedMessageActionType; + +// Action Creators + +export const actions = { + replaceAttachments, + resetComposer, + setLinkPreviewResult, + setMediaQualitySetting, + setQuotedMessage, +}; + +function replaceAttachments( + payload: ReadonlyArray +): 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 +): SetQuotedMessageActionType { + return { + type: SET_QUOTED_MESSAGE, + payload, + }; +} + +// Reducer + +export function getEmptyState(): ComposerStateType { + return { + attachments: [], + linkPreviewLoading: false, + shouldSendHighQualityAttachments: false, + }; +} + +export function reducer( + state: Readonly = getEmptyState(), + action: Readonly +): 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; +} diff --git a/ts/state/reducer.ts b/ts/state/reducer.ts index 940bd4a82d..060315a36e 100644 --- a/ts/state/reducer.ts +++ b/ts/state/reducer.ts @@ -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, diff --git a/ts/state/smart/CompositionArea.tsx b/ts/state/smart/CompositionArea.tsx index dc8fc0fe72..6bc555603d 100644 --- a/ts/state/smart/CompositionArea.tsx +++ b/ts/state/smart/CompositionArea.tsx @@ -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), diff --git a/ts/state/types.ts b/ts/state/types.ts index 5c722719a0..87c9e9833a 100644 --- a/ts/state/types.ts +++ b/ts/state/types.ts @@ -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; diff --git a/ts/test-both/state/ducks/composer_test.ts b/ts/test-both/state/ducks/composer_test.ts new file mode 100644 index 0000000000..1bc0e14155 --- /dev/null +++ b/ts/test-both/state/ducks/composer_test.ts @@ -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 = [{ 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 = []; + 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'); + }); + }); +}); diff --git a/ts/test-electron/util/canvasToBlob_test.ts b/ts/test-electron/util/canvasToBlob_test.ts new file mode 100644 index 0000000000..0513915b7f --- /dev/null +++ b/ts/test-electron/util/canvasToBlob_test.ts @@ -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); + }); +}); diff --git a/ts/types/Attachment.ts b/ts/types/Attachment.ts index ae8ad86a33..e32713cadb 100644 --- a/ts/types/Attachment.ts +++ b/ts/types/Attachment.ts @@ -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; diff --git a/ts/types/LinkPreview.ts b/ts/types/LinkPreview.ts new file mode 100644 index 0000000000..94253b9b3a --- /dev/null +++ b/ts/types/LinkPreview.ts @@ -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; diff --git a/ts/util/autoOrientImage.ts b/ts/util/autoOrientImage.ts new file mode 100644 index 0000000000..dc2a23d050 --- /dev/null +++ b/ts/util/autoOrientImage.ts @@ -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 { + 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; + } +} diff --git a/ts/util/canvasToArrayBuffer.ts b/ts/util/canvasToArrayBuffer.ts index 2d9763f2c7..33537ad083 100644 --- a/ts/util/canvasToArrayBuffer.ts +++ b/ts/util/canvasToArrayBuffer.ts @@ -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 { - const blob: Blob = await new Promise((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(); } diff --git a/ts/util/canvasToBlob.ts b/ts/util/canvasToBlob.ts new file mode 100644 index 0000000000..20671a6975 --- /dev/null +++ b/ts/util/canvasToBlob.ts @@ -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 { + 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 + ) + ); +} diff --git a/ts/util/lint/exceptions.json b/ts/util/lint/exceptions.json index 624e71ebe7..3b4b94e25a 100644 --- a/ts/util/lint/exceptions.json +++ b/ts/util/lint/exceptions.json @@ -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", diff --git a/ts/util/scaleImageToLevel.ts b/ts/util/scaleImageToLevel.ts new file mode 100644 index 0000000000..a994d3b606 --- /dev/null +++ b/ts/util/scaleImageToLevel.ts @@ -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 { + const map = new Map(); + 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 { + 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 { + 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); +} diff --git a/ts/views/conversation_view.ts b/ts/views/conversation_view.ts index c3af1df0b3..53b1264271 100644 --- a/ts/views/conversation_view.ts +++ b/ts/views/conversation_view.ts @@ -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 = $( - '
' - ); - - 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 { 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 { 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 { 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 - ): Promise { + ): Promise { 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 - ): Promise { + ): Promise { 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 - ): Promise { + ): Promise { 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) { diff --git a/ts/window.d.ts b/ts/window.d.ts index 224fe46e23..23cff3456e 100644 --- a/ts/window.d.ts +++ b/ts/window.d.ts @@ -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 } | { diff --git a/yarn.lock b/yarn.lock index 4969942bca..9f03952552 100644 --- a/yarn.lock +++ b/yarn.lock @@ -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"