diff --git a/app/global_errors.ts b/app/global_errors.ts index f2bec345edd..d29aa0396d5 100644 --- a/app/global_errors.ts +++ b/app/global_errors.ts @@ -3,7 +3,7 @@ import { app, dialog, clipboard } from 'electron'; -import * as Errors from '../js/modules/types/errors'; +import * as Errors from '../ts/types/errors'; import { redactAll } from '../ts/util/privacy'; import { LocaleMessagesType } from '../ts/types/I18N'; import { reallyJsonStringify } from '../ts/util/reallyJsonStringify'; diff --git a/js/modules/signal.js b/js/modules/signal.js index 31276df3aed..3a68d77fdb3 100644 --- a/js/modules/signal.js +++ b/js/modules/signal.js @@ -131,11 +131,11 @@ const conversationsSelectors = require('../../ts/state/selectors/conversations') const searchSelectors = require('../../ts/state/selectors/search'); // Types -const AttachmentType = require('./types/attachment'); +const AttachmentType = require('../../ts/types/Attachment'); const VisualAttachment = require('./types/visual_attachment'); const Contact = require('../../ts/types/Contact'); const Conversation = require('./types/conversation'); -const Errors = require('./types/errors'); +const Errors = require('../../ts/types/errors'); const MediaGalleryMessage = require('../../ts/components/conversation/media-gallery/types/Message'); const MessageType = require('./types/message'); const MIME = require('../../ts/types/MIME'); diff --git a/js/modules/types/attachment.js b/js/modules/types/attachment.js deleted file mode 100644 index 7881b3909ab..00000000000 --- a/js/modules/types/attachment.js +++ /dev/null @@ -1,347 +0,0 @@ -// Copyright 2018-2021 Signal Messenger, LLC -// SPDX-License-Identifier: AGPL-3.0-only - -const is = require('@sindresorhus/is'); - -const { arrayBufferToBlob, blobToArrayBuffer } = require('blob-util'); -const AttachmentTS = require('../../../ts/types/Attachment'); -const GoogleChrome = require('../../../ts/util/GoogleChrome'); -const { toLogFormat } = require('./errors'); -const { scaleImageToLevel } = require('../../../ts/util/scaleImageToLevel'); -const { - migrateDataToFileSystem, -} = require('./attachment/migrate_data_to_file_system'); - -// // Incoming message attachment fields -// { -// id: string -// contentType: MIMEType -// data: ArrayBuffer -// digest: ArrayBuffer -// fileName?: string -// flags: null -// key: ArrayBuffer -// size: integer -// thumbnail: ArrayBuffer -// } - -// // Outgoing message attachment fields -// { -// contentType: MIMEType -// data: ArrayBuffer -// fileName: string -// size: integer -// } - -// Returns true if `rawAttachment` is a valid attachment based on our current schema. -// Over time, we can expand this definition to become more narrow, e.g. require certain -// fields, etc. -exports.isValid = rawAttachment => { - // NOTE: We cannot use `_.isPlainObject` because `rawAttachment` is - // deserialized by protobuf: - if (!rawAttachment) { - return false; - } - - return true; -}; - -// Upgrade steps -// NOTE: This step strips all EXIF metadata from JPEG images as -// part of re-encoding the image: -exports.autoOrientJPEG = async (attachment, _, message) => { - if (!AttachmentTS.canBeTranscoded(attachment)) { - return attachment; - } - - // If we haven't downloaded the attachment yet, we won't have the data - if (!attachment.data) { - return attachment; - } - - const dataBlob = await arrayBufferToBlob( - attachment.data, - attachment.contentType - ); - const xcodedDataBlob = await scaleImageToLevel( - dataBlob, - message ? message.sendHQImages : false - ); - const xcodedDataArrayBuffer = await blobToArrayBuffer(xcodedDataBlob); - - // IMPORTANT: We overwrite the existing `data` `ArrayBuffer` losing the original - // image data. Ideally, we’d preserve the original image data for users who want to - // retain it but due to reports of data loss, we don’t want to overburden IndexedDB - // by potentially doubling stored image data. - // See: https://github.com/signalapp/Signal-Desktop/issues/1589 - const xcodedAttachment = { - ...attachment, - data: xcodedDataArrayBuffer, - size: xcodedDataArrayBuffer.byteLength, - }; - - // `digest` is no longer valid for auto-oriented image data, so we discard it: - delete xcodedAttachment.digest; - - return xcodedAttachment; -}; - -const UNICODE_LEFT_TO_RIGHT_OVERRIDE = '\u202D'; -const UNICODE_RIGHT_TO_LEFT_OVERRIDE = '\u202E'; -const UNICODE_REPLACEMENT_CHARACTER = '\uFFFD'; -const INVALID_CHARACTERS_PATTERN = new RegExp( - `[${UNICODE_LEFT_TO_RIGHT_OVERRIDE}${UNICODE_RIGHT_TO_LEFT_OVERRIDE}]`, - 'g' -); -// NOTE: Expose synchronous version to do property-based testing using `testcheck`, -// which currently doesn’t support async testing: -// https://github.com/leebyron/testcheck-js/issues/45 -exports._replaceUnicodeOrderOverridesSync = attachment => { - if (!is.string(attachment.fileName)) { - return attachment; - } - - const normalizedFilename = attachment.fileName.replace( - INVALID_CHARACTERS_PATTERN, - UNICODE_REPLACEMENT_CHARACTER - ); - const newAttachment = { ...attachment, fileName: normalizedFilename }; - - return newAttachment; -}; - -exports.replaceUnicodeOrderOverrides = async attachment => - exports._replaceUnicodeOrderOverridesSync(attachment); - -// \u202A-\u202E is LRE, RLE, PDF, LRO, RLO -// \u2066-\u2069 is LRI, RLI, FSI, PDI -// \u200E is LRM -// \u200F is RLM -// \u061C is ALM -const V2_UNWANTED_UNICODE = /[\u202A-\u202E\u2066-\u2069\u200E\u200F\u061C]/g; - -exports.replaceUnicodeV2 = async attachment => { - if (!is.string(attachment.fileName)) { - return attachment; - } - - const fileName = attachment.fileName.replace( - V2_UNWANTED_UNICODE, - UNICODE_REPLACEMENT_CHARACTER - ); - - return { - ...attachment, - fileName, - }; -}; - -exports.removeSchemaVersion = ({ attachment, logger }) => { - if (!exports.isValid(attachment)) { - logger.error( - 'Attachment.removeSchemaVersion: Invalid input attachment:', - attachment - ); - return attachment; - } - - const attachmentWithoutSchemaVersion = { ...attachment }; - delete attachmentWithoutSchemaVersion.schemaVersion; - return attachmentWithoutSchemaVersion; -}; - -exports.migrateDataToFileSystem = migrateDataToFileSystem; - -// hasData :: Attachment -> Boolean -exports.hasData = attachment => - attachment.data instanceof ArrayBuffer || ArrayBuffer.isView(attachment.data); - -// loadData :: (RelativePath -> IO (Promise ArrayBuffer)) -// Attachment -> -// IO (Promise Attachment) -exports.loadData = readAttachmentData => { - if (!is.function(readAttachmentData)) { - throw new TypeError("'readAttachmentData' must be a function"); - } - - return async attachment => { - if (!exports.isValid(attachment)) { - throw new TypeError("'attachment' is not valid"); - } - - const isAlreadyLoaded = exports.hasData(attachment); - if (isAlreadyLoaded) { - return attachment; - } - - if (!is.string(attachment.path)) { - throw new TypeError("'attachment.path' is required"); - } - - const data = await readAttachmentData(attachment.path); - return { ...attachment, data, size: data.byteLength }; - }; -}; - -// deleteData :: (RelativePath -> IO Unit) -// Attachment -> -// IO Unit -exports.deleteData = deleteOnDisk => { - if (!is.function(deleteOnDisk)) { - throw new TypeError('deleteData: deleteOnDisk must be a function'); - } - - return async attachment => { - if (!exports.isValid(attachment)) { - throw new TypeError('deleteData: attachment is not valid'); - } - - const { path, thumbnail, screenshot } = attachment; - if (is.string(path)) { - await deleteOnDisk(path); - } - - if (thumbnail && is.string(thumbnail.path)) { - await deleteOnDisk(thumbnail.path); - } - - if (screenshot && is.string(screenshot.path)) { - await deleteOnDisk(screenshot.path); - } - }; -}; - -exports.isImage = AttachmentTS.isImage; -exports.isVideo = AttachmentTS.isVideo; -exports.isGIF = AttachmentTS.isGIF; -exports.isAudio = AttachmentTS.isAudio; -exports.isVoiceMessage = AttachmentTS.isVoiceMessage; -exports.getUploadSizeLimitKb = AttachmentTS.getUploadSizeLimitKb; -exports.save = AttachmentTS.save; - -const THUMBNAIL_SIZE = 150; -const THUMBNAIL_CONTENT_TYPE = 'image/png'; - -exports.captureDimensionsAndScreenshot = async ( - attachment, - { - writeNewAttachmentData, - getAbsoluteAttachmentPath, - makeObjectUrl, - revokeObjectUrl, - getImageDimensions, - makeImageThumbnail, - makeVideoScreenshot, - logger, - } -) => { - const { contentType } = attachment; - - if ( - !GoogleChrome.isImageTypeSupported(contentType) && - !GoogleChrome.isVideoTypeSupported(contentType) - ) { - return attachment; - } - - // If the attachment hasn't been downloaded yet, we won't have a path - if (!attachment.path) { - return attachment; - } - - const absolutePath = await getAbsoluteAttachmentPath(attachment.path); - - if (GoogleChrome.isImageTypeSupported(contentType)) { - try { - const { width, height } = await getImageDimensions({ - objectUrl: absolutePath, - logger, - }); - const thumbnailBuffer = await blobToArrayBuffer( - await makeImageThumbnail({ - size: THUMBNAIL_SIZE, - objectUrl: absolutePath, - contentType: THUMBNAIL_CONTENT_TYPE, - logger, - }) - ); - - const thumbnailPath = await writeNewAttachmentData(thumbnailBuffer); - return { - ...attachment, - width, - height, - thumbnail: { - path: thumbnailPath, - contentType: THUMBNAIL_CONTENT_TYPE, - width: THUMBNAIL_SIZE, - height: THUMBNAIL_SIZE, - }, - }; - } catch (error) { - logger.error( - 'captureDimensionsAndScreenshot:', - 'error processing image; skipping screenshot generation', - toLogFormat(error) - ); - return attachment; - } - } - - let screenshotObjectUrl; - try { - const screenshotBuffer = await blobToArrayBuffer( - await makeVideoScreenshot({ - objectUrl: absolutePath, - contentType: THUMBNAIL_CONTENT_TYPE, - logger, - }) - ); - screenshotObjectUrl = makeObjectUrl( - screenshotBuffer, - THUMBNAIL_CONTENT_TYPE - ); - const { width, height } = await getImageDimensions({ - objectUrl: screenshotObjectUrl, - logger, - }); - const screenshotPath = await writeNewAttachmentData(screenshotBuffer); - - const thumbnailBuffer = await blobToArrayBuffer( - await makeImageThumbnail({ - size: THUMBNAIL_SIZE, - objectUrl: screenshotObjectUrl, - contentType: THUMBNAIL_CONTENT_TYPE, - logger, - }) - ); - - const thumbnailPath = await writeNewAttachmentData(thumbnailBuffer); - - return { - ...attachment, - screenshot: { - contentType: THUMBNAIL_CONTENT_TYPE, - path: screenshotPath, - width, - height, - }, - thumbnail: { - path: thumbnailPath, - contentType: THUMBNAIL_CONTENT_TYPE, - width: THUMBNAIL_SIZE, - height: THUMBNAIL_SIZE, - }, - width, - height, - }; - } catch (error) { - logger.error( - 'captureDimensionsAndScreenshot: error processing video; skipping screenshot generation', - toLogFormat(error) - ); - return attachment; - } finally { - revokeObjectUrl(screenshotObjectUrl); - } -}; diff --git a/js/modules/types/attachment/migrate_data_to_file_system.js b/js/modules/types/attachment/migrate_data_to_file_system.js deleted file mode 100644 index e68d1813104..00000000000 --- a/js/modules/types/attachment/migrate_data_to_file_system.js +++ /dev/null @@ -1,40 +0,0 @@ -// Copyright 2018-2020 Signal Messenger, LLC -// SPDX-License-Identifier: AGPL-3.0-only - -const { isArrayBuffer, isFunction, isUndefined, omit } = require('lodash'); - -// type Context :: { -// writeNewAttachmentData :: ArrayBuffer -> Promise (IO Path) -// } -// -// migrateDataToFileSystem :: Attachment -> -// Context -> -// Promise Attachment -exports.migrateDataToFileSystem = async ( - attachment, - { writeNewAttachmentData } = {} -) => { - if (!isFunction(writeNewAttachmentData)) { - throw new TypeError("'writeNewAttachmentData' must be a function"); - } - - const { data } = attachment; - const hasData = !isUndefined(data); - const shouldSkipSchemaUpgrade = !hasData; - if (shouldSkipSchemaUpgrade) { - return attachment; - } - - const isValidData = isArrayBuffer(data); - if (!isValidData) { - throw new TypeError( - 'Expected `attachment.data` to be an array buffer;' + - ` got: ${typeof attachment.data}` - ); - } - - const path = await writeNewAttachmentData(data); - - const attachmentWithoutData = omit({ ...attachment, path }, ['data']); - return attachmentWithoutData; -}; diff --git a/js/modules/types/contact.js b/js/modules/types/contact.js index 312057ba66b..22e6af4bdc6 100644 --- a/js/modules/types/contact.js +++ b/js/modules/types/contact.js @@ -3,7 +3,7 @@ const { omit, compact, map } = require('lodash'); -const { toLogFormat } = require('./errors'); +const { toLogFormat } = require('../../../ts/types/errors'); const { SignalService } = require('../../../ts/protobuf'); const { parse: parsePhoneNumber } = require('../../../ts/types/PhoneNumber'); diff --git a/js/modules/types/errors.d.ts b/js/modules/types/errors.d.ts deleted file mode 100644 index f09402e95c9..00000000000 --- a/js/modules/types/errors.d.ts +++ /dev/null @@ -1,4 +0,0 @@ -// Copyright 2018-2020 Signal Messenger, LLC -// SPDX-License-Identifier: AGPL-3.0-only - -export function toLogFormat(error: any): string; diff --git a/js/modules/types/errors.js b/js/modules/types/errors.js deleted file mode 100644 index 9ad90833298..00000000000 --- a/js/modules/types/errors.js +++ /dev/null @@ -1,15 +0,0 @@ -// Copyright 2018-2020 Signal Messenger, LLC -// SPDX-License-Identifier: AGPL-3.0-only - -// toLogFormat :: Error -> String -exports.toLogFormat = error => { - if (!error) { - return error; - } - - if (error && error.stack) { - return error.stack; - } - - return error.toString(); -}; diff --git a/js/modules/types/message.js b/js/modules/types/message.js index 958cb9c1265..a07c1a2a7a0 100644 --- a/js/modules/types/message.js +++ b/js/modules/types/message.js @@ -4,8 +4,8 @@ const { isFunction, isObject, isString, omit } = require('lodash'); const Contact = require('./contact'); -const Attachment = require('./attachment'); -const Errors = require('./errors'); +const Attachment = require('../../../ts/types/Attachment'); +const Errors = require('../../../ts/types/errors'); const SchemaVersion = require('./schema_version'); const { initializeAttachmentMetadata, diff --git a/js/modules/types/visual_attachment.js b/js/modules/types/visual_attachment.js index 491d09ae851..d876ce596f0 100644 --- a/js/modules/types/visual_attachment.js +++ b/js/modules/types/visual_attachment.js @@ -5,7 +5,7 @@ const loadImage = require('blueimp-load-image'); const { blobToArrayBuffer } = require('blob-util'); -const { toLogFormat } = require('./errors'); +const { toLogFormat } = require('../../../ts/types/errors'); const { arrayBufferToObjectURL, } = require('../../../ts/util/arrayBufferToObjectURL'); diff --git a/package.json b/package.json index 7e1405ead8a..5415d4cd5ce 100644 --- a/package.json +++ b/package.json @@ -78,7 +78,7 @@ "axe-core": "4.1.4", "backbone": "1.4.0", "better-sqlite3": "https://github.com/signalapp/better-sqlite3#2fa02d2484e9f9a10df5ac7ea4617fb2dff30006", - "blob-util": "1.3.0", + "blob-util": "2.0.2", "blueimp-load-image": "5.14.0", "blurhash": "1.1.3", "classnames": "2.2.5", diff --git a/stylesheets/_modules.scss b/stylesheets/_modules.scss index 92b4958b4a1..c98724cd42f 100644 --- a/stylesheets/_modules.scss +++ b/stylesheets/_modules.scss @@ -4084,6 +4084,10 @@ button.module-conversation-details__action-button { line-height: 14px; user-select: none; } + + video { + cursor: pointer; + } } // Only if it's a sticker do we put the outline inside it diff --git a/test/modules/types/attachment_test.js b/test/modules/types/attachment_test.js deleted file mode 100644 index 29d2d72c87f..00000000000 --- a/test/modules/types/attachment_test.js +++ /dev/null @@ -1,234 +0,0 @@ -// Copyright 2018-2020 Signal Messenger, LLC -// SPDX-License-Identifier: AGPL-3.0-only - -require('mocha-testcheck').install(); - -const { assert } = require('chai'); - -const Attachment = require('../../../js/modules/types/attachment'); -const { - stringToArrayBuffer, -} = require('../../../js/modules/string_to_array_buffer'); - -describe('Attachment', () => { - describe('replaceUnicodeOrderOverrides', () => { - it('should sanitize left-to-right order override character', async () => { - const input = { - contentType: 'image/jpeg', - fileName: 'test\u202Dfig.exe', - size: 1111, - }; - const expected = { - contentType: 'image/jpeg', - fileName: 'test\uFFFDfig.exe', - size: 1111, - }; - - const actual = await Attachment.replaceUnicodeOrderOverrides(input); - assert.deepEqual(actual, expected); - }); - - it('should sanitize right-to-left order override character', async () => { - const input = { - contentType: 'image/jpeg', - fileName: 'test\u202Efig.exe', - size: 1111, - }; - const expected = { - contentType: 'image/jpeg', - fileName: 'test\uFFFDfig.exe', - size: 1111, - }; - - const actual = await Attachment.replaceUnicodeOrderOverrides(input); - assert.deepEqual(actual, expected); - }); - - it('should sanitize multiple override characters', async () => { - const input = { - contentType: 'image/jpeg', - fileName: 'test\u202e\u202dlol\u202efig.exe', - size: 1111, - }; - const expected = { - contentType: 'image/jpeg', - fileName: 'test\uFFFD\uFFFDlol\uFFFDfig.exe', - size: 1111, - }; - - const actual = await Attachment.replaceUnicodeOrderOverrides(input); - assert.deepEqual(actual, expected); - }); - - const hasNoUnicodeOrderOverrides = value => - !value.includes('\u202D') && !value.includes('\u202E'); - - check.it( - 'should ignore non-order-override characters', - gen.string.suchThat(hasNoUnicodeOrderOverrides), - fileName => { - const input = { - contentType: 'image/jpeg', - fileName, - size: 1111, - }; - - const actual = Attachment._replaceUnicodeOrderOverridesSync(input); - assert.deepEqual(actual, input); - } - ); - }); - - describe('replaceUnicodeV2', () => { - it('should remove all bad characters', async () => { - const input = { - size: 1111, - fileName: - 'file\u202A\u202B\u202C\u202D\u202E\u2066\u2067\u2068\u2069\u200E\u200F\u061C.jpeg', - }; - const expected = { - fileName: - 'file\uFFFD\uFFFD\uFFFD\uFFFD\uFFFD\uFFFD\uFFFD\uFFFD\uFFFD\uFFFD\uFFFD\uFFFD.jpeg', - size: 1111, - }; - - const actual = await Attachment.replaceUnicodeV2(input); - assert.deepEqual(actual, expected); - }); - - it('should should leave normal filename alone', async () => { - const input = { - fileName: 'normal.jpeg', - size: 1111, - }; - const expected = { - fileName: 'normal.jpeg', - size: 1111, - }; - - const actual = await Attachment.replaceUnicodeV2(input); - assert.deepEqual(actual, expected); - }); - - it('should handle missing fileName', async () => { - const input = { - size: 1111, - }; - const expected = { - size: 1111, - }; - - const actual = await Attachment.replaceUnicodeV2(input); - assert.deepEqual(actual, expected); - }); - }); - - describe('removeSchemaVersion', () => { - it('should remove existing schema version', () => { - const input = { - contentType: 'image/jpeg', - fileName: 'foo.jpg', - size: 1111, - schemaVersion: 1, - }; - - const expected = { - contentType: 'image/jpeg', - fileName: 'foo.jpg', - size: 1111, - }; - - const actual = Attachment.removeSchemaVersion({ - attachment: input, - logger: { - error: () => null, - }, - }); - assert.deepEqual(actual, expected); - }); - }); - - describe('migrateDataToFileSystem', () => { - it('should write data to disk and store relative path to it', async () => { - const input = { - contentType: 'image/jpeg', - data: stringToArrayBuffer('Above us only sky'), - fileName: 'foo.jpg', - size: 1111, - }; - - const expected = { - contentType: 'image/jpeg', - path: 'abc/abcdefgh123456789', - fileName: 'foo.jpg', - size: 1111, - }; - - const expectedAttachmentData = stringToArrayBuffer('Above us only sky'); - const writeNewAttachmentData = async attachmentData => { - assert.deepEqual(attachmentData, expectedAttachmentData); - return 'abc/abcdefgh123456789'; - }; - - const actual = await Attachment.migrateDataToFileSystem(input, { - writeNewAttachmentData, - logger: { - warn: () => null, - }, - }); - assert.deepEqual(actual, expected); - }); - - it('should skip over (invalid) attachments without data', async () => { - const input = { - contentType: 'image/jpeg', - fileName: 'foo.jpg', - size: 1111, - }; - - const expected = { - contentType: 'image/jpeg', - fileName: 'foo.jpg', - size: 1111, - }; - - const writeNewAttachmentData = async () => 'abc/abcdefgh123456789'; - - const actual = await Attachment.migrateDataToFileSystem(input, { - writeNewAttachmentData, - logger: { - warn: () => null, - }, - }); - assert.deepEqual(actual, expected); - }); - - it('should throw error if data is not valid', async () => { - const input = { - contentType: 'image/jpeg', - data: 42, - fileName: 'foo.jpg', - size: 1111, - }; - - const writeNewAttachmentData = async () => 'abc/abcdefgh123456789'; - - try { - await Attachment.migrateDataToFileSystem(input, { - writeNewAttachmentData, - logger: { - warn: () => null, - }, - }); - } catch (error) { - assert.strictEqual( - error.message, - 'Expected `attachment.data` to be an array buffer; got: number' - ); - return; - } - - assert.fail('Unreachable'); - }); - }); -}); diff --git a/ts/components/Lightbox.tsx b/ts/components/Lightbox.tsx index d5041ac974e..1a7c86a7618 100644 --- a/ts/components/Lightbox.tsx +++ b/ts/components/Lightbox.tsx @@ -31,6 +31,7 @@ export type Props = { objectURL: string; caption?: string; isViewOnce: boolean; + loop?: boolean; onNext?: () => void; onPrevious?: () => void; onSave?: () => void; @@ -300,6 +301,7 @@ export class Lightbox extends React.Component { contentType, i18n, isViewOnce, + loop = false, objectURL, onNext, onPrevious, @@ -320,7 +322,13 @@ export class Lightbox extends React.Component {
{!is.undefined(contentType) - ? this.renderObject({ objectURL, contentType, i18n, isViewOnce }) + ? this.renderObject({ + objectURL, + contentType, + i18n, + isViewOnce, + loop, + }) : null} {caption ?
{caption}
: null}
@@ -363,11 +371,13 @@ export class Lightbox extends React.Component { contentType, i18n, isViewOnce, + loop, }: { objectURL: string; contentType: MIME.MIMEType; i18n: LocalizerType; isViewOnce: boolean; + loop: boolean; }) => { const isImageTypeSupported = GoogleChrome.isImageTypeSupported(contentType); if (isImageTypeSupported) { @@ -392,8 +402,8 @@ export class Lightbox extends React.Component { return (