From 924c271b13549af0ee482d4ca394037c6d6684e6 Mon Sep 17 00:00:00 2001 From: Scott Nonnenberg Date: Thu, 9 Jun 2022 18:10:20 -0700 Subject: [PATCH] Convert js/modules/types/message to Typescript --- js/modules/signal.js | 4 +- js/modules/types/message.d.ts | 4 - ts/groups.ts | 2 +- ts/messages/migrateMessageData.ts | 4 +- ts/model-types.d.ts | 17 +- ts/sql/Client.ts | 2 +- ts/state/selectors/message.ts | 4 +- ts/test-node/types/EmbeddedContact_test.ts | 16 +- .../test-node/types/Message2_test.ts | 479 +++++++++++------ .../initializeAttachmentMetadata_test.ts | 56 +- ts/types/Attachment.ts | 24 +- ts/types/EmbeddedContact.ts | 10 +- ts/types/Message.ts | 8 +- .../types/message.js => ts/types/Message2.ts | 495 ++++++++++++------ ts/types/PhoneNumber.ts | 2 +- ts/types/SchemaVersion.ts | 2 +- .../message/initializeAttachmentMetadata.ts | 13 +- ts/views/conversation_view.tsx | 4 +- 18 files changed, 748 insertions(+), 398 deletions(-) delete mode 100644 js/modules/types/message.d.ts rename test/modules/types/message_test.js => ts/test-node/types/Message2_test.ts (58%) rename js/modules/types/message.js => ts/types/Message2.ts (62%) diff --git a/js/modules/signal.js b/js/modules/signal.js index cf0a14a84..41b91b000 100644 --- a/js/modules/signal.js +++ b/js/modules/signal.js @@ -110,7 +110,7 @@ const searchSelectors = require('../../ts/state/selectors/search'); // Types const AttachmentType = require('../../ts/types/Attachment'); const VisualAttachment = require('../../ts/types/VisualAttachment'); -const MessageType = require('./types/message'); +const MessageType = require('../../ts/types/Message2'); const { UUID } = require('../../ts/types/UUID'); const { Address } = require('../../ts/types/Address'); const { QualifiedAddress } = require('../../ts/types/QualifiedAddress'); @@ -281,6 +281,8 @@ function initializeMigrations({ makeVideoScreenshot, logger, maxVersion, + getAbsoluteStickerPath, + writeNewStickerData, }); }, writeMessageAttachments: MessageType.createAttachmentDataWriter({ diff --git a/js/modules/types/message.d.ts b/js/modules/types/message.d.ts deleted file mode 100644 index b9a5d91ca..000000000 --- a/js/modules/types/message.d.ts +++ /dev/null @@ -1,4 +0,0 @@ -// Copyright 2020 Signal Messenger, LLC -// SPDX-License-Identifier: AGPL-3.0-only - -export const CURRENT_SCHEMA_VERSION: number; diff --git a/ts/groups.ts b/ts/groups.ts index d037a4343..054ddbb1f 100644 --- a/ts/groups.ts +++ b/ts/groups.ts @@ -62,7 +62,7 @@ import type { GroupLogResponseType, } from './textsecure/WebAPI'; import type MessageSender from './textsecure/SendMessage'; -import { CURRENT_SCHEMA_VERSION as MAX_MESSAGE_SCHEMA } from '../js/modules/types/message'; +import { CURRENT_SCHEMA_VERSION as MAX_MESSAGE_SCHEMA } from './types/Message2'; import type { ConversationModel } from './models/conversations'; import { getGroupSizeHardLimit } from './groups/limits'; import { diff --git a/ts/messages/migrateMessageData.ts b/ts/messages/migrateMessageData.ts index 51e9c81e4..d20470252 100644 --- a/ts/messages/migrateMessageData.ts +++ b/ts/messages/migrateMessageData.ts @@ -2,7 +2,7 @@ // SPDX-License-Identifier: AGPL-3.0-only import { isFunction, isNumber } from 'lodash'; -import * as Message from '../../js/modules/types/message'; +import { CURRENT_SCHEMA_VERSION } from '../types/Message2'; import type { MessageAttributesType } from '../model-types.d'; import type { UUIDStringType } from '../types/UUID'; @@ -14,7 +14,7 @@ export async function migrateMessageData({ upgradeMessageSchema, getMessagesNeedingUpgrade, saveMessage, - maxVersion = Message.CURRENT_SCHEMA_VERSION, + maxVersion = CURRENT_SCHEMA_VERSION, }: Readonly<{ numMessagesPerBatch: number; upgradeMessageSchema: ( diff --git a/ts/model-types.d.ts b/ts/model-types.d.ts index 145e42d4b..3cdce8733 100644 --- a/ts/model-types.d.ts +++ b/ts/model-types.d.ts @@ -61,8 +61,14 @@ export type GroupMigrationType = { droppedMemberIds: Array; invitedMembers: Array; }; +export type PreviewType = { + domain: string; + image: AttachmentType; + title: string; + url: string; +}; -export type PreviewMessageType = Array; +export type PreviewMessageType = Array; export type QuotedMessageType = { attachments: Array; @@ -90,6 +96,9 @@ export type StickerMessageType = { stickerId: number; packKey: string; data?: AttachmentType; + path?: string; + width?: number; + height?: number; }; export type RetryOptions = Readonly<{ @@ -129,9 +138,9 @@ export type MessageAttributesType = { expireTimer?: number; groupMigration?: GroupMigrationType; group_update?: GroupV1Update; - hasAttachments?: boolean; - hasFileAttachments?: boolean; - hasVisualMediaAttachments?: boolean; + hasAttachments?: boolean | 0 | 1; + hasFileAttachments?: boolean | 0 | 1; + hasVisualMediaAttachments?: boolean | 0 | 1; isErased?: boolean; isTapToViewInvalid?: boolean; isViewOnce?: boolean; diff --git a/ts/sql/Client.ts b/ts/sql/Client.ts index 5d2fd4699..7aa58405a 100644 --- a/ts/sql/Client.ts +++ b/ts/sql/Client.ts @@ -30,7 +30,7 @@ import { deleteExternalFiles } from '../types/Conversation'; import { expiringMessagesDeletionService } from '../services/expiringMessagesDeletion'; import { tapToViewMessagesDeletionService } from '../services/tapToViewMessagesDeletionService'; import * as Bytes from '../Bytes'; -import { CURRENT_SCHEMA_VERSION } from '../../js/modules/types/message'; +import { CURRENT_SCHEMA_VERSION } from '../types/Message2'; import { createBatcher } from '../util/batcher'; import { assert, strictAssert } from '../util/assert'; import { cleanDataForIpc } from './cleanDataForIpc'; diff --git a/ts/state/selectors/message.ts b/ts/state/selectors/message.ts index 7d82d4044..3254ce74d 100644 --- a/ts/state/selectors/message.ts +++ b/ts/state/selectors/message.ts @@ -1491,7 +1491,7 @@ export function getPropsForAttachment( url: path ? window.Signal.Migrations.getAbsoluteAttachmentPath(path) : undefined, - screenshot: screenshot + screenshot: screenshot?.path ? { ...screenshot, url: window.Signal.Migrations.getAbsoluteAttachmentPath( @@ -1499,7 +1499,7 @@ export function getPropsForAttachment( ), } : undefined, - thumbnail: thumbnail + thumbnail: thumbnail?.path ? { ...thumbnail, url: window.Signal.Migrations.getAbsoluteAttachmentPath( diff --git a/ts/test-node/types/EmbeddedContact_test.ts b/ts/test-node/types/EmbeddedContact_test.ts index da8e078ae..7e6faf99e 100644 --- a/ts/test-node/types/EmbeddedContact_test.ts +++ b/ts/test-node/types/EmbeddedContact_test.ts @@ -270,7 +270,7 @@ describe('Contact', () => { const result = await upgradeVersion(message.contact[0], { message, logger, - regionCode: '1', + getRegionCode: () => '1', writeNewAttachmentData, }); assert.deepEqual(result, message.contact[0]); @@ -311,7 +311,7 @@ describe('Contact', () => { }; const result = await upgradeVersion(message.contact[0], { message, - regionCode: 'US', + getRegionCode: () => 'US', logger, writeNewAttachmentData, }); @@ -355,7 +355,7 @@ describe('Contact', () => { ], }; const result = await upgradeVersion(message.contact[0], { - regionCode: '1', + getRegionCode: () => '1', writeNewAttachmentData, message, logger, @@ -440,7 +440,7 @@ describe('Contact', () => { }; const result = await upgradeVersion(message.contact[0], { - regionCode: '1', + getRegionCode: () => '1', writeNewAttachmentData, message, logger, @@ -487,7 +487,7 @@ describe('Contact', () => { ], }; const result = await upgradeVersion(message.contact[0], { - regionCode: '1', + getRegionCode: () => '1', writeNewAttachmentData, message, logger, @@ -534,7 +534,7 @@ describe('Contact', () => { ], }; const result = await upgradeVersion(message.contact[0], { - regionCode: '1', + getRegionCode: () => '1', writeNewAttachmentData, message, logger, @@ -577,7 +577,7 @@ describe('Contact', () => { }, }; const result = await upgradeVersion(message.contact[0], { - regionCode: '1', + getRegionCode: () => '1', writeNewAttachmentData, message, logger, @@ -606,7 +606,7 @@ describe('Contact', () => { ], }; const result = await upgradeVersion(message.contact[0], { - regionCode: '1', + getRegionCode: () => '1', writeNewAttachmentData, message, logger, diff --git a/test/modules/types/message_test.js b/ts/test-node/types/Message2_test.ts similarity index 58% rename from test/modules/types/message_test.js rename to ts/test-node/types/Message2_test.ts index 4c7709c52..bbbb98196 100644 --- a/test/modules/types/message_test.js +++ b/ts/test-node/types/Message2_test.ts @@ -1,30 +1,97 @@ // Copyright 2018-2020 Signal Messenger, LLC // SPDX-License-Identifier: AGPL-3.0-only -const { assert } = require('chai'); -const sinon = require('sinon'); +import { assert } from 'chai'; +import * as sinon from 'sinon'; -const Message = require('../../../js/modules/types/message'); -const { SignalService } = require('../../../ts/protobuf'); -const Bytes = require('../../../ts/Bytes'); +import * as Message from '../../types/Message2'; +import { SignalService } from '../../protobuf'; +import * as Bytes from '../../Bytes'; +import * as MIME from '../../types/MIME'; + +import type { EmbeddedContactType } from '../../types/EmbeddedContact'; +import type { + MessageAttributesType, + StickerMessageType, +} from '../../model-types.d'; +import type { AttachmentType } from '../../types/Attachment'; +import type { LoggerType } from '../../types/Logging'; describe('Message', () => { - const logger = { + const logger: LoggerType = { warn: () => null, error: () => null, + fatal: () => null, + info: () => null, + debug: () => null, + trace: () => null, }; + function getDefaultMessage( + props?: Partial + ): MessageAttributesType { + return { + id: 'some-id', + type: 'incoming', + sent_at: 45, + received_at: 45, + timestamp: 45, + conversationId: 'some-conversation-id', + ...props, + }; + } + + function getDefaultContext( + props?: Partial + ): Message.ContextType { + return { + getAbsoluteAttachmentPath: (_path: string) => + 'fake-absolute-attachment-path', + getAbsoluteStickerPath: (_path: string) => 'fake-absolute-sticker-path', + getImageDimensions: async (_params: { + objectUrl: string; + logger: LoggerType; + }) => ({ + width: 10, + height: 20, + }), + getRegionCode: () => 'region-code', + logger, + makeImageThumbnail: async (_params: { + size: number; + objectUrl: string; + contentType: MIME.MIMEType; + logger: LoggerType; + }) => new Blob(), + makeObjectUrl: ( + _data: Uint8Array | ArrayBuffer, + _contentType: MIME.MIMEType + ) => 'fake-object-url', + makeVideoScreenshot: async (_params: { + objectUrl: string; + contentType: MIME.MIMEType; + logger: LoggerType; + }) => new Blob(), + revokeObjectUrl: (_objectUrl: string) => undefined, + writeNewAttachmentData: async (_data: Uint8Array) => + 'fake-attachment-path', + writeNewStickerData: async (_sticker: StickerMessageType) => + 'fake-sticker-path', + ...props, + }; + } + const writeExistingAttachmentData = () => Promise.resolve(); + describe('createAttachmentDataWriter', () => { it('should ignore messages that didn’t go through attachment migration', async () => { - const input = { + const input = getDefaultMessage({ body: 'Imagine there is no heaven…', schemaVersion: 2, - }; - const expected = { + }); + const expected = getDefaultMessage({ body: 'Imagine there is no heaven…', schemaVersion: 2, - }; - const writeExistingAttachmentData = () => {}; + }); const actual = await Message.createAttachmentDataWriter({ writeExistingAttachmentData, @@ -34,17 +101,16 @@ describe('Message', () => { }); it('should ignore messages without attachments', async () => { - const input = { + const input = getDefaultMessage({ body: 'Imagine there is no heaven…', schemaVersion: 4, attachments: [], - }; - const expected = { + }); + const expected = getDefaultMessage({ body: 'Imagine there is no heaven…', schemaVersion: 4, attachments: [], - }; - const writeExistingAttachmentData = () => {}; + }); const actual = await Message.createAttachmentDataWriter({ writeExistingAttachmentData, @@ -54,32 +120,39 @@ describe('Message', () => { }); it('should write attachments to file system on original path', async () => { - const input = { + const input = getDefaultMessage({ body: 'Imagine there is no heaven…', schemaVersion: 4, attachments: [ { + contentType: MIME.IMAGE_GIF, + size: 3534, path: 'ab/abcdefghi', data: Bytes.fromString('It’s easy if you try'), }, ], - }; - const expected = { + }); + const expected = getDefaultMessage({ body: 'Imagine there is no heaven…', schemaVersion: 4, attachments: [ { + contentType: MIME.IMAGE_GIF, + size: 3534, path: 'ab/abcdefghi', }, ], contact: [], preview: [], - }; + }); - const writeExistingAttachmentData = attachment => { + // eslint-disable-next-line @typescript-eslint/no-shadow + const writeExistingAttachmentData = async ( + attachment: Pick + ) => { assert.equal(attachment.path, 'ab/abcdefghi'); assert.strictEqual( - Bytes.toString(attachment.data), + Bytes.toString(attachment.data || new Uint8Array()), 'It’s easy if you try' ); }; @@ -92,11 +165,15 @@ describe('Message', () => { }); it('should process quote attachment thumbnails', async () => { - const input = { + const input = getDefaultMessage({ body: 'Imagine there is no heaven…', schemaVersion: 4, attachments: [], quote: { + id: 3523, + isViewOnce: false, + messageId: 'some-message-id', + referencedMessageNotFound: false, attachments: [ { thumbnail: { @@ -106,12 +183,16 @@ describe('Message', () => { }, ], }, - }; - const expected = { + }); + const expected = getDefaultMessage({ body: 'Imagine there is no heaven…', schemaVersion: 4, attachments: [], quote: { + id: 3523, + isViewOnce: false, + messageId: 'some-message-id', + referencedMessageNotFound: false, attachments: [ { thumbnail: { @@ -122,12 +203,15 @@ describe('Message', () => { }, contact: [], preview: [], - }; + }); - const writeExistingAttachmentData = attachment => { + // eslint-disable-next-line @typescript-eslint/no-shadow + const writeExistingAttachmentData = async ( + attachment: Pick + ) => { assert.equal(attachment.path, 'ab/abcdefghi'); assert.strictEqual( - Bytes.toString(attachment.data), + Bytes.toString(attachment.data || new Uint8Array()), 'It’s easy if you try' ); }; @@ -140,45 +224,52 @@ describe('Message', () => { }); it('should process contact avatars', async () => { - const input = { + const input = getDefaultMessage({ body: 'Imagine there is no heaven…', schemaVersion: 4, attachments: [], contact: [ { - name: 'john', + name: { givenName: 'john' }, avatar: { isProfile: false, avatar: { + contentType: MIME.IMAGE_PNG, + size: 47, path: 'ab/abcdefghi', data: Bytes.fromString('It’s easy if you try'), }, }, }, ], - }; - const expected = { + }); + const expected = getDefaultMessage({ body: 'Imagine there is no heaven…', schemaVersion: 4, attachments: [], contact: [ { - name: 'john', + name: { givenName: 'john' }, avatar: { isProfile: false, avatar: { + contentType: MIME.IMAGE_PNG, + size: 47, path: 'ab/abcdefghi', }, }, }, ], preview: [], - }; + }); - const writeExistingAttachmentData = attachment => { + // eslint-disable-next-line @typescript-eslint/no-shadow + const writeExistingAttachmentData = async ( + attachment: Pick + ) => { assert.equal(attachment.path, 'ab/abcdefghi'); assert.strictEqual( - Bytes.toString(attachment.data), + Bytes.toString(attachment.data || new Uint8Array()), 'It’s easy if you try' ); }; @@ -193,14 +284,14 @@ describe('Message', () => { describe('initializeSchemaVersion', () => { it('should ignore messages with previously inherited schema', () => { - const input = { + const input = getDefaultMessage({ body: 'Imagine there is no heaven…', schemaVersion: 2, - }; - const expected = { + }); + const expected = getDefaultMessage({ body: 'Imagine there is no heaven…', schemaVersion: 2, - }; + }); const actual = Message.initializeSchemaVersion({ message: input, @@ -211,15 +302,15 @@ describe('Message', () => { context('for message without attachments', () => { it('should initialize schema version to zero', () => { - const input = { + const input = getDefaultMessage({ body: 'Imagine there is no heaven…', attachments: [], - }; - const expected = { + }); + const expected = getDefaultMessage({ body: 'Imagine there is no heaven…', attachments: [], schemaVersion: 0, - }; + }); const actual = Message.initializeSchemaVersion({ message: input, @@ -231,26 +322,28 @@ describe('Message', () => { context('for message with attachments', () => { it('should inherit existing attachment schema version', () => { - const input = { + const input = getDefaultMessage({ body: 'Imagine there is no heaven…', attachments: [ { - contentType: 'image/jpeg', + contentType: MIME.IMAGE_JPEG, + size: 45, fileName: 'lennon.jpg', schemaVersion: 7, }, ], - }; - const expected = { + }); + const expected = getDefaultMessage({ body: 'Imagine there is no heaven…', attachments: [ { - contentType: 'image/jpeg', + contentType: MIME.IMAGE_JPEG, + size: 45, fileName: 'lennon.jpg', }, ], schemaVersion: 7, - }; + }); const actual = Message.initializeSchemaVersion({ message: input, @@ -263,10 +356,10 @@ describe('Message', () => { describe('upgradeSchema', () => { it('should upgrade an unversioned message to the latest version', async () => { - const input = { + const input = getDefaultMessage({ attachments: [ { - contentType: 'audio/aac', + contentType: MIME.AUDIO_AAC, flags: SignalService.AttachmentPointer.Flags.VOICE_MESSAGE, data: Bytes.fromString('It’s easy if you try'), fileName: 'test\u202Dfig.exe', @@ -274,11 +367,11 @@ describe('Message', () => { }, ], schemaVersion: 0, - }; - const expected = { + }); + const expected = getDefaultMessage({ attachments: [ { - contentType: 'audio/aac', + contentType: MIME.AUDIO_AAC, flags: 1, path: 'abc/abcdefg', fileName: 'test\uFFFDfig.exe', @@ -290,10 +383,10 @@ describe('Message', () => { hasFileAttachments: undefined, schemaVersion: Message.CURRENT_SCHEMA_VERSION, contact: [], - }; + }); const expectedAttachmentData = 'It’s easy if you try'; - const context = { + const context = getDefaultContext({ writeNewAttachmentData: async attachmentData => { assert.strictEqual( Bytes.toString(attachmentData), @@ -301,58 +394,46 @@ describe('Message', () => { ); return 'abc/abcdefg'; }, - getRegionCode: () => 'US', - getAbsoluteAttachmentPath: () => 'some/path/on/disk', - makeObjectUrl: () => 'blob://FAKE', - revokeObjectUrl: () => null, - getImageDimensions: () => ({ height: 10, width: 15 }), - makeImageThumbnail: () => new Blob(), - makeVideoScreenshot: () => new Blob(), - logger: { - warn: () => null, - error: () => null, - }, - }; + }); const actual = await Message.upgradeSchema(input, context); assert.deepEqual(actual, expected); }); context('with multiple upgrade steps', () => { it('should return last valid message when any upgrade step fails', async () => { - const input = { + const input = getDefaultMessage({ attachments: [ { - contentType: 'application/json', - data: null, + contentType: MIME.APPLICATION_JSON, fileName: 'test\u202Dfig.exe', size: 1111, }, ], + body: 'start', schemaVersion: 0, - }; - const expected = { + }); + const expected = getDefaultMessage({ attachments: [ { - contentType: 'application/json', - data: null, + contentType: MIME.APPLICATION_JSON, fileName: 'test\u202Dfig.exe', size: 1111, }, ], - hasUpgradedToVersion1: true, + body: 'start +1', schemaVersion: 1, - }; + }); - const v1 = async message => ({ + const v1 = async (message: MessageAttributesType) => ({ ...message, - hasUpgradedToVersion1: true, + body: `${message.body} +1`, }); const v2 = async () => { throw new Error('boom'); }; - const v3 = async message => ({ + const v3 = async (message: MessageAttributesType) => ({ ...message, - hasUpgradedToVersion3: true, + body: `${message.body} +3`, }); const toVersion1 = Message._withSchemaVersion({ @@ -368,8 +449,8 @@ describe('Message', () => { upgrade: v3, }); - const context = { logger }; - const upgradeSchema = async message => + const context = getDefaultContext({ logger }); + const upgradeSchema = async (message: MessageAttributesType) => toVersion3( await toVersion2(await toVersion1(message, context), context), context @@ -380,42 +461,40 @@ describe('Message', () => { }); it('should skip out-of-order upgrade steps', async () => { - const input = { + const input = getDefaultMessage({ attachments: [ { - contentType: 'application/json', - data: null, + contentType: MIME.APPLICATION_JSON, fileName: 'test\u202Dfig.exe', size: 1111, }, ], + body: 'start', schemaVersion: 0, - }; - const expected = { + }); + const expected = getDefaultMessage({ attachments: [ { - contentType: 'application/json', - data: null, + contentType: MIME.APPLICATION_JSON, fileName: 'test\u202Dfig.exe', size: 1111, }, ], + body: 'start +1 +2', schemaVersion: 2, - hasUpgradedToVersion1: true, - hasUpgradedToVersion2: true, - }; + }); - const v1 = async attachment => ({ - ...attachment, - hasUpgradedToVersion1: true, + const v1 = async (message: MessageAttributesType) => ({ + ...message, + body: `${message.body} +1`, }); - const v2 = async attachment => ({ - ...attachment, - hasUpgradedToVersion2: true, + const v2 = async (message: MessageAttributesType) => ({ + ...message, + body: `${message.body} +2`, }); - const v3 = async attachment => ({ - ...attachment, - hasUpgradedToVersion3: true, + const v3 = async (message: MessageAttributesType) => ({ + ...message, + body: `${message.body} +3`, }); const toVersion1 = Message._withSchemaVersion({ @@ -431,15 +510,13 @@ describe('Message', () => { upgrade: v3, }); - const context = { logger }; - // NOTE: We upgrade to 3 before 2, i.e. the pipeline should abort: - const upgradeSchema = async attachment => - toVersion2( - await toVersion3(await toVersion1(attachment, context), context), - context - ); + const context = getDefaultContext({ logger }); + const atVersion1 = await toVersion1(input, context); - const actual = await upgradeSchema(input); + // Note: this will fail to apply and log, since it's jumping two versions up + const atVersion3 = await toVersion3(atVersion1, context); + + const actual = await toVersion2(atVersion3, context); assert.deepEqual(actual, expected); }); }); @@ -447,37 +524,49 @@ describe('Message', () => { describe('_withSchemaVersion', () => { it('should require a version number', () => { - const toVersionX = () => {}; + const toVersionX = () => null; assert.throws( () => - Message._withSchemaVersion({ schemaVersion: toVersionX, upgrade: 2 }), + Message._withSchemaVersion({ + // eslint-disable-next-line @typescript-eslint/no-explicit-any + schemaVersion: toVersionX as any, + upgrade: () => Promise.resolve(getDefaultMessage()), + }), '_withSchemaVersion: schemaVersion is invalid' ); }); it('should require an upgrade function', () => { assert.throws( - () => Message._withSchemaVersion({ schemaVersion: 2, upgrade: 3 }), + () => + // eslint-disable-next-line @typescript-eslint/no-explicit-any + Message._withSchemaVersion({ schemaVersion: 2, upgrade: 3 as any }), '_withSchemaVersion: upgrade must be a function' ); }); it('should skip upgrading if message has already been upgraded', async () => { - const upgrade = async message => ({ ...message, foo: true }); + const upgrade = async (message: MessageAttributesType) => ({ + ...message, + foo: true, + }); const upgradeWithVersion = Message._withSchemaVersion({ schemaVersion: 3, upgrade, }); - const input = { + const input = getDefaultMessage({ id: 'guid-guid-guid-guid', schemaVersion: 4, - }; - const expected = { + }); + const expected = getDefaultMessage({ id: 'guid-guid-guid-guid', schemaVersion: 4, - }; - const actual = await upgradeWithVersion(input, { logger }); + }); + const actual = await upgradeWithVersion( + input, + getDefaultContext({ logger }) + ); assert.deepEqual(actual, expected); }); @@ -490,15 +579,18 @@ describe('Message', () => { upgrade, }); - const input = { + const input = getDefaultMessage({ id: 'guid-guid-guid-guid', schemaVersion: 0, - }; - const expected = { + }); + const expected = getDefaultMessage({ id: 'guid-guid-guid-guid', schemaVersion: 0, - }; - const actual = await upgradeWithVersion(input, { logger }); + }); + const actual = await upgradeWithVersion( + input, + getDefaultContext({ logger }) + ); assert.deepEqual(actual, expected); }); @@ -506,18 +598,22 @@ describe('Message', () => { const upgrade = async () => null; const upgradeWithVersion = Message._withSchemaVersion({ schemaVersion: 3, - upgrade, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + upgrade: upgrade as any, }); - const input = { + const input = getDefaultMessage({ id: 'guid-guid-guid-guid', schemaVersion: 0, - }; - const expected = { + }); + const expected = getDefaultMessage({ id: 'guid-guid-guid-guid', schemaVersion: 0, - }; - const actual = await upgradeWithVersion(input, { logger }); + }); + const actual = await upgradeWithVersion( + input, + getDefaultContext({ logger }) + ); assert.deepEqual(actual, expected); }); }); @@ -529,10 +625,10 @@ describe('Message', () => { .throws(new Error("Shouldn't be called")); const upgradeVersion = Message._mapQuotedAttachments(upgradeAttachment); - const message = { + const message = getDefaultMessage({ body: 'hey there!', - }; - const result = await upgradeVersion(message); + }); + const result = await upgradeVersion(message, getDefaultContext()); assert.deepEqual(result, message); }); @@ -542,20 +638,32 @@ describe('Message', () => { .throws(new Error("Shouldn't be called")); const upgradeVersion = Message._mapQuotedAttachments(upgradeAttachment); - const message = { + const message = getDefaultMessage({ body: 'hey there!', quote: { text: 'hey!', - }, - }; - const expected = { + id: 34233, + isViewOnce: false, + messageId: 'message-id', + referencedMessageNotFound: false, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + } as any, + }); + const expected = getDefaultMessage({ body: 'hey there!', quote: { text: 'hey!', attachments: [], + id: 34233, + isViewOnce: false, + messageId: 'message-id', + referencedMessageNotFound: false, }, - }; - const result = await upgradeVersion(message, { logger }); + }); + const result = await upgradeVersion( + message, + getDefaultContext({ logger }) + ); assert.deepEqual(result, expected); }); @@ -565,14 +673,21 @@ describe('Message', () => { .throws(new Error("Shouldn't be called")); const upgradeVersion = Message._mapQuotedAttachments(upgradeAttachment); - const message = { + const message = getDefaultMessage({ body: 'hey there!', quote: { text: 'hey!', attachments: [], + id: 34233, + isViewOnce: false, + messageId: 'message-id', + referencedMessageNotFound: false, }, - }; - const result = await upgradeVersion(message, { logger }); + }); + const result = await upgradeVersion( + message, + getDefaultContext({ logger }) + ); assert.deepEqual(result, message); }); @@ -582,7 +697,7 @@ describe('Message', () => { .throws(new Error("Shouldn't be called")); const upgradeVersion = Message._mapQuotedAttachments(upgradeAttachment); - const message = { + const message = getDefaultMessage({ body: 'hey there!', quote: { text: 'hey!', @@ -592,9 +707,16 @@ describe('Message', () => { contentType: 'text/plain', }, ], + id: 34233, + isViewOnce: false, + messageId: 'message-id', + referencedMessageNotFound: false, }, - }; - const result = await upgradeVersion(message, { logger }); + }); + const result = await upgradeVersion( + message, + getDefaultContext({ logger }) + ); assert.deepEqual(result, message); }); @@ -604,7 +726,7 @@ describe('Message', () => { .returns({ fileName: 'processed!' }); const upgradeVersion = Message._mapQuotedAttachments(upgradeAttachment); - const message = { + const message = getDefaultMessage({ body: 'hey there!', quote: { text: 'hey!', @@ -617,9 +739,13 @@ describe('Message', () => { }, }, ], + id: 34233, + isViewOnce: false, + messageId: 'message-id', + referencedMessageNotFound: false, }, - }; - const expected = { + }); + const expected = getDefaultMessage({ body: 'hey there!', quote: { text: 'hey!', @@ -632,9 +758,16 @@ describe('Message', () => { }, }, ], + id: 34233, + isViewOnce: false, + messageId: 'message-id', + referencedMessageNotFound: false, }, - }; - const result = await upgradeVersion(message, { logger }); + }); + const result = await upgradeVersion( + message, + getDefaultContext({ logger }) + ); assert.deepEqual(result, expected); }); @@ -644,7 +777,7 @@ describe('Message', () => { }); const upgradeVersion = Message._mapQuotedAttachments(upgradeAttachment); - const message = { + const message = getDefaultMessage({ body: 'hey there!', quote: { text: 'hey!', @@ -655,9 +788,13 @@ describe('Message', () => { }, }, ], + id: 34233, + isViewOnce: false, + messageId: 'message-id', + referencedMessageNotFound: false, }, - }; - const expected = { + }); + const expected = getDefaultMessage({ body: 'hey there!', quote: { text: 'hey!', @@ -668,9 +805,16 @@ describe('Message', () => { }, }, ], + id: 34233, + isViewOnce: false, + messageId: 'message-id', + referencedMessageNotFound: false, }, - }; - const result = await upgradeVersion(message, { logger }); + }); + const result = await upgradeVersion( + message, + getDefaultContext({ logger }) + ); assert.deepEqual(result, expected); }); }); @@ -682,22 +826,23 @@ describe('Message', () => { .throws(new Error("Shouldn't be called")); const upgradeVersion = Message._mapContact(upgradeContact); - const message = { + const message = getDefaultMessage({ body: 'hey there!', - }; - const expected = { + }); + const expected = getDefaultMessage({ body: 'hey there!', contact: [], - }; - const result = await upgradeVersion(message); + }); + const result = await upgradeVersion(message, getDefaultContext()); assert.deepEqual(result, expected); }); it('handles one contact', async () => { - const upgradeContact = contact => Promise.resolve(contact); + const upgradeContact = (contact: EmbeddedContactType) => + Promise.resolve(contact); const upgradeVersion = Message._mapContact(upgradeContact); - const message = { + const message = getDefaultMessage({ body: 'hey there!', contact: [ { @@ -706,8 +851,8 @@ describe('Message', () => { }, }, ], - }; - const expected = { + }); + const expected = getDefaultMessage({ body: 'hey there!', contact: [ { @@ -716,8 +861,8 @@ describe('Message', () => { }, }, ], - }; - const result = await upgradeVersion(message); + }); + const result = await upgradeVersion(message, getDefaultContext()); assert.deepEqual(result, expected); }); }); diff --git a/ts/test-node/types/message/initializeAttachmentMetadata_test.ts b/ts/test-node/types/message/initializeAttachmentMetadata_test.ts index bb6c39c6f..a22d95330 100644 --- a/ts/test-node/types/message/initializeAttachmentMetadata_test.ts +++ b/ts/test-node/types/message/initializeAttachmentMetadata_test.ts @@ -4,15 +4,29 @@ import { assert } from 'chai'; import * as Message from '../../../types/message/initializeAttachmentMetadata'; -import type { IncomingMessage } from '../../../types/Message'; import { SignalService } from '../../../protobuf'; import * as MIME from '../../../types/MIME'; import * as Bytes from '../../../Bytes'; +import type { MessageAttributesType } from '../../../model-types.d'; + +function getDefaultMessage( + props?: Partial +): MessageAttributesType { + return { + id: 'some-id', + type: 'incoming', + sent_at: 45, + received_at: 45, + timestamp: 45, + conversationId: 'some-conversation-id', + ...props, + }; +} describe('Message', () => { describe('initializeAttachmentMetadata', () => { it('should classify visual media attachments', async () => { - const input: IncomingMessage = { + const input = getDefaultMessage({ type: 'incoming', conversationId: 'foo', id: '11111111-1111-1111-1111-111111111111', @@ -27,8 +41,8 @@ describe('Message', () => { size: 1111, }, ], - }; - const expected: IncomingMessage = { + }); + const expected = getDefaultMessage({ type: 'incoming', conversationId: 'foo', id: '11111111-1111-1111-1111-111111111111', @@ -46,14 +60,14 @@ describe('Message', () => { hasAttachments: 1, hasVisualMediaAttachments: 1, hasFileAttachments: undefined, - }; + }); const actual = await Message.initializeAttachmentMetadata(input); assert.deepEqual(actual, expected); }); it('should classify file attachments', async () => { - const input: IncomingMessage = { + const input = getDefaultMessage({ type: 'incoming', conversationId: 'foo', id: '11111111-1111-1111-1111-111111111111', @@ -68,8 +82,8 @@ describe('Message', () => { size: 1111, }, ], - }; - const expected: IncomingMessage = { + }); + const expected = getDefaultMessage({ type: 'incoming', conversationId: 'foo', id: '11111111-1111-1111-1111-111111111111', @@ -87,14 +101,14 @@ describe('Message', () => { hasAttachments: 1, hasVisualMediaAttachments: undefined, hasFileAttachments: 1, - }; + }); const actual = await Message.initializeAttachmentMetadata(input); assert.deepEqual(actual, expected); }); it('should classify voice message attachments', async () => { - const input: IncomingMessage = { + const input = getDefaultMessage({ type: 'incoming', conversationId: 'foo', id: '11111111-1111-1111-1111-111111111111', @@ -110,8 +124,8 @@ describe('Message', () => { size: 1111, }, ], - }; - const expected: IncomingMessage = { + }); + const expected = getDefaultMessage({ type: 'incoming', conversationId: 'foo', id: '11111111-1111-1111-1111-111111111111', @@ -130,14 +144,14 @@ describe('Message', () => { hasAttachments: 1, hasVisualMediaAttachments: undefined, hasFileAttachments: undefined, - }; + }); const actual = await Message.initializeAttachmentMetadata(input); assert.deepEqual(actual, expected); }); it('does not include long message attachments', async () => { - const input: IncomingMessage = { + const input = getDefaultMessage({ type: 'incoming', conversationId: 'foo', id: '11111111-1111-1111-1111-111111111111', @@ -152,8 +166,8 @@ describe('Message', () => { size: 1111, }, ], - }; - const expected: IncomingMessage = { + }); + const expected = getDefaultMessage({ type: 'incoming', conversationId: 'foo', id: '11111111-1111-1111-1111-111111111111', @@ -171,14 +185,14 @@ describe('Message', () => { hasAttachments: 0, hasVisualMediaAttachments: undefined, hasFileAttachments: undefined, - }; + }); const actual = await Message.initializeAttachmentMetadata(input); assert.deepEqual(actual, expected); }); it('handles not attachments', async () => { - const input: IncomingMessage = { + const input = getDefaultMessage({ type: 'incoming', conversationId: 'foo', id: '11111111-1111-1111-1111-111111111111', @@ -186,8 +200,8 @@ describe('Message', () => { received_at: 1523317140899, sent_at: 1523317140800, attachments: [], - }; - const expected: IncomingMessage = { + }); + const expected = getDefaultMessage({ type: 'incoming', conversationId: 'foo', id: '11111111-1111-1111-1111-111111111111', @@ -198,7 +212,7 @@ describe('Message', () => { hasAttachments: 0, hasVisualMediaAttachments: undefined, hasFileAttachments: undefined, - }; + }); const actual = await Message.initializeAttachmentMetadata(input); assert.deepEqual(actual, expected); diff --git a/ts/types/Attachment.ts b/ts/types/Attachment.ts index a7222ab18..44833057b 100644 --- a/ts/types/Attachment.ts +++ b/ts/types/Attachment.ts @@ -24,8 +24,8 @@ import { } from '../util/GoogleChrome'; import type { LocalizerType } from './Util'; import { ThemeType } from './Util'; -import * as GoogleChrome from '../util/GoogleChrome'; import { scaleImageToLevel } from '../util/scaleImageToLevel'; +import * as GoogleChrome from '../util/GoogleChrome'; import { parseIntOrThrow } from '../util/parseIntOrThrow'; import { getValue } from '../RemoteConfig'; @@ -58,6 +58,7 @@ export type AttachmentType = { url?: string; contentType: MIME.MIMEType; path: string; + data?: Uint8Array; }; screenshotData?: Uint8Array; screenshotPath?: string; @@ -74,6 +75,9 @@ export type AttachmentType = { /** Legacy field. Used only for downloading old attachments */ id?: number; + /** Legacy field, used long ago for migrating attachments to disk. */ + schemaVersion?: number; + /** Removed once we download the attachment */ digest?: string; key?: string; @@ -159,11 +163,12 @@ export type AttachmentDraftType = }; export type ThumbnailType = { - height: number; - width: number; + height?: number; + width?: number; url?: string; contentType: MIME.MIMEType; - path: string; + path?: string; + data?: Uint8Array; // Only used when quote needed to make an in-memory thumbnail objectUrl?: string; }; @@ -432,16 +437,19 @@ export async function captureDimensionsAndScreenshot( attachment: AttachmentType, params: { writeNewAttachmentData: (data: Uint8Array) => Promise; - getAbsoluteAttachmentPath: (path: string) => Promise; + getAbsoluteAttachmentPath: (path: string) => string; makeObjectUrl: ( data: Uint8Array | ArrayBuffer, contentType: MIME.MIMEType ) => string; revokeObjectUrl: (path: string) => void; - getImageDimensions: (params: { objectUrl: string; logger: LoggerType }) => { + getImageDimensions: (params: { + objectUrl: string; + logger: LoggerType; + }) => Promise<{ width: number; height: number; - }; + }>; makeImageThumbnail: (params: { size: number; objectUrl: string; @@ -481,7 +489,7 @@ export async function captureDimensionsAndScreenshot( return attachment; } - const absolutePath = await getAbsoluteAttachmentPath(attachment.path); + const absolutePath = getAbsoluteAttachmentPath(attachment.path); if (GoogleChrome.isImageTypeSupported(contentType)) { try { diff --git a/ts/types/EmbeddedContact.ts b/ts/types/EmbeddedContact.ts index 7e6da1657..e794e9ecd 100644 --- a/ts/types/EmbeddedContact.ts +++ b/ts/types/EmbeddedContact.ts @@ -191,12 +191,12 @@ export function parseAndWriteAvatar( contact: EmbeddedContactType, context: { message: MessageAttributesType; - regionCode: string; + getRegionCode: () => string | undefined; logger: Pick; writeNewAttachmentData: (data: Uint8Array) => Promise; } ): Promise => { - const { message, regionCode, logger } = context; + const { message, getRegionCode, logger } = context; const { avatar } = contact; const contactWithUpdatedAvatar = @@ -212,7 +212,7 @@ export function parseAndWriteAvatar( // eliminates empty numbers, emails, and addresses; adds type if not provided const parsedContact = parseContact(contactWithUpdatedAvatar, { - regionCode, + regionCode: getRegionCode(), }); const error = _validate(parsedContact, { @@ -231,7 +231,7 @@ export function parseAndWriteAvatar( function parseContact( contact: EmbeddedContactType, - { regionCode }: { regionCode: string } + { regionCode }: { regionCode: string | undefined } ): EmbeddedContactType { const boundParsePhone = (phoneNumber: Phone): Phone | undefined => parsePhoneItem(phoneNumber, { regionCode }); @@ -294,7 +294,7 @@ export function _validate( function parsePhoneItem( item: Phone, - { regionCode }: { regionCode: string } + { regionCode }: { regionCode: string | undefined } ): Phone | undefined { if (!item.value) { return undefined; diff --git a/ts/types/Message.ts b/ts/types/Message.ts index 2a2f48a6f..613ac44f7 100644 --- a/ts/types/Message.ts +++ b/ts/types/Message.ts @@ -75,13 +75,13 @@ export type ProfileChangeNotificationMessage = Readonly< ExpirationTimerUpdate >; -type SharedMessageProperties = Readonly<{ +export type SharedMessageProperties = Readonly<{ conversationId: string; sent_at: number; timestamp: number; }>; -type ExpirationTimerUpdate = Partial< +export type ExpirationTimerUpdate = Partial< Readonly<{ expirationTimerUpdate: Readonly<{ expireTimer: number; @@ -91,7 +91,7 @@ type ExpirationTimerUpdate = Partial< }> >; -type MessageSchemaVersion5 = Partial< +export type MessageSchemaVersion5 = Partial< Readonly<{ hasAttachments: IndexableBoolean; hasVisualMediaAttachments: IndexablePresence; @@ -99,7 +99,7 @@ type MessageSchemaVersion5 = Partial< }> >; -type MessageSchemaVersion6 = Partial< +export type MessageSchemaVersion6 = Partial< Readonly<{ contact: Array; }> diff --git a/js/modules/types/message.js b/ts/types/Message2.ts similarity index 62% rename from js/modules/types/message.js rename to ts/types/Message2.ts index 5bb5dd783..5ec090ecf 100644 --- a/js/modules/types/message.js +++ b/ts/types/Message2.ts @@ -1,19 +1,80 @@ // Copyright 2018-2021 Signal Messenger, LLC // SPDX-License-Identifier: AGPL-3.0-only -const { isFunction, isObject, isString, omit } = require('lodash'); +import { isFunction, isObject, isString, omit } from 'lodash'; -const Contact = require('../../../ts/types/EmbeddedContact'); -const Attachment = require('../../../ts/types/Attachment'); -const Errors = require('../../../ts/types/errors'); -const SchemaVersion = require('../../../ts/types/SchemaVersion'); -const { - initializeAttachmentMetadata, -} = require('../../../ts/types/message/initializeAttachmentMetadata'); -const MessageTS = require('../../../ts/types/Message'); +import * as Contact from './EmbeddedContact'; +import type { AttachmentType } from './Attachment'; +import { + autoOrientJPEG, + captureDimensionsAndScreenshot, + hasData, + migrateDataToFileSystem, + removeSchemaVersion, + replaceUnicodeOrderOverrides, + replaceUnicodeV2, +} from './Attachment'; +import * as Errors from './errors'; +import * as SchemaVersion from './SchemaVersion'; +import { initializeAttachmentMetadata } from './message/initializeAttachmentMetadata'; -const GROUP = 'group'; -const PRIVATE = 'private'; +import type * as MIME from './MIME'; +import type { LoggerType } from './Logging'; +import type { EmbeddedContactType } from './EmbeddedContact'; + +import type { + MessageAttributesType, + PreviewMessageType, + PreviewType, + QuotedMessageType, + StickerMessageType, +} from '../model-types.d'; + +export { hasExpiration } from './Message'; + +export const GROUP = 'group'; +export const PRIVATE = 'private'; + +export type ContextType = { + getAbsoluteAttachmentPath: (path: string) => string; + getAbsoluteStickerPath: (path: string) => string; + getImageDimensions: (params: { + objectUrl: string; + logger: LoggerType; + }) => Promise<{ + width: number; + height: number; + }>; + getRegionCode: () => string; + logger: LoggerType; + makeImageThumbnail: (params: { + size: number; + objectUrl: string; + contentType: MIME.MIMEType; + logger: LoggerType; + }) => Promise; + makeObjectUrl: ( + data: Uint8Array | ArrayBuffer, + contentType: MIME.MIMEType + ) => string; + makeVideoScreenshot: (params: { + objectUrl: string; + contentType: MIME.MIMEType; + logger: LoggerType; + }) => Promise; + maxVersion?: number; + revokeObjectUrl: (objectUrl: string) => void; + writeNewAttachmentData: (data: Uint8Array) => Promise; + writeNewStickerData: (sticker: StickerMessageType) => Promise; +}; + +type WriteExistingAttachmentDataType = ( + attachment: Pick +) => Promise; + +export type ContextWithMessageType = ContextType & { + message: MessageAttributesType; +}; // Schema version history // @@ -55,32 +116,30 @@ const PRIVATE = 'private'; const INITIAL_SCHEMA_VERSION = 0; -// Public API -exports.GROUP = GROUP; -exports.PRIVATE = PRIVATE; - // Placeholder until we have stronger preconditions: -exports.isValid = () => true; +export const isValid = (_message: MessageAttributesType): boolean => true; // Schema -exports.initializeSchemaVersion = ({ message, logger }) => { +export const initializeSchemaVersion = ({ + message, + logger, +}: { + message: MessageAttributesType; + logger: LoggerType; +}): MessageAttributesType => { const isInitialized = SchemaVersion.isValid(message.schemaVersion) && message.schemaVersion >= 1; if (isInitialized) { return message; } - const numAttachments = Array.isArray(message.attachments) - ? message.attachments.length - : 0; - const hasAttachments = numAttachments > 0; - if (!hasAttachments) { + const firstAttachment = message?.attachments?.[0]; + if (!firstAttachment) { return { ...message, schemaVersion: INITIAL_SCHEMA_VERSION }; } // All attachments should have the same schema version, so we just pick // the first one: - const firstAttachment = message.attachments[0]; const inheritedSchemaVersion = SchemaVersion.isValid( firstAttachment.schemaVersion ) @@ -89,9 +148,10 @@ exports.initializeSchemaVersion = ({ message, logger }) => { const messageWithInitialSchema = { ...message, schemaVersion: inheritedSchemaVersion, - attachments: message.attachments.map(attachment => - Attachment.removeSchemaVersion({ attachment, logger }) - ), + attachments: + message?.attachments?.map(attachment => + removeSchemaVersion({ attachment, logger }) + ) || [], }; return messageWithInitialSchema; @@ -101,7 +161,19 @@ exports.initializeSchemaVersion = ({ message, logger }) => { // type UpgradeStep = (Message, Context) -> Promise Message // SchemaVersion -> UpgradeStep -> UpgradeStep -exports._withSchemaVersion = ({ schemaVersion, upgrade }) => { +export const _withSchemaVersion = ({ + schemaVersion, + upgrade, +}: { + schemaVersion: number; + upgrade: ( + message: MessageAttributesType, + context: ContextType + ) => Promise; +}): (( + message: MessageAttributesType, + context: ContextType +) => Promise) => { if (!SchemaVersion.isValid(schemaVersion)) { throw new TypeError('_withSchemaVersion: schemaVersion is invalid'); } @@ -109,7 +181,7 @@ exports._withSchemaVersion = ({ schemaVersion, upgrade }) => { throw new TypeError('_withSchemaVersion: upgrade must be a function'); } - return async (message, context) => { + return async (message: MessageAttributesType, context: ContextType) => { if (!context || !isObject(context.logger)) { throw new TypeError( '_withSchemaVersion: context must have logger object' @@ -117,7 +189,7 @@ exports._withSchemaVersion = ({ schemaVersion, upgrade }) => { } const { logger } = context; - if (!exports.isValid(message)) { + if (!isValid(message)) { logger.error( 'Message._withSchemaVersion: Invalid input message:', message @@ -125,7 +197,7 @@ exports._withSchemaVersion = ({ schemaVersion, upgrade }) => { return message; } - const isAlreadyUpgraded = message.schemaVersion >= schemaVersion; + const isAlreadyUpgraded = (message.schemaVersion || 0) >= schemaVersion; if (isAlreadyUpgraded) { return message; } @@ -152,7 +224,7 @@ exports._withSchemaVersion = ({ schemaVersion, upgrade }) => { return message; } - if (!exports.isValid(upgradedMessage)) { + if (!isValid(upgradedMessage)) { logger.error( 'Message._withSchemaVersion: Invalid upgraded message:', upgradedMessage @@ -168,34 +240,59 @@ exports._withSchemaVersion = ({ schemaVersion, upgrade }) => { // _mapAttachments :: (Attachment -> Promise Attachment) -> // (Message, Context) -> // Promise Message -exports._mapAttachments = upgradeAttachment => async (message, context) => { - const upgradeWithContext = attachment => - upgradeAttachment(attachment, context, message); - const attachments = await Promise.all( - (message.attachments || []).map(upgradeWithContext) - ); - return { ...message, attachments }; -}; +export type UpgradeAttachmentType = ( + attachment: AttachmentType, + context: ContextType, + message: MessageAttributesType +) => Promise; + +export const _mapAttachments = + (upgradeAttachment: UpgradeAttachmentType) => + async ( + message: MessageAttributesType, + context: ContextType + ): Promise => { + const upgradeWithContext = (attachment: AttachmentType) => + upgradeAttachment(attachment, context, message); + const attachments = await Promise.all( + (message.attachments || []).map(upgradeWithContext) + ); + return { ...message, attachments }; + }; // Public API // _mapContact :: (Contact -> Promise Contact) -> // (Message, Context) -> // Promise Message -exports._mapContact = upgradeContact => async (message, context) => { - const contextWithMessage = { ...context, message }; - const upgradeWithContext = contact => - upgradeContact(contact, contextWithMessage); - const contact = await Promise.all( - (message.contact || []).map(upgradeWithContext) - ); - return { ...message, contact }; -}; + +export type UpgradeContactType = ( + contact: EmbeddedContactType, + contextWithMessage: ContextWithMessageType +) => Promise; +export const _mapContact = + (upgradeContact: UpgradeContactType) => + async ( + message: MessageAttributesType, + context: ContextType + ): Promise => { + const contextWithMessage = { ...context, message }; + const upgradeWithContext = (contact: EmbeddedContactType) => + upgradeContact(contact, contextWithMessage); + const contact = await Promise.all( + (message.contact || []).map(upgradeWithContext) + ); + return { ...message, contact }; + }; // _mapQuotedAttachments :: (QuotedAttachment -> Promise QuotedAttachment) -> // (Message, Context) -> // Promise Message -exports._mapQuotedAttachments = - upgradeAttachment => async (message, context) => { +export const _mapQuotedAttachments = + (upgradeAttachment: UpgradeAttachmentType) => + async ( + message: MessageAttributesType, + context: ContextType + ): Promise => { if (!message.quote) { return message; } @@ -203,13 +300,19 @@ exports._mapQuotedAttachments = throw new Error('_mapQuotedAttachments: context must have logger object'); } - const upgradeWithContext = async attachment => { + const upgradeWithContext = async ( + attachment: AttachmentType + ): Promise => { const { thumbnail } = attachment; if (!thumbnail) { return attachment; } - const upgradedThumbnail = await upgradeAttachment(thumbnail, context); + const upgradedThumbnail = await upgradeAttachment( + thumbnail as AttachmentType, + context, + message + ); return { ...attachment, thumbnail: upgradedThumbnail }; }; @@ -225,8 +328,12 @@ exports._mapQuotedAttachments = // _mapPreviewAttachments :: (PreviewAttachment -> Promise PreviewAttachment) -> // (Message, Context) -> // Promise Message -exports._mapPreviewAttachments = - upgradeAttachment => async (message, context) => { +export const _mapPreviewAttachments = + (upgradeAttachment: UpgradeAttachmentType) => + async ( + message: MessageAttributesType, + context: ContextType + ): Promise => { if (!message.preview) { return message; } @@ -236,13 +343,13 @@ exports._mapPreviewAttachments = ); } - const upgradeWithContext = async preview => { + const upgradeWithContext = async (preview: PreviewType) => { const { image } = preview; if (!image) { return preview; } - const upgradedImage = await upgradeAttachment(image, context); + const upgradedImage = await upgradeAttachment(image, context, message); return { ...preview, image: upgradedImage }; }; @@ -252,58 +359,59 @@ exports._mapPreviewAttachments = return { ...message, preview }; }; -const toVersion0 = async (message, context) => - exports.initializeSchemaVersion({ message, logger: context.logger }); -const toVersion1 = exports._withSchemaVersion({ +const toVersion0 = async ( + message: MessageAttributesType, + context: ContextType +) => initializeSchemaVersion({ message, logger: context.logger }); +const toVersion1 = _withSchemaVersion({ schemaVersion: 1, - upgrade: exports._mapAttachments(Attachment.autoOrientJPEG), + upgrade: _mapAttachments(autoOrientJPEG), }); -const toVersion2 = exports._withSchemaVersion({ +const toVersion2 = _withSchemaVersion({ schemaVersion: 2, - upgrade: exports._mapAttachments(Attachment.replaceUnicodeOrderOverrides), + upgrade: _mapAttachments(replaceUnicodeOrderOverrides), }); -const toVersion3 = exports._withSchemaVersion({ +const toVersion3 = _withSchemaVersion({ schemaVersion: 3, - upgrade: exports._mapAttachments(Attachment.migrateDataToFileSystem), + upgrade: _mapAttachments(migrateDataToFileSystem), }); -const toVersion4 = exports._withSchemaVersion({ +const toVersion4 = _withSchemaVersion({ schemaVersion: 4, - upgrade: exports._mapQuotedAttachments(Attachment.migrateDataToFileSystem), + upgrade: _mapQuotedAttachments(migrateDataToFileSystem), }); -const toVersion5 = exports._withSchemaVersion({ +const toVersion5 = _withSchemaVersion({ schemaVersion: 5, upgrade: initializeAttachmentMetadata, }); -const toVersion6 = exports._withSchemaVersion({ +const toVersion6 = _withSchemaVersion({ schemaVersion: 6, - upgrade: exports._mapContact( - Contact.parseAndWriteAvatar(Attachment.migrateDataToFileSystem) - ), + upgrade: _mapContact(Contact.parseAndWriteAvatar(migrateDataToFileSystem)), }); // IMPORTANT: We’ve updated our definition of `initializeAttachmentMetadata`, so // we need to run it again on existing items that have previously been incorrectly // classified: -const toVersion7 = exports._withSchemaVersion({ +const toVersion7 = _withSchemaVersion({ schemaVersion: 7, upgrade: initializeAttachmentMetadata, }); -const toVersion8 = exports._withSchemaVersion({ +const toVersion8 = _withSchemaVersion({ schemaVersion: 8, - upgrade: exports._mapAttachments(Attachment.captureDimensionsAndScreenshot), + upgrade: _mapAttachments(captureDimensionsAndScreenshot), }); -const toVersion9 = exports._withSchemaVersion({ +const toVersion9 = _withSchemaVersion({ schemaVersion: 9, - upgrade: exports._mapAttachments(Attachment.replaceUnicodeV2), + upgrade: _mapAttachments(replaceUnicodeV2), }); -const toVersion10 = exports._withSchemaVersion({ +const toVersion10 = _withSchemaVersion({ schemaVersion: 10, upgrade: async (message, context) => { - const processPreviews = exports._mapPreviewAttachments( - Attachment.migrateDataToFileSystem - ); - const processSticker = async (stickerMessage, stickerContext) => { + const processPreviews = _mapPreviewAttachments(migrateDataToFileSystem); + const processSticker = async ( + stickerMessage: MessageAttributesType, + stickerContext: ContextType + ): Promise => { const { sticker } = stickerMessage; if (!sticker || !sticker.data || !sticker.data.data) { return stickerMessage; @@ -313,10 +421,7 @@ const toVersion10 = exports._withSchemaVersion({ ...stickerMessage, sticker: { ...sticker, - data: await Attachment.migrateDataToFileSystem( - sticker.data, - stickerContext - ), + data: await migrateDataToFileSystem(sticker.data, stickerContext), }, }; }; @@ -341,27 +446,29 @@ const VERSIONS = [ toVersion9, toVersion10, ]; -exports.CURRENT_SCHEMA_VERSION = VERSIONS.length - 1; +export const CURRENT_SCHEMA_VERSION = VERSIONS.length - 1; // We need dimensions and screenshots for images for proper display -exports.VERSION_NEEDED_FOR_DISPLAY = 9; +export const VERSION_NEEDED_FOR_DISPLAY = 9; // UpgradeStep -exports.upgradeSchema = async ( - rawMessage, +export const upgradeSchema = async ( + rawMessage: MessageAttributesType, { writeNewAttachmentData, getRegionCode, getAbsoluteAttachmentPath, + getAbsoluteStickerPath, makeObjectUrl, revokeObjectUrl, getImageDimensions, makeImageThumbnail, makeVideoScreenshot, + writeNewStickerData, logger, - maxVersion = exports.CURRENT_SCHEMA_VERSION, - } = {} -) => { + maxVersion = CURRENT_SCHEMA_VERSION, + }: ContextType +): Promise => { if (!isFunction(writeNewAttachmentData)) { throw new TypeError('context.writeNewAttachmentData is required'); } @@ -389,6 +496,12 @@ exports.upgradeSchema = async ( if (!isObject(logger)) { throw new TypeError('context.logger is required'); } + if (!isFunction(getAbsoluteStickerPath)) { + throw new TypeError('context.getAbsoluteStickerPath is required'); + } + if (!isFunction(writeNewStickerData)) { + throw new TypeError('context.writeNewStickerData is required'); + } let message = rawMessage; for (let index = 0, max = VERSIONS.length; index < max; index += 1) { @@ -402,7 +515,6 @@ exports.upgradeSchema = async ( // eslint-disable-next-line no-await-in-loop message = await currentVersion(message, { writeNewAttachmentData, - regionCode: getRegionCode(), getAbsoluteAttachmentPath, makeObjectUrl, revokeObjectUrl, @@ -410,6 +522,9 @@ exports.upgradeSchema = async ( makeImageThumbnail, makeVideoScreenshot, logger, + getAbsoluteStickerPath, + getRegionCode, + writeNewStickerData, }); } @@ -418,8 +533,8 @@ exports.upgradeSchema = async ( // Runs on attachments outside of the schema upgrade process, since attachments are // downloaded out of band. -exports.processNewAttachment = async ( - attachment, +export const processNewAttachment = async ( + attachment: AttachmentType, { writeNewAttachmentData, getAbsoluteAttachmentPath, @@ -429,8 +544,8 @@ exports.processNewAttachment = async ( makeImageThumbnail, makeVideoScreenshot, logger, - } = {} -) => { + }: ContextType +): Promise => { if (!isFunction(writeNewAttachmentData)) { throw new TypeError('context.writeNewAttachmentData is required'); } @@ -456,16 +571,13 @@ exports.processNewAttachment = async ( throw new TypeError('context.logger is required'); } - const rotatedAttachment = await Attachment.autoOrientJPEG( - attachment, - undefined, - { isIncoming: true } - ); - const onDiskAttachment = await Attachment.migrateDataToFileSystem( - rotatedAttachment, - { writeNewAttachmentData } - ); - const finalAttachment = await Attachment.captureDimensionsAndScreenshot( + const rotatedAttachment = await autoOrientJPEG(attachment, undefined, { + isIncoming: true, + }); + const onDiskAttachment = await migrateDataToFileSystem(rotatedAttachment, { + writeNewAttachmentData, + }); + const finalAttachment = await captureDimensionsAndScreenshot( onDiskAttachment, { writeNewAttachmentData, @@ -482,15 +594,15 @@ exports.processNewAttachment = async ( return finalAttachment; }; -exports.processNewSticker = async ( - stickerData, +export const processNewSticker = async ( + stickerData: StickerMessageType, { writeNewStickerData, getAbsoluteStickerPath, getImageDimensions, logger, - } = {} -) => { + }: ContextType +): Promise<{ path: string; width: number; height: number }> => { if (!isFunction(writeNewStickerData)) { throw new TypeError('context.writeNewStickerData is required'); } @@ -519,25 +631,41 @@ exports.processNewSticker = async ( }; }; -exports.createAttachmentLoader = loadAttachmentData => { +type LoadAttachmentType = ( + attachment: AttachmentType +) => Promise; + +export const createAttachmentLoader = ( + loadAttachmentData: LoadAttachmentType +): ((message: MessageAttributesType) => Promise) => { if (!isFunction(loadAttachmentData)) { throw new TypeError( 'createAttachmentLoader: loadAttachmentData is required' ); } - return async message => ({ + return async ( + message: MessageAttributesType + ): Promise => ({ ...message, - attachments: await Promise.all(message.attachments.map(loadAttachmentData)), + attachments: await Promise.all( + (message.attachments || []).map(loadAttachmentData) + ), }); }; -exports.loadQuoteData = loadAttachmentData => { +export const loadQuoteData = ( + loadAttachmentData: LoadAttachmentType +): (( + quote: QuotedMessageType | undefined | null +) => Promise) => { if (!isFunction(loadAttachmentData)) { throw new TypeError('loadQuoteData: loadAttachmentData is required'); } - return async quote => { + return async ( + quote: QuotedMessageType | undefined | null + ): Promise => { if (!quote) { return null; } @@ -562,48 +690,58 @@ exports.loadQuoteData = loadAttachmentData => { }; }; -exports.loadContactData = loadAttachmentData => { +export const loadContactData = ( + loadAttachmentData: LoadAttachmentType +): (( + contact: Array | undefined +) => Promise | null>) => { if (!isFunction(loadAttachmentData)) { throw new TypeError('loadContactData: loadAttachmentData is required'); } - return async contact => { + return async ( + contact: Array | undefined + ): Promise | null> => { if (!contact) { return null; } return Promise.all( - contact.map(async item => { - if ( - !item || - !item.avatar || - !item.avatar.avatar || - !item.avatar.avatar.path - ) { - return item; - } + contact.map( + async (item: EmbeddedContactType): Promise => { + if ( + !item || + !item.avatar || + !item.avatar.avatar || + !item.avatar.avatar.path + ) { + return item; + } - return { - ...item, - avatar: { - ...item.avatar, + return { + ...item, avatar: { - ...item.avatar.avatar, - ...(await loadAttachmentData(item.avatar.avatar)), + ...item.avatar, + avatar: { + ...item.avatar.avatar, + ...(await loadAttachmentData(item.avatar.avatar)), + }, }, - }, - }; - }) + }; + } + ) ); }; }; -exports.loadPreviewData = loadAttachmentData => { +export const loadPreviewData = ( + loadAttachmentData: LoadAttachmentType +): ((preview: PreviewMessageType) => Promise) => { if (!isFunction(loadAttachmentData)) { throw new TypeError('loadPreviewData: loadAttachmentData is required'); } - return async preview => { + return async (preview: PreviewMessageType) => { if (!preview || !preview.length) { return []; } @@ -623,12 +761,14 @@ exports.loadPreviewData = loadAttachmentData => { }; }; -exports.loadStickerData = loadAttachmentData => { +export const loadStickerData = ( + loadAttachmentData: LoadAttachmentType +): ((sticker: StickerMessageType) => Promise) => { if (!isFunction(loadAttachmentData)) { throw new TypeError('loadStickerData: loadAttachmentData is required'); } - return async sticker => { + return async (sticker: StickerMessageType) => { if (!sticker || !sticker.data) { return null; } @@ -640,7 +780,13 @@ exports.loadStickerData = loadAttachmentData => { }; }; -exports.deleteAllExternalFiles = ({ deleteAttachmentData, deleteOnDisk }) => { +export const deleteAllExternalFiles = ({ + deleteAttachmentData, + deleteOnDisk, +}: { + deleteAttachmentData: (attachment: AttachmentType) => Promise; + deleteOnDisk: (path: string) => Promise; +}): ((message: MessageAttributesType) => Promise) => { if (!isFunction(deleteAttachmentData)) { throw new TypeError( 'deleteAllExternalFiles: deleteAttachmentData must be a function' @@ -653,7 +799,7 @@ exports.deleteAllExternalFiles = ({ deleteAttachmentData, deleteOnDisk }) => { ); } - return async message => { + return async (message: MessageAttributesType) => { const { attachments, quote, contact, preview, sticker } = message; if (attachments && attachments.length) { @@ -712,10 +858,13 @@ exports.deleteAllExternalFiles = ({ deleteAttachmentData, deleteOnDisk }) => { // createAttachmentDataWriter :: (RelativePath -> IO Unit) // Message -> // IO (Promise Message) -exports.createAttachmentDataWriter = ({ +export const createAttachmentDataWriter = ({ writeExistingAttachmentData, logger, -}) => { +}: { + writeExistingAttachmentData: WriteExistingAttachmentDataType; + logger: LoggerType; +}): ((message: MessageAttributesType) => Promise) => { if (!isFunction(writeExistingAttachmentData)) { throw new TypeError( 'createAttachmentDataWriter: writeExistingAttachmentData must be a function' @@ -725,12 +874,14 @@ exports.createAttachmentDataWriter = ({ throw new TypeError('createAttachmentDataWriter: logger must be an object'); } - return async rawMessage => { - if (!exports.isValid(rawMessage)) { + return async ( + rawMessage: MessageAttributesType + ): Promise => { + if (!isValid(rawMessage)) { throw new TypeError("'rawMessage' is not valid"); } - const message = exports.initializeSchemaVersion({ + const message = initializeSchemaVersion({ message: rawMessage, logger, }); @@ -748,13 +899,13 @@ exports.createAttachmentDataWriter = ({ const lastVersionWithAttachmentDataInMemory = 2; const willAttachmentsGoToFileSystemOnUpgrade = - message.schemaVersion <= lastVersionWithAttachmentDataInMemory; + (message.schemaVersion || 0) <= lastVersionWithAttachmentDataInMemory; if (willAttachmentsGoToFileSystemOnUpgrade) { return message; } (attachments || []).forEach(attachment => { - if (!Attachment.hasData(attachment)) { + if (!hasData(attachment)) { throw new TypeError( "'attachment.data' is required during message import" ); @@ -767,27 +918,41 @@ exports.createAttachmentDataWriter = ({ } }); - const writeThumbnails = exports._mapQuotedAttachments(async thumbnail => { + const writeQuoteAttachment = async (attachment: AttachmentType) => { + const { thumbnail } = attachment; + if (!thumbnail) { + return attachment; + } + const { data, path } = thumbnail; - // we want to be bulletproof to thumbnails without data + // we want to be bulletproof to attachments without data if (!data || !path) { logger.warn( - 'Thumbnail had neither data nor path.', + 'quote attachment had neither data nor path.', 'id:', message.id, 'source:', message.source ); - return thumbnail; + return attachment; } await writeExistingAttachmentData(thumbnail); - return omit(thumbnail, ['data']); - }); + return { + ...attachment, + thumbnail: omit(thumbnail, ['data']), + }; + }; - const writeContactAvatar = async messageContact => { + const writeContactAvatar = async ( + messageContact: EmbeddedContactType + ): Promise => { const { avatar } = messageContact; + if (!avatar) { + return messageContact; + } + if (avatar && !avatar.avatar) { return omit(messageContact, ['avatar']); } @@ -800,7 +965,9 @@ exports.createAttachmentDataWriter = ({ }; }; - const writePreviewImage = async item => { + const writePreviewImage = async ( + item: PreviewType + ): Promise => { const { image } = item; if (!image) { return omit(item, ['image']); @@ -812,7 +979,17 @@ exports.createAttachmentDataWriter = ({ }; const messageWithoutAttachmentData = { - ...(await writeThumbnails(message, { logger })), + ...message, + ...(quote + ? { + quote: { + ...quote, + attachments: await Promise.all( + (quote?.attachments || []).map(writeQuoteAttachment) + ), + }, + } + : undefined), contact: await Promise.all((contact || []).map(writeContactAvatar)), preview: await Promise.all((preview || []).map(writePreviewImage)), attachments: await Promise.all( @@ -842,5 +1019,3 @@ exports.createAttachmentDataWriter = ({ return messageWithoutAttachmentData; }; }; - -exports.hasExpiration = MessageTS.hasExpiration; diff --git a/ts/types/PhoneNumber.ts b/ts/types/PhoneNumber.ts index d176ef73e..d58c624c2 100644 --- a/ts/types/PhoneNumber.ts +++ b/ts/types/PhoneNumber.ts @@ -51,7 +51,7 @@ export const format = memoizee(_format, { export function parse( phoneNumber: string, options: { - regionCode: string; + regionCode: string | undefined; } ): string { const { regionCode } = options; diff --git a/ts/types/SchemaVersion.ts b/ts/types/SchemaVersion.ts index e771ccdde..96699ebbf 100644 --- a/ts/types/SchemaVersion.ts +++ b/ts/types/SchemaVersion.ts @@ -3,6 +3,6 @@ import { isNumber } from 'lodash'; -export const isValid = (value: unknown): boolean => { +export const isValid = (value: unknown): value is number => { return Boolean(isNumber(value) && value >= 0); }; diff --git a/ts/types/message/initializeAttachmentMetadata.ts b/ts/types/message/initializeAttachmentMetadata.ts index ac15b9d04..ab8fb711f 100644 --- a/ts/types/message/initializeAttachmentMetadata.ts +++ b/ts/types/message/initializeAttachmentMetadata.ts @@ -3,19 +3,20 @@ import * as Attachment from '../Attachment'; import * as IndexedDB from '../IndexedDB'; -import type { Message, UserMessage } from '../Message'; + +import type { MessageAttributesType } from '../../model-types.d'; const hasAttachment = (predicate: (value: Attachment.AttachmentType) => boolean) => - (message: UserMessage): IndexedDB.IndexablePresence => - IndexedDB.toIndexablePresence(message.attachments.some(predicate)); + (message: MessageAttributesType): IndexedDB.IndexablePresence => + IndexedDB.toIndexablePresence((message.attachments || []).some(predicate)); const hasFileAttachment = hasAttachment(Attachment.isFile); const hasVisualMediaAttachment = hasAttachment(Attachment.isVisualMedia); export const initializeAttachmentMetadata = async ( - message: Message -): Promise => { + message: MessageAttributesType +): Promise => { if (message.type === 'verified-change') { return message; } @@ -26,7 +27,7 @@ export const initializeAttachmentMetadata = async ( return message; } - const attachments = message.attachments.filter( + const attachments = (message.attachments || []).filter( (attachment: Attachment.AttachmentType) => attachment.contentType !== 'text/x-signal-plain' ); diff --git a/ts/views/conversation_view.tsx b/ts/views/conversation_view.tsx index 8bf61a814..505bd3ad8 100644 --- a/ts/views/conversation_view.tsx +++ b/ts/views/conversation_view.tsx @@ -1598,7 +1598,7 @@ export class ConversationView extends window.Backbone.View { return { path: attachment.path, objectURL: getAbsoluteAttachmentPath(attachment.path), - thumbnailObjectUrl: thumbnail + thumbnailObjectUrl: thumbnail?.path ? getAbsoluteAttachmentPath(thumbnail.path) : undefined, contentType: attachment.contentType, @@ -2566,7 +2566,7 @@ export class ConversationView extends window.Backbone.View { return { objectURL: getAbsoluteAttachmentPath(attachment.path || ''), - thumbnailObjectUrl: thumbnail + thumbnailObjectUrl: thumbnail?.path ? getAbsoluteAttachmentPath(thumbnail.path) : '', contentType: attachment.contentType,