Option to send photos as high quality
This commit is contained in:
parent
6c56d5a5f1
commit
01eabf9ec6
44 changed files with 1263 additions and 363 deletions
|
@ -719,30 +719,6 @@ Signal Desktop makes use of the following open source projects.
|
||||||
See the License for the specific language governing permissions and
|
See the License for the specific language governing permissions and
|
||||||
limitations under the License.
|
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
|
## blueimp-load-image
|
||||||
|
|
||||||
MIT License
|
MIT License
|
||||||
|
|
|
@ -5605,5 +5605,29 @@
|
||||||
"ConversationDetailsHeader--add-group-description": {
|
"ConversationDetailsHeader--add-group-description": {
|
||||||
"message": "Add group description...",
|
"message": "Add group description...",
|
||||||
"description": "Placeholder text in the details header for those that can edit the 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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
1
images/icons/v2/hq-outline-24.svg
Normal file
1
images/icons/v2/hq-outline-24.svg
Normal 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 |
1
images/icons/v2/hq-solid-24.svg
Normal file
1
images/icons/v2/hq-solid-24.svg
Normal 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 |
1
images/icons/v2/sq-24.svg
Normal file
1
images/icons/v2/sq-24.svg
Normal 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 |
|
@ -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
|
|
||||||
);
|
|
||||||
});
|
|
||||||
};
|
|
|
@ -3,16 +3,12 @@
|
||||||
|
|
||||||
const is = require('@sindresorhus/is');
|
const is = require('@sindresorhus/is');
|
||||||
|
|
||||||
const {
|
const { arrayBufferToBlob, blobToArrayBuffer } = require('blob-util');
|
||||||
arrayBufferToBlob,
|
|
||||||
blobToArrayBuffer,
|
|
||||||
dataURLToBlob,
|
|
||||||
} = require('blob-util');
|
|
||||||
const AttachmentTS = require('../../../ts/types/Attachment');
|
const AttachmentTS = require('../../../ts/types/Attachment');
|
||||||
const GoogleChrome = require('../../../ts/util/GoogleChrome');
|
const GoogleChrome = require('../../../ts/util/GoogleChrome');
|
||||||
const MIME = require('../../../ts/types/MIME');
|
const MIME = require('../../../ts/types/MIME');
|
||||||
const { toLogFormat } = require('./errors');
|
const { toLogFormat } = require('./errors');
|
||||||
const { autoOrientImage } = require('../auto_orient_image');
|
const { scaleImageToLevel } = require('../../../ts/util/scaleImageToLevel');
|
||||||
const {
|
const {
|
||||||
migrateDataToFileSystem,
|
migrateDataToFileSystem,
|
||||||
} = require('./attachment/migrate_data_to_file_system');
|
} = require('./attachment/migrate_data_to_file_system');
|
||||||
|
@ -54,7 +50,7 @@ exports.isValid = rawAttachment => {
|
||||||
// Upgrade steps
|
// Upgrade steps
|
||||||
// NOTE: This step strips all EXIF metadata from JPEG images as
|
// NOTE: This step strips all EXIF metadata from JPEG images as
|
||||||
// part of re-encoding the image:
|
// part of re-encoding the image:
|
||||||
exports.autoOrientJPEG = async attachment => {
|
exports.autoOrientJPEG = async (attachment, _, message) => {
|
||||||
if (!MIME.isJPEG(attachment.contentType)) {
|
if (!MIME.isJPEG(attachment.contentType)) {
|
||||||
return attachment;
|
return attachment;
|
||||||
}
|
}
|
||||||
|
@ -68,24 +64,27 @@ exports.autoOrientJPEG = async attachment => {
|
||||||
attachment.data,
|
attachment.data,
|
||||||
attachment.contentType
|
attachment.contentType
|
||||||
);
|
);
|
||||||
const newDataBlob = await dataURLToBlob(await autoOrientImage(dataBlob));
|
const xcodedDataBlob = await scaleImageToLevel(
|
||||||
const newDataArrayBuffer = await blobToArrayBuffer(newDataBlob);
|
dataBlob,
|
||||||
|
message.sendHQImages
|
||||||
|
);
|
||||||
|
const xcodedDataArrayBuffer = await blobToArrayBuffer(xcodedDataBlob);
|
||||||
|
|
||||||
// IMPORTANT: We overwrite the existing `data` `ArrayBuffer` losing the original
|
// 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
|
// 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
|
// retain it but due to reports of data loss, we don’t want to overburden IndexedDB
|
||||||
// by potentially doubling stored image data.
|
// by potentially doubling stored image data.
|
||||||
// See: https://github.com/signalapp/Signal-Desktop/issues/1589
|
// See: https://github.com/signalapp/Signal-Desktop/issues/1589
|
||||||
const newAttachment = {
|
const xcodedAttachment = {
|
||||||
...attachment,
|
...attachment,
|
||||||
data: newDataArrayBuffer,
|
data: xcodedDataArrayBuffer,
|
||||||
size: newDataArrayBuffer.byteLength,
|
size: xcodedDataArrayBuffer.byteLength,
|
||||||
};
|
};
|
||||||
|
|
||||||
// `digest` is no longer valid for auto-oriented image data, so we discard it:
|
// `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';
|
const UNICODE_LEFT_TO_RIGHT_OVERRIDE = '\u202D';
|
||||||
|
|
|
@ -170,7 +170,7 @@ exports._withSchemaVersion = ({ schemaVersion, upgrade }) => {
|
||||||
// Promise Message
|
// Promise Message
|
||||||
exports._mapAttachments = upgradeAttachment => async (message, context) => {
|
exports._mapAttachments = upgradeAttachment => async (message, context) => {
|
||||||
const upgradeWithContext = attachment =>
|
const upgradeWithContext = attachment =>
|
||||||
upgradeAttachment(attachment, context);
|
upgradeAttachment(attachment, context, message);
|
||||||
const attachments = await Promise.all(
|
const attachments = await Promise.all(
|
||||||
(message.attachments || []).map(upgradeWithContext)
|
(message.attachments || []).map(upgradeWithContext)
|
||||||
);
|
);
|
||||||
|
|
|
@ -1,15 +1,15 @@
|
||||||
// Copyright 2018-2020 Signal Messenger, LLC
|
// Copyright 2018-2021 Signal Messenger, LLC
|
||||||
// SPDX-License-Identifier: AGPL-3.0-only
|
// SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
|
||||||
/* global document, URL, Blob */
|
/* global document, URL, Blob */
|
||||||
|
|
||||||
const loadImage = require('blueimp-load-image');
|
const loadImage = require('blueimp-load-image');
|
||||||
const dataURLToBlobSync = require('blueimp-canvas-to-blob');
|
|
||||||
const { blobToArrayBuffer } = require('blob-util');
|
const { blobToArrayBuffer } = require('blob-util');
|
||||||
const { toLogFormat } = require('./errors');
|
const { toLogFormat } = require('./errors');
|
||||||
const {
|
const {
|
||||||
arrayBufferToObjectURL,
|
arrayBufferToObjectURL,
|
||||||
} = require('../../../ts/util/arrayBufferToObjectURL');
|
} = require('../../../ts/util/arrayBufferToObjectURL');
|
||||||
|
const { canvasToBlob } = require('../../../ts/util/canvasToBlob');
|
||||||
|
|
||||||
exports.blobToArrayBuffer = blobToArrayBuffer;
|
exports.blobToArrayBuffer = blobToArrayBuffer;
|
||||||
|
|
||||||
|
@ -40,7 +40,7 @@ exports.makeImageThumbnail = ({
|
||||||
new Promise((resolve, reject) => {
|
new Promise((resolve, reject) => {
|
||||||
const image = document.createElement('img');
|
const image = document.createElement('img');
|
||||||
|
|
||||||
image.addEventListener('load', () => {
|
image.addEventListener('load', async () => {
|
||||||
// using components/blueimp-load-image
|
// using components/blueimp-load-image
|
||||||
|
|
||||||
// first, make the correct size
|
// first, make the correct size
|
||||||
|
@ -63,9 +63,12 @@ exports.makeImageThumbnail = ({
|
||||||
minHeight: size,
|
minHeight: size,
|
||||||
});
|
});
|
||||||
|
|
||||||
const blob = dataURLToBlobSync(canvas.toDataURL(contentType));
|
try {
|
||||||
|
const blob = await canvasToBlob(canvas, contentType);
|
||||||
resolve(blob);
|
resolve(blob);
|
||||||
|
} catch (err) {
|
||||||
|
reject(err);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
image.addEventListener('error', error => {
|
image.addEventListener('error', error => {
|
||||||
|
@ -88,7 +91,7 @@ exports.makeVideoScreenshot = ({
|
||||||
video.currentTime = 1.0;
|
video.currentTime = 1.0;
|
||||||
}
|
}
|
||||||
|
|
||||||
function capture() {
|
async function capture() {
|
||||||
const canvas = document.createElement('canvas');
|
const canvas = document.createElement('canvas');
|
||||||
canvas.width = video.videoWidth;
|
canvas.width = video.videoWidth;
|
||||||
canvas.height = video.videoHeight;
|
canvas.height = video.videoHeight;
|
||||||
|
@ -96,12 +99,15 @@ exports.makeVideoScreenshot = ({
|
||||||
.getContext('2d')
|
.getContext('2d')
|
||||||
.drawImage(video, 0, 0, canvas.width, canvas.height);
|
.drawImage(video, 0, 0, canvas.width, canvas.height);
|
||||||
|
|
||||||
const image = dataURLToBlobSync(canvas.toDataURL(contentType));
|
|
||||||
|
|
||||||
video.addEventListener('loadeddata', seek);
|
video.addEventListener('loadeddata', seek);
|
||||||
video.removeEventListener('seeked', capture);
|
video.removeEventListener('seeked', capture);
|
||||||
|
|
||||||
resolve(image);
|
try {
|
||||||
|
const image = canvasToBlob(canvas, contentType);
|
||||||
|
resolve(image);
|
||||||
|
} catch (err) {
|
||||||
|
reject(err);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
video.addEventListener('loadeddata', seek);
|
video.addEventListener('loadeddata', seek);
|
||||||
|
|
|
@ -78,7 +78,6 @@
|
||||||
"backbone": "1.4.0",
|
"backbone": "1.4.0",
|
||||||
"better-sqlite3": "https://github.com/signalapp/better-sqlite3#2fa02d2484e9f9a10df5ac7ea4617fb2dff30006",
|
"better-sqlite3": "https://github.com/signalapp/better-sqlite3#2fa02d2484e9f9a10df5ac7ea4617fb2dff30006",
|
||||||
"blob-util": "1.3.0",
|
"blob-util": "1.3.0",
|
||||||
"blueimp-canvas-to-blob": "3.14.0",
|
|
||||||
"blueimp-load-image": "5.14.0",
|
"blueimp-load-image": "5.14.0",
|
||||||
"blurhash": "1.1.3",
|
"blurhash": "1.1.3",
|
||||||
"classnames": "2.2.5",
|
"classnames": "2.2.5",
|
||||||
|
|
25
patches/@types+blueimp-load-image+5.14.1.patch
Normal file
25
patches/@types+blueimp-load-image+5.14.1.patch
Normal 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;
|
|
@ -484,14 +484,11 @@ try {
|
||||||
window.nodeSetImmediate(() => {});
|
window.nodeSetImmediate(() => {});
|
||||||
}, 1000);
|
}, 1000);
|
||||||
|
|
||||||
const { autoOrientImage } = require('./js/modules/auto_orient_image');
|
|
||||||
const { imageToBlurHash } = require('./ts/util/imageToBlurHash');
|
const { imageToBlurHash } = require('./ts/util/imageToBlurHash');
|
||||||
const { isGroupCallingEnabled } = require('./ts/util/isGroupCallingEnabled');
|
const { isGroupCallingEnabled } = require('./ts/util/isGroupCallingEnabled');
|
||||||
const { isValidGuid } = require('./ts/util/isValidGuid');
|
const { isValidGuid } = require('./ts/util/isValidGuid');
|
||||||
const { ActiveWindowService } = require('./ts/services/ActiveWindowService');
|
const { ActiveWindowService } = require('./ts/services/ActiveWindowService');
|
||||||
|
|
||||||
window.autoOrientImage = autoOrientImage;
|
|
||||||
window.dataURLToBlobSync = require('blueimp-canvas-to-blob');
|
|
||||||
window.imageToBlurHash = imageToBlurHash;
|
window.imageToBlurHash = imageToBlurHash;
|
||||||
window.emojiData = require('emoji-datasource');
|
window.emojiData = require('emoji-datasource');
|
||||||
window.libphonenumber = require('google-libphonenumber').PhoneNumberUtil.getInstance();
|
window.libphonenumber = require('google-libphonenumber').PhoneNumberUtil.getInstance();
|
||||||
|
|
|
@ -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--active.react-contextmenu-item--checked:before,
|
||||||
.react-contextmenu-item.react-contextmenu-item--selected.react-contextmenu-item--checked:before {
|
.react-contextmenu-item.react-contextmenu-item--selected.react-contextmenu-item--checked:before {
|
||||||
color: $color-black;
|
color: $color-black;
|
||||||
|
|
116
stylesheets/components/MediaQualitySelector.scss
Normal file
116
stylesheets/components/MediaQualitySelector.scss
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -49,6 +49,7 @@
|
||||||
@import './components/GroupDescription.scss';
|
@import './components/GroupDescription.scss';
|
||||||
@import './components/GroupDialog.scss';
|
@import './components/GroupDialog.scss';
|
||||||
@import './components/GroupInput.scss';
|
@import './components/GroupInput.scss';
|
||||||
|
@import './components/MediaQualitySelector.scss';
|
||||||
@import './components/MessageAudio.scss';
|
@import './components/MessageAudio.scss';
|
||||||
@import './components/Modal.scss';
|
@import './components/Modal.scss';
|
||||||
@import './components/SafetyNumberChangeDialog.scss';
|
@import './components/SafetyNumberChangeDialog.scss';
|
||||||
|
|
|
@ -10,6 +10,7 @@ export type ConfigKeyType =
|
||||||
| 'desktop.groupCalling'
|
| 'desktop.groupCalling'
|
||||||
| 'desktop.gv2'
|
| 'desktop.gv2'
|
||||||
| 'desktop.mandatoryProfileSharing'
|
| 'desktop.mandatoryProfileSharing'
|
||||||
|
| 'desktop.mediaQuality.levels'
|
||||||
| 'desktop.messageRequests'
|
| 'desktop.messageRequests'
|
||||||
| 'desktop.retryReceiptLifespan'
|
| 'desktop.retryReceiptLifespan'
|
||||||
| 'desktop.retryRespondMaxAge'
|
| 'desktop.retryRespondMaxAge'
|
||||||
|
|
|
@ -984,6 +984,7 @@ export async function startApp(): Promise<void> {
|
||||||
store.dispatch
|
store.dispatch
|
||||||
),
|
),
|
||||||
calling: bindActionCreators(actionCreators.calling, store.dispatch),
|
calling: bindActionCreators(actionCreators.calling, store.dispatch),
|
||||||
|
composer: bindActionCreators(actionCreators.composer, store.dispatch),
|
||||||
conversations: bindActionCreators(
|
conversations: bindActionCreators(
|
||||||
actionCreators.conversations,
|
actionCreators.conversations,
|
||||||
store.dispatch
|
store.dispatch
|
||||||
|
|
|
@ -32,6 +32,25 @@ const createProps = (overrideProps: Partial<Props> = {}): Props => ({
|
||||||
i18n,
|
i18n,
|
||||||
micCellEl,
|
micCellEl,
|
||||||
onChooseAttachment: action('onChooseAttachment'),
|
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
|
// CompositionInput
|
||||||
onSubmit: action('onSubmit'),
|
onSubmit: action('onSubmit'),
|
||||||
onEditorStateChange: action('onEditorStateChange'),
|
onEditorStateChange: action('onEditorStateChange'),
|
||||||
|
|
|
@ -31,6 +31,12 @@ import { MandatoryProfileSharingActions } from './conversation/MandatoryProfileS
|
||||||
import { countStickers } from './stickers/lib';
|
import { countStickers } from './stickers/lib';
|
||||||
import { LocalizerType } from '../types/Util';
|
import { LocalizerType } from '../types/Util';
|
||||||
import { EmojiPickDataType } from './emoji/EmojiPicker';
|
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 = {
|
export type OwnProps = {
|
||||||
readonly i18n: LocalizerType;
|
readonly i18n: LocalizerType;
|
||||||
|
@ -50,14 +56,24 @@ export type OwnProps = {
|
||||||
setDisabled: (disabled: boolean) => void;
|
setDisabled: (disabled: boolean) => void;
|
||||||
setShowMic: (showMic: boolean) => void;
|
setShowMic: (showMic: boolean) => void;
|
||||||
setMicActive: (micActive: boolean) => void;
|
setMicActive: (micActive: boolean) => void;
|
||||||
attSlotRef: React.RefObject<HTMLDivElement>;
|
|
||||||
reset: InputApi['reset'];
|
reset: InputApi['reset'];
|
||||||
resetEmojiResults: InputApi['resetEmojiResults'];
|
resetEmojiResults: InputApi['resetEmojiResults'];
|
||||||
}>;
|
}>;
|
||||||
readonly micCellEl?: HTMLElement;
|
readonly micCellEl?: HTMLElement;
|
||||||
readonly attCellEl?: HTMLElement;
|
readonly draftAttachments: Array<AttachmentType>;
|
||||||
readonly attachmentListEl?: HTMLElement;
|
readonly shouldSendHighQualityAttachments: boolean;
|
||||||
onChooseAttachment(): unknown;
|
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<
|
export type Props = Pick<
|
||||||
|
@ -103,9 +119,25 @@ const emptyElement = (el: HTMLElement) => {
|
||||||
|
|
||||||
export const CompositionArea = ({
|
export const CompositionArea = ({
|
||||||
i18n,
|
i18n,
|
||||||
attachmentListEl,
|
|
||||||
micCellEl,
|
micCellEl,
|
||||||
onChooseAttachment,
|
onChooseAttachment,
|
||||||
|
// AttachmentList
|
||||||
|
draftAttachments,
|
||||||
|
onAddAttachment,
|
||||||
|
onClearAttachments,
|
||||||
|
onClickAttachment,
|
||||||
|
onCloseAttachment,
|
||||||
|
// StagedLinkPreview
|
||||||
|
linkPreviewLoading,
|
||||||
|
linkPreviewResult,
|
||||||
|
onCloseLinkPreview,
|
||||||
|
// Quote
|
||||||
|
quotedMessageProps,
|
||||||
|
onClickQuotedMessage,
|
||||||
|
setQuotedMessage,
|
||||||
|
// MediaQualitySelector
|
||||||
|
onSelectMediaQuality,
|
||||||
|
shouldSendHighQualityAttachments,
|
||||||
// CompositionInput
|
// CompositionInput
|
||||||
onSubmit,
|
onSubmit,
|
||||||
compositionApi,
|
compositionApi,
|
||||||
|
@ -198,9 +230,6 @@ export const CompositionArea = ({
|
||||||
receivedPacks,
|
receivedPacks,
|
||||||
}) > 0;
|
}) > 0;
|
||||||
|
|
||||||
// A ref to grab a slot where backbone can insert link previews and attachments
|
|
||||||
const attSlotRef = React.useRef<HTMLDivElement>(null);
|
|
||||||
|
|
||||||
if (compositionApi) {
|
if (compositionApi) {
|
||||||
// Using a React.MutableRefObject, so we need to reassign this prop.
|
// Using a React.MutableRefObject, so we need to reassign this prop.
|
||||||
// eslint-disable-next-line no-param-reassign
|
// eslint-disable-next-line no-param-reassign
|
||||||
|
@ -210,7 +239,6 @@ export const CompositionArea = ({
|
||||||
setDisabled,
|
setDisabled,
|
||||||
setShowMic,
|
setShowMic,
|
||||||
setMicActive,
|
setMicActive,
|
||||||
attSlotRef,
|
|
||||||
reset: () => {
|
reset: () => {
|
||||||
if (inputApiRef.current) {
|
if (inputApiRef.current) {
|
||||||
inputApiRef.current.reset();
|
inputApiRef.current.reset();
|
||||||
|
@ -251,27 +279,31 @@ export const CompositionArea = ({
|
||||||
return noop;
|
return noop;
|
||||||
}, [micCellRef, micCellEl, large, dirty, showMic]);
|
}, [micCellRef, micCellEl, large, dirty, showMic]);
|
||||||
|
|
||||||
React.useLayoutEffect(() => {
|
const showMediaQualitySelector = draftAttachments.some(isImageAttachment);
|
||||||
const { current: attSlot } = attSlotRef;
|
|
||||||
if (attSlot && attachmentListEl) {
|
|
||||||
attSlot.appendChild(attachmentListEl);
|
|
||||||
}
|
|
||||||
|
|
||||||
return noop;
|
const leftHandSideButtonsFragment = (
|
||||||
}, [attSlotRef, attachmentListEl]);
|
<>
|
||||||
|
<div className="module-composition-area__button-cell">
|
||||||
const emojiButtonFragment = (
|
<EmojiButton
|
||||||
<div className="module-composition-area__button-cell">
|
i18n={i18n}
|
||||||
<EmojiButton
|
doSend={handleForceSend}
|
||||||
i18n={i18n}
|
onPickEmoji={insertEmoji}
|
||||||
doSend={handleForceSend}
|
onClose={focusInput}
|
||||||
onPickEmoji={insertEmoji}
|
recentEmojis={recentEmojis}
|
||||||
onClose={focusInput}
|
skinTone={skinTone}
|
||||||
recentEmojis={recentEmojis}
|
onSetSkinTone={onSetSkinTone}
|
||||||
skinTone={skinTone}
|
/>
|
||||||
onSetSkinTone={onSetSkinTone}
|
</div>
|
||||||
/>
|
{showMediaQualitySelector ? (
|
||||||
</div>
|
<div className="module-composition-area__button-cell">
|
||||||
|
<MediaQualitySelector
|
||||||
|
i18n={i18n}
|
||||||
|
isHighQuality={shouldSendHighQualityAttachments}
|
||||||
|
onSelectQuality={onSelectMediaQuality}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
|
|
||||||
const micButtonFragment = showMic ? (
|
const micButtonFragment = showMic ? (
|
||||||
|
@ -480,15 +512,52 @@ export const CompositionArea = ({
|
||||||
'module-composition-area__row',
|
'module-composition-area__row',
|
||||||
'module-composition-area__row--column'
|
'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
|
<div
|
||||||
className={classNames(
|
className={classNames(
|
||||||
'module-composition-area__row',
|
'module-composition-area__row',
|
||||||
large ? 'module-composition-area__row--padded' : null
|
large ? 'module-composition-area__row--padded' : null
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{!large ? emojiButtonFragment : null}
|
{!large ? leftHandSideButtonsFragment : null}
|
||||||
<div className="module-composition-area__input">
|
<div className="module-composition-area__input">
|
||||||
<CompositionInput
|
<CompositionInput
|
||||||
i18n={i18n}
|
i18n={i18n}
|
||||||
|
@ -523,7 +592,7 @@ export const CompositionArea = ({
|
||||||
'module-composition-area__row--control-row'
|
'module-composition-area__row--control-row'
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{emojiButtonFragment}
|
{leftHandSideButtonsFragment}
|
||||||
{stickerButtonFragment}
|
{stickerButtonFragment}
|
||||||
{attButton}
|
{attButton}
|
||||||
{!dirty ? micButtonFragment : null}
|
{!dirty ? micButtonFragment : null}
|
||||||
|
|
|
@ -269,7 +269,6 @@ export const ForwardMessageModal: FunctionComponent<PropsType> = ({
|
||||||
domain={linkPreview.url}
|
domain={linkPreview.url}
|
||||||
i18n={i18n}
|
i18n={i18n}
|
||||||
image={linkPreview.image}
|
image={linkPreview.image}
|
||||||
isLoaded
|
|
||||||
onClose={() => removeLinkPreview()}
|
onClose={() => removeLinkPreview()}
|
||||||
title={linkPreview.title}
|
title={linkPreview.title}
|
||||||
/>
|
/>
|
||||||
|
|
34
ts/components/MediaQualitySelector.stories.tsx
Normal file
34
ts/components/MediaQualitySelector.stories.tsx
Normal 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,
|
||||||
|
})}
|
||||||
|
/>
|
||||||
|
));
|
152
ts/components/MediaQualitySelector.tsx
Normal file
152
ts/components/MediaQualitySelector.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
};
|
|
@ -1,4 +1,4 @@
|
||||||
// Copyright 2020 Signal Messenger, LLC
|
// Copyright 2020-2021 Signal Messenger, LLC
|
||||||
// SPDX-License-Identifier: AGPL-3.0-only
|
// SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
|
||||||
import * as React from 'react';
|
import * as React from 'react';
|
||||||
|
@ -6,7 +6,7 @@ import moment, { Moment } from 'moment';
|
||||||
import { isLinkPreviewDateValid } from '../../linkPreviews/isLinkPreviewDateValid';
|
import { isLinkPreviewDateValid } from '../../linkPreviews/isLinkPreviewDateValid';
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
date: null | number;
|
date?: null | number;
|
||||||
className?: string;
|
className?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
@ -1,9 +1,9 @@
|
||||||
// Copyright 2020 Signal Messenger, LLC
|
// Copyright 2020-2021 Signal Messenger, LLC
|
||||||
// SPDX-License-Identifier: AGPL-3.0-only
|
// SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
|
||||||
import * as React from 'react';
|
import * as React from 'react';
|
||||||
import { storiesOf } from '@storybook/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 { action } from '@storybook/addon-actions';
|
||||||
|
|
||||||
import { AttachmentType } from '../../types/Attachment';
|
import { AttachmentType } from '../../types/Attachment';
|
||||||
|
@ -36,7 +36,6 @@ const createAttachment = (
|
||||||
});
|
});
|
||||||
|
|
||||||
const createProps = (overrideProps: Partial<Props> = {}): Props => ({
|
const createProps = (overrideProps: Partial<Props> = {}): Props => ({
|
||||||
isLoaded: boolean('isLoaded', overrideProps.isLoaded !== false),
|
|
||||||
title: text(
|
title: text(
|
||||||
'title',
|
'title',
|
||||||
typeof overrideProps.title === 'string'
|
typeof overrideProps.title === 'string'
|
||||||
|
@ -57,9 +56,7 @@ const createProps = (overrideProps: Partial<Props> = {}): Props => ({
|
||||||
});
|
});
|
||||||
|
|
||||||
story.add('Loading', () => {
|
story.add('Loading', () => {
|
||||||
const props = createProps({
|
const props = createProps({ domain: '' });
|
||||||
isLoaded: false,
|
|
||||||
});
|
|
||||||
|
|
||||||
return <StagedLinkPreview {...props} />;
|
return <StagedLinkPreview {...props} />;
|
||||||
});
|
});
|
||||||
|
|
|
@ -1,4 +1,4 @@
|
||||||
// Copyright 2019-2020 Signal Messenger, LLC
|
// Copyright 2019-2021 Signal Messenger, LLC
|
||||||
// SPDX-License-Identifier: AGPL-3.0-only
|
// SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
|
@ -11,11 +11,10 @@ import { AttachmentType, isImageAttachment } from '../../types/Attachment';
|
||||||
import { LocalizerType } from '../../types/Util';
|
import { LocalizerType } from '../../types/Util';
|
||||||
|
|
||||||
export type Props = {
|
export type Props = {
|
||||||
isLoaded: boolean;
|
title?: string;
|
||||||
title: string;
|
description?: null | string;
|
||||||
description: null | string;
|
date?: null | number;
|
||||||
date: null | number;
|
domain?: string;
|
||||||
domain: string;
|
|
||||||
image?: AttachmentType;
|
image?: AttachmentType;
|
||||||
|
|
||||||
i18n: LocalizerType;
|
i18n: LocalizerType;
|
||||||
|
@ -23,7 +22,6 @@ export type Props = {
|
||||||
};
|
};
|
||||||
|
|
||||||
export const StagedLinkPreview: React.FC<Props> = ({
|
export const StagedLinkPreview: React.FC<Props> = ({
|
||||||
isLoaded,
|
|
||||||
onClose,
|
onClose,
|
||||||
i18n,
|
i18n,
|
||||||
title,
|
title,
|
||||||
|
@ -33,6 +31,7 @@ export const StagedLinkPreview: React.FC<Props> = ({
|
||||||
domain,
|
domain,
|
||||||
}: Props) => {
|
}: Props) => {
|
||||||
const isImage = isImageAttachment(image);
|
const isImage = isImageAttachment(image);
|
||||||
|
const isLoaded = Boolean(domain);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
|
@ -46,7 +45,7 @@ export const StagedLinkPreview: React.FC<Props> = ({
|
||||||
{i18n('loadingPreview')}
|
{i18n('loadingPreview')}
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
{isLoaded && image && isImage ? (
|
{isLoaded && image && isImage && domain ? (
|
||||||
<div className="module-staged-link-preview__icon-container">
|
<div className="module-staged-link-preview__icon-container">
|
||||||
<Image
|
<Image
|
||||||
alt={i18n('stagedPreviewThumbnail', [domain])}
|
alt={i18n('stagedPreviewThumbnail', [domain])}
|
||||||
|
|
7
ts/model-types.d.ts
vendored
7
ts/model-types.d.ts
vendored
|
@ -56,12 +56,13 @@ export type QuotedMessageType = {
|
||||||
// `author` is an old attribute that holds the author's E164. We shouldn't use it for
|
// `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.
|
// new messages, but old messages might have this attribute.
|
||||||
author?: string;
|
author?: string;
|
||||||
authorUuid: string;
|
authorUuid?: string;
|
||||||
bodyRanges: BodyRangesType;
|
bodyRanges?: BodyRangesType;
|
||||||
id: string;
|
id: string;
|
||||||
referencedMessageNotFound: boolean;
|
referencedMessageNotFound: boolean;
|
||||||
isViewOnce: boolean;
|
isViewOnce: boolean;
|
||||||
text: string;
|
text?: string;
|
||||||
|
messageId: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type RetryOptions = Readonly<{
|
export type RetryOptions = Readonly<{
|
||||||
|
|
|
@ -6,12 +6,13 @@
|
||||||
import { ProfileKeyCredentialRequestContext } from 'zkgroup';
|
import { ProfileKeyCredentialRequestContext } from 'zkgroup';
|
||||||
import { compact, sample } from 'lodash';
|
import { compact, sample } from 'lodash';
|
||||||
import {
|
import {
|
||||||
MessageModelCollectionType,
|
|
||||||
WhatIsThis,
|
|
||||||
MessageAttributesType,
|
|
||||||
ReactionModelType,
|
|
||||||
ConversationAttributesType,
|
ConversationAttributesType,
|
||||||
|
MessageAttributesType,
|
||||||
|
MessageModelCollectionType,
|
||||||
|
QuotedMessageType,
|
||||||
|
ReactionModelType,
|
||||||
VerificationOptions,
|
VerificationOptions,
|
||||||
|
WhatIsThis,
|
||||||
} from '../model-types.d';
|
} from '../model-types.d';
|
||||||
import { CallMode, CallHistoryDetailsType } from '../types/Calling';
|
import { CallMode, CallHistoryDetailsType } from '../types/Calling';
|
||||||
import { CallbackResultType, GroupV2InfoType } from '../textsecure/SendMessage';
|
import { CallbackResultType, GroupV2InfoType } from '../textsecure/SendMessage';
|
||||||
|
@ -40,7 +41,6 @@ import {
|
||||||
verifyAccessKey,
|
verifyAccessKey,
|
||||||
} from '../Crypto';
|
} from '../Crypto';
|
||||||
import * as Bytes from '../Bytes';
|
import * as Bytes from '../Bytes';
|
||||||
import { DataMessageClass } from '../textsecure.d';
|
|
||||||
import { BodyRangesType } from '../types/Util';
|
import { BodyRangesType } from '../types/Util';
|
||||||
import { getTextWithMentions } from '../util';
|
import { getTextWithMentions } from '../util';
|
||||||
import { migrateColor } from '../util/migrateColor';
|
import { migrateColor } from '../util/migrateColor';
|
||||||
|
@ -3083,7 +3083,7 @@ export class ConversationModel extends window.Backbone
|
||||||
|
|
||||||
async makeQuote(
|
async makeQuote(
|
||||||
quotedMessage: typeof window.Whisper.MessageType
|
quotedMessage: typeof window.Whisper.MessageType
|
||||||
): Promise<DataMessageClass.Quote> {
|
): Promise<QuotedMessageType> {
|
||||||
const { getName } = Contact;
|
const { getName } = Contact;
|
||||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||||
const contact = quotedMessage.getContact()!;
|
const contact = quotedMessage.getContact()!;
|
||||||
|
@ -3100,13 +3100,15 @@ export class ConversationModel extends window.Backbone
|
||||||
|
|
||||||
return {
|
return {
|
||||||
authorUuid: contact.get('uuid'),
|
authorUuid: contact.get('uuid'),
|
||||||
bodyRanges: quotedMessage.get('bodyRanges'),
|
|
||||||
id: quotedMessage.get('sent_at'),
|
|
||||||
text: body || embeddedContactName,
|
|
||||||
isViewOnce: isTapToView(quotedMessage.attributes),
|
|
||||||
attachments: isTapToView(quotedMessage.attributes)
|
attachments: isTapToView(quotedMessage.attributes)
|
||||||
? [{ contentType: 'image/jpeg', fileName: null }]
|
? [{ contentType: 'image/jpeg', fileName: null }]
|
||||||
: await this.getQuoteAttachment(attachments, preview, sticker),
|
: 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,
|
mentions?: BodyRangesType,
|
||||||
{
|
{
|
||||||
dontClearDraft,
|
dontClearDraft,
|
||||||
|
sendHQImages,
|
||||||
timestamp,
|
timestamp,
|
||||||
}: { dontClearDraft: boolean; timestamp?: number } = {
|
}: {
|
||||||
dontClearDraft: false,
|
dontClearDraft?: boolean;
|
||||||
}
|
sendHQImages?: boolean;
|
||||||
|
timestamp?: number;
|
||||||
|
} = {}
|
||||||
): void {
|
): void {
|
||||||
if (this.isGroupV1AndDisabled()) {
|
if (this.isGroupV1AndDisabled()) {
|
||||||
return;
|
return;
|
||||||
|
@ -3530,6 +3535,7 @@ export class ConversationModel extends window.Backbone
|
||||||
recipients,
|
recipients,
|
||||||
sticker,
|
sticker,
|
||||||
bodyRanges: mentions,
|
bodyRanges: mentions,
|
||||||
|
sendHQImages,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (isDirectConversation(this.attributes)) {
|
if (isDirectConversation(this.attributes)) {
|
||||||
|
|
|
@ -5,6 +5,7 @@ import { actions as accounts } from './ducks/accounts';
|
||||||
import { actions as app } from './ducks/app';
|
import { actions as app } from './ducks/app';
|
||||||
import { actions as audioPlayer } from './ducks/audioPlayer';
|
import { actions as audioPlayer } from './ducks/audioPlayer';
|
||||||
import { actions as calling } from './ducks/calling';
|
import { actions as calling } from './ducks/calling';
|
||||||
|
import { actions as composer } from './ducks/composer';
|
||||||
import { actions as conversations } from './ducks/conversations';
|
import { actions as conversations } from './ducks/conversations';
|
||||||
import { actions as emojis } from './ducks/emojis';
|
import { actions as emojis } from './ducks/emojis';
|
||||||
import { actions as expiration } from './ducks/expiration';
|
import { actions as expiration } from './ducks/expiration';
|
||||||
|
@ -24,6 +25,7 @@ export const actionCreators: ReduxActions = {
|
||||||
app,
|
app,
|
||||||
audioPlayer,
|
audioPlayer,
|
||||||
calling,
|
calling,
|
||||||
|
composer,
|
||||||
conversations,
|
conversations,
|
||||||
emojis,
|
emojis,
|
||||||
expiration,
|
expiration,
|
||||||
|
@ -43,6 +45,7 @@ export const mapDispatchToProps = {
|
||||||
...app,
|
...app,
|
||||||
...audioPlayer,
|
...audioPlayer,
|
||||||
...calling,
|
...calling,
|
||||||
|
...composer,
|
||||||
...conversations,
|
...conversations,
|
||||||
...emojis,
|
...emojis,
|
||||||
...expiration,
|
...expiration,
|
||||||
|
|
168
ts/state/ducks/composer.ts
Normal file
168
ts/state/ducks/composer.ts
Normal 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;
|
||||||
|
}
|
|
@ -7,6 +7,7 @@ import { reducer as accounts } from './ducks/accounts';
|
||||||
import { reducer as app } from './ducks/app';
|
import { reducer as app } from './ducks/app';
|
||||||
import { reducer as audioPlayer } from './ducks/audioPlayer';
|
import { reducer as audioPlayer } from './ducks/audioPlayer';
|
||||||
import { reducer as calling } from './ducks/calling';
|
import { reducer as calling } from './ducks/calling';
|
||||||
|
import { reducer as composer } from './ducks/composer';
|
||||||
import { reducer as conversations } from './ducks/conversations';
|
import { reducer as conversations } from './ducks/conversations';
|
||||||
import { reducer as emojis } from './ducks/emojis';
|
import { reducer as emojis } from './ducks/emojis';
|
||||||
import { reducer as expiration } from './ducks/expiration';
|
import { reducer as expiration } from './ducks/expiration';
|
||||||
|
@ -25,6 +26,7 @@ export const reducer = combineReducers({
|
||||||
app,
|
app,
|
||||||
audioPlayer,
|
audioPlayer,
|
||||||
calling,
|
calling,
|
||||||
|
composer,
|
||||||
conversations,
|
conversations,
|
||||||
emojis,
|
emojis,
|
||||||
expiration,
|
expiration,
|
||||||
|
|
|
@ -9,11 +9,12 @@ import { StateType } from '../reducer';
|
||||||
import { isConversationSMSOnly } from '../../util/isConversationSMSOnly';
|
import { isConversationSMSOnly } from '../../util/isConversationSMSOnly';
|
||||||
|
|
||||||
import { selectRecentEmojis } from '../selectors/emojis';
|
import { selectRecentEmojis } from '../selectors/emojis';
|
||||||
import { getIntl } from '../selectors/user';
|
import { getIntl, getUserConversationId } from '../selectors/user';
|
||||||
import {
|
import {
|
||||||
getConversationSelector,
|
getConversationSelector,
|
||||||
isMissingRequiredProfileSharing,
|
isMissingRequiredProfileSharing,
|
||||||
} from '../selectors/conversations';
|
} from '../selectors/conversations';
|
||||||
|
import { getPropsForQuote } from '../selectors/message';
|
||||||
import {
|
import {
|
||||||
getBlessedStickerPacks,
|
getBlessedStickerPacks,
|
||||||
getInstalledStickerPacks,
|
getInstalledStickerPacks,
|
||||||
|
@ -25,12 +26,14 @@ import {
|
||||||
|
|
||||||
type ExternalProps = {
|
type ExternalProps = {
|
||||||
id: string;
|
id: string;
|
||||||
|
onClickQuotedMessage: (id?: string) => unknown;
|
||||||
};
|
};
|
||||||
|
|
||||||
const mapStateToProps = (state: StateType, props: ExternalProps) => {
|
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) {
|
if (!conversation) {
|
||||||
throw new Error(`Conversation id ${id} not found!`);
|
throw new Error(`Conversation id ${id} not found!`);
|
||||||
}
|
}
|
||||||
|
@ -54,6 +57,14 @@ const mapStateToProps = (state: StateType, props: ExternalProps) => {
|
||||||
get(state.items, ['showStickerPickerHint'], false) &&
|
get(state.items, ['showStickerPickerHint'], false) &&
|
||||||
receivedPacks.length > 0;
|
receivedPacks.length > 0;
|
||||||
|
|
||||||
|
const {
|
||||||
|
attachments: draftAttachments,
|
||||||
|
linkPreviewLoading,
|
||||||
|
linkPreviewResult,
|
||||||
|
quotedMessage,
|
||||||
|
shouldSendHighQualityAttachments,
|
||||||
|
} = state.composer;
|
||||||
|
|
||||||
const recentEmojis = selectRecentEmojis(state);
|
const recentEmojis = selectRecentEmojis(state);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
@ -61,6 +72,23 @@ const mapStateToProps = (state: StateType, props: ExternalProps) => {
|
||||||
i18n: getIntl(state),
|
i18n: getIntl(state),
|
||||||
draftText,
|
draftText,
|
||||||
draftBodyRanges,
|
draftBodyRanges,
|
||||||
|
// AttachmentsList
|
||||||
|
draftAttachments,
|
||||||
|
// MediaQualitySelector
|
||||||
|
shouldSendHighQualityAttachments,
|
||||||
|
// StagedLinkPreview
|
||||||
|
linkPreviewLoading,
|
||||||
|
linkPreviewResult,
|
||||||
|
// Quote
|
||||||
|
quotedMessageProps: quotedMessage
|
||||||
|
? getPropsForQuote(
|
||||||
|
quotedMessage,
|
||||||
|
conversationSelector,
|
||||||
|
getUserConversationId(state)
|
||||||
|
)
|
||||||
|
: undefined,
|
||||||
|
onClickQuotedMessage: () =>
|
||||||
|
onClickQuotedMessage(quotedMessage?.quote?.messageId),
|
||||||
// Emojis
|
// Emojis
|
||||||
recentEmojis,
|
recentEmojis,
|
||||||
skinTone: get(state, ['items', 'skinTone'], 0),
|
skinTone: get(state, ['items', 'skinTone'], 0),
|
||||||
|
|
|
@ -5,6 +5,7 @@ import { actions as accounts } from './ducks/accounts';
|
||||||
import { actions as app } from './ducks/app';
|
import { actions as app } from './ducks/app';
|
||||||
import { actions as audioPlayer } from './ducks/audioPlayer';
|
import { actions as audioPlayer } from './ducks/audioPlayer';
|
||||||
import { actions as calling } from './ducks/calling';
|
import { actions as calling } from './ducks/calling';
|
||||||
|
import { actions as composer } from './ducks/composer';
|
||||||
import { actions as conversations } from './ducks/conversations';
|
import { actions as conversations } from './ducks/conversations';
|
||||||
import { actions as emojis } from './ducks/emojis';
|
import { actions as emojis } from './ducks/emojis';
|
||||||
import { actions as expiration } from './ducks/expiration';
|
import { actions as expiration } from './ducks/expiration';
|
||||||
|
@ -23,6 +24,7 @@ export type ReduxActions = {
|
||||||
app: typeof app;
|
app: typeof app;
|
||||||
audioPlayer: typeof audioPlayer;
|
audioPlayer: typeof audioPlayer;
|
||||||
calling: typeof calling;
|
calling: typeof calling;
|
||||||
|
composer: typeof composer;
|
||||||
conversations: typeof conversations;
|
conversations: typeof conversations;
|
||||||
emojis: typeof emojis;
|
emojis: typeof emojis;
|
||||||
expiration: typeof expiration;
|
expiration: typeof expiration;
|
||||||
|
|
120
ts/test-both/state/ducks/composer_test.ts
Normal file
120
ts/test-both/state/ducks/composer_test.ts
Normal 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');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
27
ts/test-electron/util/canvasToBlob_test.ts
Normal file
27
ts/test-electron/util/canvasToBlob_test.ts
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
|
@ -43,6 +43,7 @@ export type AttachmentType = {
|
||||||
contentType: MIME.MIMEType;
|
contentType: MIME.MIMEType;
|
||||||
path: string;
|
path: string;
|
||||||
};
|
};
|
||||||
|
screenshotPath?: string;
|
||||||
flags?: number;
|
flags?: number;
|
||||||
thumbnail?: ThumbnailType;
|
thumbnail?: ThumbnailType;
|
||||||
isCorrupted?: boolean;
|
isCorrupted?: boolean;
|
||||||
|
@ -52,6 +53,29 @@ export type AttachmentType = {
|
||||||
cdnKey?: string;
|
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 = {
|
export type ThumbnailType = {
|
||||||
height: number;
|
height: number;
|
||||||
width: number;
|
width: number;
|
||||||
|
|
20
ts/types/LinkPreview.ts
Normal file
20
ts/types/LinkPreview.ts
Normal 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;
|
31
ts/util/autoOrientImage.ts
Normal file
31
ts/util/autoOrientImage.ts
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
|
@ -1,17 +1,11 @@
|
||||||
// Copyright 2021 Signal Messenger, LLC
|
// Copyright 2021 Signal Messenger, LLC
|
||||||
// SPDX-License-Identifier: AGPL-3.0-only
|
// SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
|
||||||
|
import { canvasToBlob } from './canvasToBlob';
|
||||||
|
|
||||||
export async function canvasToArrayBuffer(
|
export async function canvasToArrayBuffer(
|
||||||
canvas: HTMLCanvasElement
|
canvas: HTMLCanvasElement
|
||||||
): Promise<ArrayBuffer> {
|
): Promise<ArrayBuffer> {
|
||||||
const blob: Blob = await new Promise<Blob>((resolve, reject) => {
|
const blob = await canvasToBlob(canvas);
|
||||||
canvas.toBlob(result => {
|
|
||||||
if (result) {
|
|
||||||
resolve(result);
|
|
||||||
} else {
|
|
||||||
reject(new Error("Couldn't convert the canvas to a Blob"));
|
|
||||||
}
|
|
||||||
}, 'image/webp');
|
|
||||||
});
|
|
||||||
return blob.arrayBuffer();
|
return blob.arrayBuffer();
|
||||||
}
|
}
|
||||||
|
|
29
ts/util/canvasToBlob.ts
Normal file
29
ts/util/canvasToBlob.ts
Normal 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
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
|
@ -1913,19 +1913,6 @@
|
||||||
"reasonCategory": "falseMatch",
|
"reasonCategory": "falseMatch",
|
||||||
"updated": "2018-09-19T18:13:29.628Z"
|
"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(",
|
"rule": "jQuery-wrap(",
|
||||||
"path": "node_modules/boom/lib/index.js",
|
"path": "node_modules/boom/lib/index.js",
|
||||||
|
@ -13436,14 +13423,6 @@
|
||||||
"updated": "2020-10-26T19:12:24.410Z",
|
"updated": "2020-10-26T19:12:24.410Z",
|
||||||
"reasonDetail": "Doesn't refer to a DOM element."
|
"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",
|
"rule": "React-useRef",
|
||||||
"path": "ts/components/CompositionArea.js",
|
"path": "ts/components/CompositionArea.js",
|
||||||
|
|
145
ts/util/scaleImageToLevel.ts
Normal file
145
ts/util/scaleImageToLevel.ts
Normal 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);
|
||||||
|
}
|
|
@ -3,7 +3,13 @@
|
||||||
|
|
||||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
/* 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 { ConversationModel } from '../models/conversations';
|
||||||
import {
|
import {
|
||||||
GroupV2PendingMemberType,
|
GroupV2PendingMemberType,
|
||||||
|
@ -28,30 +34,19 @@ import * as Bytes from '../Bytes';
|
||||||
import {
|
import {
|
||||||
canReply,
|
canReply,
|
||||||
getAttachmentsForMessage,
|
getAttachmentsForMessage,
|
||||||
getPropsForQuote,
|
|
||||||
isOutgoing,
|
isOutgoing,
|
||||||
isTapToView,
|
isTapToView,
|
||||||
} from '../state/selectors/message';
|
} from '../state/selectors/message';
|
||||||
import { getMessagesByConversation } from '../state/selectors/conversations';
|
import { getMessagesByConversation } from '../state/selectors/conversations';
|
||||||
import { ConversationDetailsMembershipList } from '../components/conversation/conversation-details/ConversationDetailsMembershipList';
|
import { ConversationDetailsMembershipList } from '../components/conversation/conversation-details/ConversationDetailsMembershipList';
|
||||||
import { showSafetyNumberChangeDialog } from '../shims/showSafetyNumberChangeDialog';
|
import { showSafetyNumberChangeDialog } from '../shims/showSafetyNumberChangeDialog';
|
||||||
|
import { autoOrientImage } from '../util/autoOrientImage';
|
||||||
type GetLinkPreviewImageResult = {
|
import { canvasToBlob } from '../util/canvasToBlob';
|
||||||
data: ArrayBuffer;
|
import {
|
||||||
size: number;
|
LinkPreviewImage,
|
||||||
contentType: string;
|
LinkPreviewResult,
|
||||||
width?: number;
|
LinkPreviewWithDomain,
|
||||||
height?: number;
|
} from '../types/LinkPreview';
|
||||||
blurHash: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
type GetLinkPreviewResult = {
|
|
||||||
title: string;
|
|
||||||
url: string;
|
|
||||||
image?: GetLinkPreviewImageResult;
|
|
||||||
description: string | null;
|
|
||||||
date: number | null;
|
|
||||||
};
|
|
||||||
|
|
||||||
type AttachmentOptions = {
|
type AttachmentOptions = {
|
||||||
messageId: string;
|
messageId: string;
|
||||||
|
@ -421,21 +416,12 @@ Whisper.ConversationView = Whisper.View.extend({
|
||||||
this.loadingScreen.render();
|
this.loadingScreen.render();
|
||||||
this.loadingScreen.$el.prependTo(this.$('.discussion-container'));
|
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.setupHeader();
|
||||||
this.setupTimeline();
|
this.setupTimeline();
|
||||||
this.setupCompositionArea({ attachmentListEl: attachmentListEl[0] });
|
this.setupCompositionArea();
|
||||||
|
|
||||||
this.linkPreviewAbortController = null;
|
this.linkPreviewAbortController = null;
|
||||||
|
this.updateAttachmentsView();
|
||||||
},
|
},
|
||||||
|
|
||||||
events: {
|
events: {
|
||||||
|
@ -615,7 +601,9 @@ Whisper.ConversationView = Whisper.View.extend({
|
||||||
window.reduxActions.conversations.setSelectedConversationHeaderTitle();
|
window.reduxActions.conversations.setSelectedConversationHeaderTitle();
|
||||||
},
|
},
|
||||||
|
|
||||||
setupCompositionArea({ attachmentListEl }: any) {
|
setupCompositionArea() {
|
||||||
|
window.reduxActions.composer.resetComposer();
|
||||||
|
|
||||||
const { model }: { model: ConversationModel } = this;
|
const { model }: { model: ConversationModel } = this;
|
||||||
|
|
||||||
const compositionApi = { current: null };
|
const compositionApi = { current: null };
|
||||||
|
@ -650,7 +638,6 @@ Whisper.ConversationView = Whisper.View.extend({
|
||||||
getQuotedMessage: () => model.get('quotedMessageId'),
|
getQuotedMessage: () => model.get('quotedMessageId'),
|
||||||
clearQuotedMessage: () => this.setQuoteMessage(null),
|
clearQuotedMessage: () => this.setQuoteMessage(null),
|
||||||
micCellEl,
|
micCellEl,
|
||||||
attachmentListEl,
|
|
||||||
onAccept: () => {
|
onAccept: () => {
|
||||||
this.syncMessageRequestResponse(
|
this.syncMessageRequestResponse(
|
||||||
'onAccept',
|
'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({
|
this.compositionAreaView = new Whisper.ReactWrapperView({
|
||||||
|
@ -1444,9 +1446,6 @@ Whisper.ConversationView = Whisper.View.extend({
|
||||||
this.timelineView.remove();
|
this.timelineView.remove();
|
||||||
this.compositionAreaView.remove();
|
this.compositionAreaView.remove();
|
||||||
|
|
||||||
if (this.attachmentListView) {
|
|
||||||
this.attachmentListView.remove();
|
|
||||||
}
|
|
||||||
if (this.captionEditorView) {
|
if (this.captionEditorView) {
|
||||||
this.captionEditorView.remove();
|
this.captionEditorView.remove();
|
||||||
}
|
}
|
||||||
|
@ -1468,9 +1467,6 @@ Whisper.ConversationView = Whisper.View.extend({
|
||||||
if (this.scrollDownButton) {
|
if (this.scrollDownButton) {
|
||||||
this.scrollDownButton.remove();
|
this.scrollDownButton.remove();
|
||||||
}
|
}
|
||||||
if (this.quoteView) {
|
|
||||||
this.quoteView.remove();
|
|
||||||
}
|
|
||||||
if (this.lightboxView) {
|
if (this.lightboxView) {
|
||||||
this.lightboxView.remove();
|
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) {
|
onClickAttachment(attachment: any) {
|
||||||
const getProps = () => ({
|
const getProps = () => ({
|
||||||
url: attachment.url,
|
url: attachment.url,
|
||||||
|
@ -1663,9 +1628,7 @@ Whisper.ConversationView = Whisper.View.extend({
|
||||||
window.Signal.Backbone.Views.Lightbox.show(this.captionEditorView.el);
|
window.Signal.Backbone.Views.Lightbox.show(this.captionEditorView.el);
|
||||||
},
|
},
|
||||||
|
|
||||||
async deleteDraftAttachment(
|
async deleteDraftAttachment(attachment: AttachmentType) {
|
||||||
attachment: Readonly<{ screenshotPath?: string; path?: string }>
|
|
||||||
) {
|
|
||||||
if (attachment.screenshotPath) {
|
if (attachment.screenshotPath) {
|
||||||
await deleteDraftFile(attachment.screenshotPath);
|
await deleteDraftFile(attachment.screenshotPath);
|
||||||
}
|
}
|
||||||
|
@ -1679,7 +1642,7 @@ Whisper.ConversationView = Whisper.View.extend({
|
||||||
window.Signal.Data.updateConversation(model.attributes);
|
window.Signal.Data.updateConversation(model.attributes);
|
||||||
},
|
},
|
||||||
|
|
||||||
async addAttachment(attachment: any) {
|
async addAttachment(attachment: InMemoryAttachmentDraftType) {
|
||||||
const { model }: { model: ConversationModel } = this;
|
const { model }: { model: ConversationModel } = this;
|
||||||
const onDisk = await this.writeDraftAttachment(attachment);
|
const onDisk = await this.writeDraftAttachment(attachment);
|
||||||
|
|
||||||
|
@ -1692,6 +1655,26 @@ Whisper.ConversationView = Whisper.View.extend({
|
||||||
await this.saveModel();
|
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) {
|
async onCloseAttachment(attachment: any) {
|
||||||
const { model }: { model: ConversationModel } = this;
|
const { model }: { model: ConversationModel } = this;
|
||||||
const draftAttachments = model.get('draftAttachments') || [];
|
const draftAttachments = model.get('draftAttachments') || [];
|
||||||
|
@ -1801,14 +1784,21 @@ Whisper.ConversationView = Whisper.View.extend({
|
||||||
},
|
},
|
||||||
|
|
||||||
updateAttachmentsView() {
|
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();
|
this.toggleMicrophone();
|
||||||
if (this.hasFiles()) {
|
if (this.hasFiles()) {
|
||||||
this.removeLinkPreview();
|
this.removeLinkPreview();
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
async writeDraftAttachment(attachment: any) {
|
async writeDraftAttachment(
|
||||||
|
attachment: InMemoryAttachmentDraftType
|
||||||
|
): Promise<OnDiskAttachmentDraftType> {
|
||||||
let toWrite = attachment;
|
let toWrite = attachment;
|
||||||
|
|
||||||
if (toWrite.data) {
|
if (toWrite.data) {
|
||||||
|
@ -1869,7 +1859,7 @@ Whisper.ConversationView = Whisper.View.extend({
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
let attachment;
|
let attachment: InMemoryAttachmentDraftType;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
if (window.Signal.Util.GoogleChrome.isImageTypeSupported(file.type)) {
|
if (window.Signal.Util.GoogleChrome.isImageTypeSupported(file.type)) {
|
||||||
|
@ -1949,7 +1939,7 @@ Whisper.ConversationView = Whisper.View.extend({
|
||||||
return true;
|
return true;
|
||||||
},
|
},
|
||||||
|
|
||||||
async handleVideoAttachment(file: any) {
|
async handleVideoAttachment(file: any): Promise<InMemoryAttachmentDraftType> {
|
||||||
const objectUrl = URL.createObjectURL(file);
|
const objectUrl = URL.createObjectURL(file);
|
||||||
if (!objectUrl) {
|
if (!objectUrl) {
|
||||||
throw new Error('Failed to create object url for video!');
|
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);
|
const blurHash = await window.imageToBlurHash(file);
|
||||||
if (MIME.isJPEG(file.type)) {
|
if (MIME.isJPEG(file.type)) {
|
||||||
const rotatedDataUrl = await window.autoOrientImage(file);
|
const rotatedBlob = await autoOrientImage(file);
|
||||||
const rotatedBlob = window.dataURLToBlobSync(rotatedDataUrl);
|
|
||||||
const { contentType, file: resizedBlob, fileName } = await this.autoScale(
|
const { contentType, file: resizedBlob, fileName } = await this.autoScale(
|
||||||
{
|
{
|
||||||
contentType: file.type,
|
contentType: file.type,
|
||||||
|
@ -1992,7 +1981,7 @@ Whisper.ConversationView = Whisper.View.extend({
|
||||||
file: rotatedBlob,
|
file: rotatedBlob,
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
const data = await await VisualAttachment.blobToArrayBuffer(resizedBlob);
|
const data = await VisualAttachment.blobToArrayBuffer(resizedBlob);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
fileName: fileName || file.name,
|
fileName: fileName || file.name,
|
||||||
|
@ -2008,7 +1997,7 @@ Whisper.ConversationView = Whisper.View.extend({
|
||||||
fileName: file.name,
|
fileName: file.name,
|
||||||
file,
|
file,
|
||||||
});
|
});
|
||||||
const data = await await VisualAttachment.blobToArrayBuffer(resizedBlob);
|
const data = await VisualAttachment.blobToArrayBuffer(resizedBlob);
|
||||||
return {
|
return {
|
||||||
fileName: fileName || file.name,
|
fileName: fileName || file.name,
|
||||||
contentType,
|
contentType,
|
||||||
|
@ -2028,7 +2017,7 @@ Whisper.ConversationView = Whisper.View.extend({
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
const url = URL.createObjectURL(file);
|
const url = URL.createObjectURL(file);
|
||||||
const img = document.createElement('img');
|
const img = document.createElement('img');
|
||||||
img.onload = () => {
|
img.onload = async () => {
|
||||||
URL.revokeObjectURL(url);
|
URL.revokeObjectURL(url);
|
||||||
|
|
||||||
const maxSize = 6000 * 1024;
|
const maxSize = 6000 * 1024;
|
||||||
|
@ -2054,7 +2043,7 @@ Whisper.ConversationView = Whisper.View.extend({
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const targetContentType = 'image/jpeg';
|
const targetContentType = IMAGE_JPEG;
|
||||||
const canvas = window.loadImage.scale(img, {
|
const canvas = window.loadImage.scale(img, {
|
||||||
canvas: true,
|
canvas: true,
|
||||||
maxWidth,
|
maxWidth,
|
||||||
|
@ -2066,9 +2055,9 @@ Whisper.ConversationView = Whisper.View.extend({
|
||||||
let blob;
|
let blob;
|
||||||
do {
|
do {
|
||||||
i -= 1;
|
i -= 1;
|
||||||
blob = window.dataURLToBlobSync(
|
// We want to do these operations in serial.
|
||||||
canvas.toDataURL(targetContentType, quality)
|
// eslint-disable-next-line no-await-in-loop
|
||||||
);
|
blob = await canvasToBlob(canvas, targetContentType, quality);
|
||||||
quality = (quality * maxSize) / blob.size;
|
quality = (quality * maxSize) / blob.size;
|
||||||
// NOTE: During testing with a large image, we observed the
|
// NOTE: During testing with a large image, we observed the
|
||||||
// `quality` value being > 1. Should we clamp it to [0.5, 1.0]?
|
// `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();
|
await this.saveModel();
|
||||||
}
|
}
|
||||||
|
|
||||||
if (this.quoteView) {
|
|
||||||
this.quoteView.remove();
|
|
||||||
this.quoteView = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (message) {
|
if (message) {
|
||||||
const quotedMessage = window.MessageController.register(
|
const quotedMessage = window.MessageController.register(
|
||||||
message.id,
|
message.id,
|
||||||
|
@ -3806,47 +3790,15 @@ Whisper.ConversationView = Whisper.View.extend({
|
||||||
renderQuotedMessage() {
|
renderQuotedMessage() {
|
||||||
const { model }: { model: ConversationModel } = this;
|
const { model }: { model: ConversationModel } = this;
|
||||||
|
|
||||||
if (this.quoteView) {
|
|
||||||
this.quoteView.remove();
|
|
||||||
this.quoteView = null;
|
|
||||||
}
|
|
||||||
if (!this.quotedMessage) {
|
if (!this.quotedMessage) {
|
||||||
|
window.reduxActions.composer.setQuotedMessage(undefined);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const props = getPropsForQuote(
|
window.reduxActions.composer.setQuotedMessage({
|
||||||
{
|
conversationId: model.id,
|
||||||
conversationId: model.id,
|
quote: this.quote,
|
||||||
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);
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|
||||||
if (contact) {
|
|
||||||
this.quoteView.listenTo(contact, 'change', () => {
|
|
||||||
this.renderQuotedMessage();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
|
|
||||||
showInvalidMessageToast(messageText?: string): boolean {
|
showInvalidMessageToast(messageText?: string): boolean {
|
||||||
|
@ -3939,7 +3891,13 @@ Whisper.ConversationView = Whisper.View.extend({
|
||||||
this.quote,
|
this.quote,
|
||||||
this.getLinkPreview(),
|
this.getLinkPreview(),
|
||||||
undefined, // sticker
|
undefined, // sticker
|
||||||
mentions
|
mentions,
|
||||||
|
{
|
||||||
|
sendHQImages:
|
||||||
|
window.reduxStore &&
|
||||||
|
window.reduxStore.getState().composer
|
||||||
|
.shouldSendHighQualityAttachments,
|
||||||
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
this.compositionApi.current.reset();
|
this.compositionApi.current.reset();
|
||||||
|
@ -3947,6 +3905,7 @@ Whisper.ConversationView = Whisper.View.extend({
|
||||||
this.setQuoteMessage(null);
|
this.setQuoteMessage(null);
|
||||||
this.resetLinkPreview();
|
this.resetLinkPreview();
|
||||||
this.clearAttachments();
|
this.clearAttachments();
|
||||||
|
window.reduxActions.composer.resetComposer();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
window.log.error(
|
window.log.error(
|
||||||
'Error pulling attached files before send',
|
'Error pulling attached files before send',
|
||||||
|
@ -4068,7 +4027,7 @@ Whisper.ConversationView = Whisper.View.extend({
|
||||||
async getStickerPackPreview(
|
async getStickerPackPreview(
|
||||||
url: string,
|
url: string,
|
||||||
abortSignal: Readonly<AbortSignal>
|
abortSignal: Readonly<AbortSignal>
|
||||||
): Promise<null | GetLinkPreviewResult> {
|
): Promise<null | LinkPreviewResult> {
|
||||||
const isPackDownloaded = (pack: any) =>
|
const isPackDownloaded = (pack: any) =>
|
||||||
pack && (pack.status === 'downloaded' || pack.status === 'installed');
|
pack && (pack.status === 'downloaded' || pack.status === 'installed');
|
||||||
const isPackValid = (pack: any) =>
|
const isPackValid = (pack: any) =>
|
||||||
|
@ -4144,7 +4103,7 @@ Whisper.ConversationView = Whisper.View.extend({
|
||||||
async getGroupPreview(
|
async getGroupPreview(
|
||||||
url: string,
|
url: string,
|
||||||
abortSignal: Readonly<AbortSignal>
|
abortSignal: Readonly<AbortSignal>
|
||||||
): Promise<null | GetLinkPreviewResult> {
|
): Promise<null | LinkPreviewResult> {
|
||||||
const urlObject = maybeParseUrl(url);
|
const urlObject = maybeParseUrl(url);
|
||||||
if (!urlObject) {
|
if (!urlObject) {
|
||||||
return null;
|
return null;
|
||||||
|
@ -4187,7 +4146,7 @@ Whisper.ConversationView = Whisper.View.extend({
|
||||||
: window.i18n('GroupV2--join--member-count--multiple', {
|
: window.i18n('GroupV2--join--member-count--multiple', {
|
||||||
count: result.memberCount.toString(),
|
count: result.memberCount.toString(),
|
||||||
});
|
});
|
||||||
let image: undefined | GetLinkPreviewImageResult;
|
let image: undefined | LinkPreviewImage;
|
||||||
|
|
||||||
if (result.avatar) {
|
if (result.avatar) {
|
||||||
try {
|
try {
|
||||||
|
@ -4198,10 +4157,10 @@ Whisper.ConversationView = Whisper.View.extend({
|
||||||
image = {
|
image = {
|
||||||
data,
|
data,
|
||||||
size: data.byteLength,
|
size: data.byteLength,
|
||||||
contentType: 'image/jpeg',
|
contentType: IMAGE_JPEG,
|
||||||
blurHash: await window.imageToBlurHash(
|
blurHash: await window.imageToBlurHash(
|
||||||
new Blob([data], {
|
new Blob([data], {
|
||||||
type: 'image/jpeg',
|
type: IMAGE_JPEG,
|
||||||
})
|
})
|
||||||
),
|
),
|
||||||
};
|
};
|
||||||
|
@ -4229,7 +4188,7 @@ Whisper.ConversationView = Whisper.View.extend({
|
||||||
async getPreview(
|
async getPreview(
|
||||||
url: string,
|
url: string,
|
||||||
abortSignal: Readonly<AbortSignal>
|
abortSignal: Readonly<AbortSignal>
|
||||||
): Promise<null | GetLinkPreviewResult> {
|
): Promise<null | LinkPreviewResult> {
|
||||||
if (window.Signal.LinkPreviews.isStickerPack(url)) {
|
if (window.Signal.LinkPreviews.isStickerPack(url)) {
|
||||||
return this.getStickerPackPreview(url, abortSignal);
|
return this.getStickerPackPreview(url, abortSignal);
|
||||||
}
|
}
|
||||||
|
@ -4410,32 +4369,10 @@ Whisper.ConversationView = Whisper.View.extend({
|
||||||
if (this.forwardMessageModal) {
|
if (this.forwardMessageModal) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (this.previewView) {
|
window.reduxActions.composer.setLinkPreviewResult(
|
||||||
this.previewView.remove();
|
Boolean(this.currentlyMatchedLink),
|
||||||
this.previewView = null;
|
this.getLinkPreviewWithDomain()
|
||||||
}
|
);
|
||||||
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,
|
|
||||||
});
|
|
||||||
},
|
},
|
||||||
|
|
||||||
getLinkPreview() {
|
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
|
// Called whenever the user changes the message composition field. But only
|
||||||
// fires if there's content in the message field after the change.
|
// fires if there's content in the message field after the change.
|
||||||
maybeBumpTyping(messageText: string) {
|
maybeBumpTyping(messageText: string) {
|
||||||
|
|
4
ts/window.d.ts
vendored
4
ts/window.d.ts
vendored
|
@ -150,8 +150,6 @@ declare global {
|
||||||
|
|
||||||
moment: typeof moment;
|
moment: typeof moment;
|
||||||
imageToBlurHash: typeof imageToBlurHash;
|
imageToBlurHash: typeof imageToBlurHash;
|
||||||
autoOrientImage: any;
|
|
||||||
dataURLToBlobSync: any;
|
|
||||||
loadImage: any;
|
loadImage: any;
|
||||||
isBehindProxy: () => boolean;
|
isBehindProxy: () => boolean;
|
||||||
getAutoLaunch: () => boolean;
|
getAutoLaunch: () => boolean;
|
||||||
|
@ -220,7 +218,7 @@ declare global {
|
||||||
getRegionCodeForNumber: (number: string) => string;
|
getRegionCodeForNumber: (number: string) => string;
|
||||||
parseNumber: (
|
parseNumber: (
|
||||||
e164: string,
|
e164: string,
|
||||||
defaultRegionCode: string
|
defaultRegionCode?: string
|
||||||
) =>
|
) =>
|
||||||
| { isValidNumber: false; error: unknown }
|
| { isValidNumber: false; error: unknown }
|
||||||
| {
|
| {
|
||||||
|
|
|
@ -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"
|
resolved "https://registry.yarnpkg.com/bluebird/-/bluebird-3.5.5.tgz#a8d0afd73251effbbd5fe384a77d73003c17a71f"
|
||||||
integrity sha512-5am6HnnfN+urzt4yfg7IgTbotDjIT/u8AJpEt0sIU9FtXfVeezXAPKswrG+xKUCOYAINpSdgZVDU6QFh+cuH3w==
|
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:
|
blueimp-load-image@5.14.0:
|
||||||
version "5.14.0"
|
version "5.14.0"
|
||||||
resolved "https://registry.yarnpkg.com/blueimp-load-image/-/blueimp-load-image-5.14.0.tgz#e8086415e580df802c33ff0da6b37a8d20205cc6"
|
resolved "https://registry.yarnpkg.com/blueimp-load-image/-/blueimp-load-image-5.14.0.tgz#e8086415e580df802c33ff0da6b37a8d20205cc6"
|
||||||
|
|
Loading…
Add table
Reference in a new issue