From a85dd1be36527562f90c6bae1b01445499e12938 Mon Sep 17 00:00:00 2001 From: Evan Hahn <69474926+EvanHahn-Signal@users.noreply.github.com> Date: Tue, 31 Aug 2021 15:58:39 -0500 Subject: [PATCH] Retry outbound "normal" messages for up to a day --- _locales/en/messages.json | 14 + ts/background.ts | 1 + ts/components/App.tsx | 33 +- ts/components/Inbox.tsx | 51 +- .../conversation/MessageDetail.stories.tsx | 1 - ts/components/conversation/MessageDetail.tsx | 10 +- ts/jobs/JobQueue.ts | 17 +- ts/jobs/JobQueueDatabaseStore.ts | 16 +- ts/jobs/formatJobForInsert.ts | 19 + ts/jobs/initializeAllJobQueues.ts | 2 + ts/jobs/normalMessageSendJobQueue.ts | 521 ++++++++++++++++++ ts/messages/MessageSendState.ts | 2 + ts/messages/getMessagesById.ts | 46 ++ ts/models/conversations.ts | 348 +++++------- ts/models/messages.ts | 352 +++--------- ts/sql/Client.ts | 16 +- ts/sql/Interface.ts | 6 +- ts/sql/Server.ts | 80 ++- ts/state/ducks/conversations.ts | 146 ++++- ts/state/selectors/conversations.ts | 50 ++ ts/state/selectors/message.ts | 11 +- ts/state/smart/App.tsx | 22 +- ts/state/smart/MessageDetail.tsx | 2 - .../messages/MessageSendState_test.ts | 15 + .../state/selectors/conversations_test.ts | 97 ++++ .../state/ducks/conversations_test.ts | 30 + .../jobs/JobQueueDatabaseStore_test.ts | 26 + ts/test-node/jobs/formatJobForInsert_test.ts | 27 + ts/textsecure/SendMessage.ts | 2 +- ts/views/conversation_view.ts | 54 +- 30 files changed, 1414 insertions(+), 603 deletions(-) create mode 100644 ts/jobs/formatJobForInsert.ts create mode 100644 ts/jobs/normalMessageSendJobQueue.ts create mode 100644 ts/messages/getMessagesById.ts create mode 100644 ts/test-node/jobs/formatJobForInsert_test.ts diff --git a/_locales/en/messages.json b/_locales/en/messages.json index be5e9f1e7..165cc6eb6 100644 --- a/_locales/en/messages.json +++ b/_locales/en/messages.json @@ -415,6 +415,20 @@ "message": "The following people may have reinstalled or changed devices. Verify your safety number with them to ensure privacy.", "description": "Shown on confirmation dialog when user attempts to send a message" }, + "safetyNumberChangeDialog__pending-messages--1": { + "message": "Send pending message", + "description": "Shown on confirmation dialog when user attempts to send a message in the outbox" + }, + "safetyNumberChangeDialog__pending-messages--many": { + "message": "Send $count$ pending messages", + "description": "Shown on confirmation dialog when user attempts to send a message in the outbox", + "placeholders": { + "count": { + "content": "$1", + "example": 123 + } + } + }, "identityKeyErrorOnSend": { "message": "Your safety number with $name1$ has changed. This could either mean that someone is trying to intercept your communication or that $name2$ has simply reinstalled Signal. You may wish to verify your safety number with this contact.", "description": "Shown when user clicks on a failed recipient in the message detail view after an identity key change", diff --git a/ts/background.ts b/ts/background.ts index 54815f25f..06fc2a853 100644 --- a/ts/background.ts +++ b/ts/background.ts @@ -940,6 +940,7 @@ export async function startApp(): Promise { ), messagesByConversation: {}, messagesLookup: {}, + outboundMessagesPendingConversationVerification: {}, selectedConversationId: undefined, selectedMessage: undefined, selectedMessageCounter: 0, diff --git a/ts/components/App.tsx b/ts/components/App.tsx index e9f7e9c4a..304d9bebf 100644 --- a/ts/components/App.tsx +++ b/ts/components/App.tsx @@ -1,7 +1,7 @@ // Copyright 2021 Signal Messenger, LLC // SPDX-License-Identifier: AGPL-3.0-only -import React, { useEffect } from 'react'; +import React, { ComponentProps, useEffect } from 'react'; import classNames from 'classnames'; import { AppViewType } from '../state/ducks/app'; @@ -11,20 +11,25 @@ import { StandaloneRegistration } from './StandaloneRegistration'; import { ThemeType } from '../types/Util'; import { usePageVisibility } from '../util/hooks'; -export type PropsType = { +type PropsType = { appView: AppViewType; - hasInitialLoadCompleted: boolean; renderCallManager: () => JSX.Element; renderGlobalModalContainer: () => JSX.Element; theme: ThemeType; -}; +} & ComponentProps; export const App = ({ appView, + cancelMessagesPendingConversationVerification, + conversationsStoppingMessageSendBecauseOfVerification, hasInitialLoadCompleted, + i18n, + numberOfMessagesPendingBecauseOfVerification, renderCallManager, renderGlobalModalContainer, + renderSafetyNumber, theme, + verifyConversationsStoppingMessageSend, }: PropsType): JSX.Element => { let contents; @@ -33,7 +38,25 @@ export const App = ({ } else if (appView === AppViewType.Standalone) { contents = ; } else if (appView === AppViewType.Inbox) { - contents = ; + contents = ( + + ); } // This are here so that themes are properly applied to anything that is diff --git a/ts/components/Inbox.tsx b/ts/components/Inbox.tsx index 170eec957..42ca085d6 100644 --- a/ts/components/Inbox.tsx +++ b/ts/components/Inbox.tsx @@ -1,8 +1,14 @@ // Copyright 2021 Signal Messenger, LLC // SPDX-License-Identifier: AGPL-3.0-only -import React, { useEffect, useRef } from 'react'; +import React, { ReactNode, useEffect, useRef } from 'react'; import * as Backbone from 'backbone'; +import { + SafetyNumberChangeDialog, + SafetyNumberProps, +} from './SafetyNumberChangeDialog'; +import type { ConversationType } from '../state/ducks/conversations'; +import type { LocalizerType } from '../types/Util'; type InboxViewType = Backbone.View & { onEmpty?: () => void; @@ -14,10 +20,24 @@ type InboxViewOptionsType = Backbone.ViewOptions & { }; export type PropsType = { + cancelMessagesPendingConversationVerification: () => void; + conversationsStoppingMessageSendBecauseOfVerification: Array; hasInitialLoadCompleted: boolean; + i18n: LocalizerType; + numberOfMessagesPendingBecauseOfVerification: number; + renderSafetyNumber: (props: SafetyNumberProps) => JSX.Element; + verifyConversationsStoppingMessageSend: () => void; }; -export const Inbox = ({ hasInitialLoadCompleted }: PropsType): JSX.Element => { +export const Inbox = ({ + cancelMessagesPendingConversationVerification, + conversationsStoppingMessageSendBecauseOfVerification, + hasInitialLoadCompleted, + i18n, + numberOfMessagesPendingBecauseOfVerification, + renderSafetyNumber, + verifyConversationsStoppingMessageSend, +}: PropsType): JSX.Element => { const hostRef = useRef(null); const viewRef = useRef(undefined); @@ -47,5 +67,30 @@ export const Inbox = ({ hasInitialLoadCompleted }: PropsType): JSX.Element => { } }, [hasInitialLoadCompleted, viewRef]); - return
; + let safetyNumberChangeDialog: ReactNode; + if (conversationsStoppingMessageSendBecauseOfVerification.length) { + const confirmText: string = + numberOfMessagesPendingBecauseOfVerification === 1 + ? i18n('safetyNumberChangeDialog__pending-messages--1') + : i18n('safetyNumberChangeDialog__pending-messages--many', [ + numberOfMessagesPendingBecauseOfVerification.toString(), + ]); + safetyNumberChangeDialog = ( + + ); + } + + return ( + <> +
+ {safetyNumberChangeDialog} + + ); }; diff --git a/ts/components/conversation/MessageDetail.stories.tsx b/ts/components/conversation/MessageDetail.stories.tsx index d7537cec1..89c132899 100644 --- a/ts/components/conversation/MessageDetail.stories.tsx +++ b/ts/components/conversation/MessageDetail.stories.tsx @@ -61,7 +61,6 @@ const createProps = (overrideProps: Partial = {}): Props => ({ i18n, interactionMode: 'keyboard', - sendAnyway: action('onSendAnyway'), showSafetyNumber: action('onShowSafetyNumber'), checkForAccount: action('checkForAccount'), diff --git a/ts/components/conversation/MessageDetail.tsx b/ts/components/conversation/MessageDetail.tsx index 5cbcd7e42..6dd2b3505 100644 --- a/ts/components/conversation/MessageDetail.tsx +++ b/ts/components/conversation/MessageDetail.tsx @@ -53,7 +53,6 @@ export type PropsData = { receivedAt: number; sentAt: number; - sendAnyway: (contactId: string, messageId: string) => unknown; showSafetyNumber: (contactId: string) => void; i18n: LocalizerType; } & Pick; @@ -145,7 +144,7 @@ export class MessageDetail extends React.Component { } public renderContact(contact: Contact): JSX.Element { - const { i18n, message, showSafetyNumber, sendAnyway } = this.props; + const { i18n, showSafetyNumber } = this.props; const errors = contact.errors || []; const errorComponent = contact.isOutgoingKeyError ? ( @@ -157,13 +156,6 @@ export class MessageDetail extends React.Component { > {i18n('showSafetyNumber')} -
) : null; const unidentifiedDeliveryComponent = contact.isUnidentifiedDelivery ? ( diff --git a/ts/jobs/JobQueue.ts b/ts/jobs/JobQueue.ts index 2c67a508f..f9d2df67f 100644 --- a/ts/jobs/JobQueue.ts +++ b/ts/jobs/JobQueue.ts @@ -136,12 +136,23 @@ export abstract class JobQueue { * If `streamJobs` has not been called yet, this will throw an error. */ async add(data: Readonly): Promise> { + this.throwIfNotStarted(); + + const job = this.createJob(data); + await this.store.insert(job); + log.info(`${this.logPrefix} added new job ${job.id}`); + return job; + } + + protected throwIfNotStarted(): void { if (!this.started) { throw new Error( `${this.logPrefix} has not started streaming. Make sure to call streamJobs().` ); } + } + protected createJob(data: Readonly): Job { const id = uuid(); const timestamp = Date.now(); @@ -158,11 +169,7 @@ export abstract class JobQueue { } })(); - log.info(`${this.logPrefix} added new job ${id}`); - - const job = new Job(id, timestamp, this.queueType, data, completion); - await this.store.insert(job); - return job; + return new Job(id, timestamp, this.queueType, data, completion); } private async enqueueStoredJob(storedJob: Readonly) { diff --git a/ts/jobs/JobQueueDatabaseStore.ts b/ts/jobs/JobQueueDatabaseStore.ts index fe66fc161..37d718411 100644 --- a/ts/jobs/JobQueueDatabaseStore.ts +++ b/ts/jobs/JobQueueDatabaseStore.ts @@ -1,10 +1,11 @@ // Copyright 2021 Signal Messenger, LLC // SPDX-License-Identifier: AGPL-3.0-only -import { pick, noop } from 'lodash'; +import { noop } from 'lodash'; import { AsyncQueue } from '../util/AsyncQueue'; import { concat, wrapPromise } from '../util/asyncIterables'; import { JobQueueStore, StoredJob } from './types'; +import { formatJobForInsert } from './formatJobForInsert'; import databaseInterface from '../sql/Client'; import * as log from '../logging/log'; @@ -23,7 +24,12 @@ export class JobQueueDatabaseStore implements JobQueueStore { constructor(private readonly db: Database) {} - async insert(job: Readonly): Promise { + async insert( + job: Readonly, + { + shouldInsertIntoDatabase = true, + }: Readonly<{ shouldInsertIntoDatabase?: boolean }> = {} + ): Promise { log.info( `JobQueueDatabaseStore adding job ${job.id} to queue ${JSON.stringify( job.queueType @@ -40,9 +46,9 @@ export class JobQueueDatabaseStore implements JobQueueStore { } await initialFetchPromise; - await this.db.insertJob( - pick(job, ['id', 'timestamp', 'queueType', 'data']) - ); + if (shouldInsertIntoDatabase) { + await this.db.insertJob(formatJobForInsert(job)); + } this.getQueue(job.queueType).add(job); } diff --git a/ts/jobs/formatJobForInsert.ts b/ts/jobs/formatJobForInsert.ts new file mode 100644 index 000000000..bd20dbd63 --- /dev/null +++ b/ts/jobs/formatJobForInsert.ts @@ -0,0 +1,19 @@ +// Copyright 2021 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +import type { ParsedJob, StoredJob } from './types'; + +/** + * Format a job to be inserted into the database. + * + * Notably, `Job` instances (which have a promise attached) cannot be serialized without + * some cleanup. That's what this function is most useful for. + */ +export const formatJobForInsert = ( + job: Readonly> +): StoredJob => ({ + id: job.id, + timestamp: job.timestamp, + queueType: job.queueType, + data: job.data, +}); diff --git a/ts/jobs/initializeAllJobQueues.ts b/ts/jobs/initializeAllJobQueues.ts index b7a503a9f..6355e05e6 100644 --- a/ts/jobs/initializeAllJobQueues.ts +++ b/ts/jobs/initializeAllJobQueues.ts @@ -3,6 +3,7 @@ import type { WebAPIType } from '../textsecure/WebAPI'; +import { normalMessageSendJobQueue } from './normalMessageSendJobQueue'; import { readSyncJobQueue } from './readSyncJobQueue'; import { removeStorageKeyJobQueue } from './removeStorageKeyJobQueue'; import { reportSpamJobQueue } from './reportSpamJobQueue'; @@ -19,6 +20,7 @@ export function initializeAllJobQueues({ }): void { reportSpamJobQueue.initialize({ server }); + normalMessageSendJobQueue.streamJobs(); readSyncJobQueue.streamJobs(); removeStorageKeyJobQueue.streamJobs(); reportSpamJobQueue.streamJobs(); diff --git a/ts/jobs/normalMessageSendJobQueue.ts b/ts/jobs/normalMessageSendJobQueue.ts new file mode 100644 index 000000000..e1d2bf0d7 --- /dev/null +++ b/ts/jobs/normalMessageSendJobQueue.ts @@ -0,0 +1,521 @@ +// Copyright 2021 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +/* eslint-disable class-methods-use-this */ + +import PQueue from 'p-queue'; +import type { LoggerType } from '../logging/log'; +import { exponentialBackoffMaxAttempts } from '../util/exponentialBackoff'; +import { commonShouldJobContinue } from './helpers/commonShouldJobContinue'; +import { MessageModel, getMessageById } from '../models/messages'; +import type { ConversationModel } from '../models/conversations'; +import { ourProfileKeyService } from '../services/ourProfileKey'; +import { strictAssert } from '../util/assert'; +import { isRecord } from '../util/isRecord'; +import * as durations from '../util/durations'; +import { isMe } from '../util/whatTypeOfConversation'; +import { getSendOptions } from '../util/getSendOptions'; +import { SignalService as Proto } from '../protobuf'; +import { handleMessageSend } from '../util/handleMessageSend'; +import type { CallbackResultType } from '../textsecure/Types.d'; +import { isSent } from '../messages/MessageSendState'; +import { getLastChallengeError, isOutgoing } from '../state/selectors/message'; +import { parseIntWithFallback } from '../util/parseIntWithFallback'; +import type { + AttachmentType, + GroupV1InfoType, + GroupV2InfoType, + PreviewType, +} from '../textsecure/SendMessage'; +import type { BodyRangesType } from '../types/Util'; +import type { WhatIsThis } from '../window.d'; + +import type { ParsedJob } from './types'; +import { JobQueue } from './JobQueue'; +import { jobQueueDatabaseStore } from './JobQueueDatabaseStore'; +import { Job } from './Job'; + +const { + loadAttachmentData, + loadPreviewData, + loadQuoteData, + loadStickerData, +} = window.Signal.Migrations; +const { Message } = window.Signal.Types; + +const MAX_RETRY_TIME = durations.DAY; +const MAX_ATTEMPTS = exponentialBackoffMaxAttempts(MAX_RETRY_TIME); + +type NormalMessageSendJobData = { + messageId: string; + conversationId: string; +}; + +export class NormalMessageSendJobQueue extends JobQueue { + private readonly queues = new Map(); + + /** + * Add a job (see `JobQueue.prototype.add`). + * + * You can override `insert` to change the way the job is added to the database. This is + * useful if you're trying to save a message and a job in the same database transaction. + */ + async add( + data: Readonly, + insert?: (job: ParsedJob) => Promise + ): Promise> { + if (!insert) { + return super.add(data); + } + + this.throwIfNotStarted(); + + const job = this.createJob(data); + await insert(job); + await jobQueueDatabaseStore.insert(job, { + shouldInsertIntoDatabase: false, + }); + return job; + } + + protected parseData(data: unknown): NormalMessageSendJobData { + // Because we do this so often and Zod is a bit slower, we do "manual" parsing here. + strictAssert(isRecord(data), 'Job data is not an object'); + const { messageId, conversationId } = data; + strictAssert( + typeof messageId === 'string', + 'Job data had a non-string message ID' + ); + strictAssert( + typeof conversationId === 'string', + 'Job data had a non-string conversation ID' + ); + return { messageId, conversationId }; + } + + private getQueue(queueKey: string): PQueue { + const existingQueue = this.queues.get(queueKey); + if (existingQueue) { + return existingQueue; + } + + const newQueue = new PQueue({ concurrency: 1 }); + newQueue.once('idle', () => { + this.queues.delete(queueKey); + }); + + this.queues.set(queueKey, newQueue); + return newQueue; + } + + private enqueue(queueKey: string, fn: () => Promise): Promise { + return this.getQueue(queueKey).add(fn); + } + + protected async run( + { + data, + timestamp, + }: Readonly<{ data: NormalMessageSendJobData; timestamp: number }>, + { attempt, log }: Readonly<{ attempt: number; log: LoggerType }> + ): Promise { + const { messageId, conversationId } = data; + + await this.enqueue(conversationId, async () => { + const isFinalAttempt = attempt >= MAX_ATTEMPTS; + + // We don't immediately use this value because we may want to mark the message + // failed before doing so. + const shouldContinue = await commonShouldJobContinue({ + attempt, + log, + maxRetryTime: MAX_RETRY_TIME, + timestamp, + }); + + await window.ConversationController.loadPromise(); + + const message = await getMessageById(messageId); + if (!message) { + log.info( + `message ${messageId} was not found, maybe because it was deleted. Giving up on sending it` + ); + return; + } + + if (!isOutgoing(message.attributes)) { + log.error( + `message ${messageId} was not an outgoing message to begin with. This is probably a bogus job. Giving up on sending it` + ); + return; + } + + if (message.isErased() || message.get('deletedForEveryone')) { + log.info(`message ${messageId} was erased. Giving up on sending it`); + return; + } + + let messageSendErrors: Array = []; + + // We don't want to save errors on messages unless we're giving up. If it's our + // final attempt, we know upfront that we want to give up. However, we might also + // want to give up if (1) we get a 508 from the server, asking us to please stop + // (2) we get a 428 from the server, flagging the message for spam (3) some other + // reason not known at the time of this writing. + // + // This awkward callback lets us hold onto errors we might want to save, so we can + // decide whether to save them later on. + const saveErrors = isFinalAttempt + ? undefined + : (errors: Array) => { + messageSendErrors = errors; + }; + + if (!shouldContinue) { + log.info( + `message ${messageId} ran out of time. Giving up on sending it` + ); + await markMessageFailed(message, messageSendErrors); + return; + } + + try { + const conversation = message.getConversation(); + if (!conversation) { + throw new Error( + `could not find conversation for message with ID ${messageId}` + ); + } + + const { + allRecipientIdentifiers, + recipientIdentifiersWithoutMe, + untrustedConversationIds, + } = getMessageRecipients({ + message, + conversation, + }); + + if (untrustedConversationIds.length) { + log.info( + `message ${messageId} sending blocked because ${untrustedConversationIds.length} conversation(s) were untrusted. Giving up on the job, but it may be reborn later` + ); + window.reduxActions.conversations.messageStoppedByMissingVerification( + messageId, + untrustedConversationIds + ); + return; + } + + if (!allRecipientIdentifiers.length) { + log.warn( + `trying to send message ${messageId} but it looks like it was already sent to everyone. This is unexpected, but we're giving up` + ); + return; + } + + const { + attachments, + body, + deletedForEveryoneTimestamp, + expireTimer, + mentions, + messageTimestamp, + preview, + profileKey, + quote, + sticker, + } = await getMessageSendData({ conversation, message }); + + let messageSendPromise: Promise; + + if (recipientIdentifiersWithoutMe.length === 0) { + log.info('sending sync message only'); + const dataMessage = await window.textsecure.messaging.getDataMessage({ + attachments, + body, + deletedForEveryoneTimestamp, + expireTimer, + preview, + profileKey, + quote, + recipients: allRecipientIdentifiers, + sticker, + timestamp: messageTimestamp, + }); + messageSendPromise = message.sendSyncMessageOnly( + dataMessage, + saveErrors + ); + } else { + const conversationType = conversation.get('type'); + const sendOptions = await getSendOptions(conversation.attributes); + const { ContentHint } = Proto.UnidentifiedSenderMessage.Message; + + let innerPromise: Promise; + if (conversationType === Message.GROUP) { + log.info('sending group message'); + innerPromise = window.Signal.Util.sendToGroup({ + groupSendOptions: { + attachments, + deletedForEveryoneTimestamp, + expireTimer, + groupV1: updateRecipients( + conversation.getGroupV1Info(), + recipientIdentifiersWithoutMe + ), + groupV2: updateRecipients( + conversation.getGroupV2Info(), + recipientIdentifiersWithoutMe + ), + messageText: body, + preview, + profileKey, + quote, + sticker, + timestamp: messageTimestamp, + mentions, + }, + conversation, + contentHint: ContentHint.RESENDABLE, + messageId, + sendOptions, + sendType: 'message', + }); + } else { + log.info('sending direct message'); + innerPromise = window.textsecure.messaging.sendMessageToIdentifier({ + identifier: recipientIdentifiersWithoutMe[0], + messageText: body, + attachments, + quote, + preview, + sticker, + reaction: null, + deletedForEveryoneTimestamp, + timestamp: messageTimestamp, + expireTimer, + contentHint: ContentHint.RESENDABLE, + groupId: undefined, + profileKey, + options: sendOptions, + }); + } + + messageSendPromise = message.send( + handleMessageSend(innerPromise, { + messageIds: [messageId], + sendType: 'message', + }), + saveErrors + ); + } + + await messageSendPromise; + + if ( + getLastChallengeError({ + errors: messageSendErrors, + }) + ) { + log.info( + `message ${messageId} hit a spam challenge. Not retrying any more` + ); + await message.saveErrors(messageSendErrors); + return; + } + + const didFullySend = + !messageSendErrors.length || didSendToEveryone(message); + if (!didFullySend) { + throw new Error('message did not fully send'); + } + } catch (err: unknown) { + const serverAskedUsToStop: boolean = messageSendErrors.some( + (messageSendError: unknown) => + messageSendError instanceof Error && + parseIntWithFallback(messageSendError.code, -1) === 508 + ); + + if (isFinalAttempt || serverAskedUsToStop) { + await markMessageFailed(message, messageSendErrors); + } + + if (serverAskedUsToStop) { + log.info('server responded with 508. Giving up on this job'); + return; + } + + throw err; + } + }); + } +} + +export const normalMessageSendJobQueue = new NormalMessageSendJobQueue({ + store: jobQueueDatabaseStore, + queueType: 'normal message send', + maxAttempts: MAX_ATTEMPTS, +}); + +function getMessageRecipients({ + conversation, + message, +}: Readonly<{ + conversation: ConversationModel; + message: MessageModel; +}>): { + allRecipientIdentifiers: Array; + recipientIdentifiersWithoutMe: Array; + untrustedConversationIds: Array; +} { + const allRecipientIdentifiers: Array = []; + const recipientIdentifiersWithoutMe: Array = []; + const untrustedConversationIds: Array = []; + + const currentConversationRecipients = conversation.getRecipientConversationIds(); + + Object.entries(message.get('sendStateByConversationId') || {}).forEach( + ([recipientConversationId, sendState]) => { + if (isSent(sendState.status)) { + return; + } + + const recipient = window.ConversationController.get( + recipientConversationId + ); + if (!recipient) { + return; + } + + const isRecipientMe = isMe(recipient.attributes); + + if ( + !currentConversationRecipients.has(recipientConversationId) && + !isRecipientMe + ) { + return; + } + + if (recipient.isUntrusted()) { + untrustedConversationIds.push(recipientConversationId); + } + + const recipientIdentifier = recipient.getSendTarget(); + if (!recipientIdentifier) { + return; + } + + allRecipientIdentifiers.push(recipientIdentifier); + if (!isRecipientMe) { + recipientIdentifiersWithoutMe.push(recipientIdentifier); + } + } + ); + + return { + allRecipientIdentifiers, + recipientIdentifiersWithoutMe, + untrustedConversationIds, + }; +} + +async function getMessageSendData({ + conversation, + message, +}: Readonly<{ + conversation: ConversationModel; + message: MessageModel; +}>): Promise<{ + attachments: Array; + body: undefined | string; + deletedForEveryoneTimestamp: undefined | number; + expireTimer: undefined | number; + mentions: undefined | BodyRangesType; + messageTimestamp: number; + preview: Array; + profileKey: undefined | ArrayBuffer; + quote: WhatIsThis; + sticker: WhatIsThis; +}> { + const messageTimestamp = + message.get('sent_at') || message.get('timestamp') || Date.now(); + + const [ + attachmentsWithData, + preview, + quote, + sticker, + profileKey, + ] = await Promise.all([ + // We don't update the caches here because (1) we expect the caches to be populated on + // initial send, so they should be there in the 99% case (2) if you're retrying a + // failed message across restarts, we don't touch the cache for simplicity. If sends + // are failing, let's not add the complication of a cache. + Promise.all((message.get('attachments') ?? []).map(loadAttachmentData)), + message.cachedOutgoingPreviewData || + loadPreviewData(message.get('preview')), + message.cachedOutgoingQuoteData || loadQuoteData(message.get('quote')), + message.cachedOutgoingStickerData || + loadStickerData(message.get('sticker')), + conversation.get('profileSharing') ? ourProfileKeyService.get() : undefined, + ]); + + const { body, attachments } = window.Whisper.Message.getLongMessageAttachment( + { + body: message.get('body'), + attachments: attachmentsWithData, + now: messageTimestamp, + } + ); + + return { + attachments, + body, + deletedForEveryoneTimestamp: message.get('deletedForEveryoneTimestamp'), + expireTimer: conversation.get('expireTimer'), + mentions: message.get('bodyRanges'), + messageTimestamp, + preview, + profileKey, + quote, + sticker, + }; +} + +async function markMessageFailed( + message: MessageModel, + errors: Array +): Promise { + message.markFailed(); + message.saveErrors(errors, { skipSave: true }); + await window.Signal.Data.saveMessage(message.attributes); +} + +function didSendToEveryone(message: Readonly): boolean { + const sendStateByConversationId = + message.get('sendStateByConversationId') || {}; + return Object.values(sendStateByConversationId).every(sendState => + isSent(sendState.status) + ); +} + +function updateRecipients( + groupInfo: undefined | GroupV1InfoType, + recipients: Array +): undefined | GroupV1InfoType; +function updateRecipients( + groupInfo: undefined | GroupV2InfoType, + recipients: Array +): undefined | GroupV2InfoType; +function updateRecipients( + groupInfo: undefined | GroupV1InfoType | GroupV2InfoType, + recipients: Array +): undefined | GroupV1InfoType | GroupV2InfoType { + return ( + groupInfo && { + ...groupInfo, + members: recipients, + } + ); +} diff --git a/ts/messages/MessageSendState.ts b/ts/messages/MessageSendState.ts index 7909c0493..f48819b59 100644 --- a/ts/messages/MessageSendState.ts +++ b/ts/messages/MessageSendState.ts @@ -59,6 +59,8 @@ export const isDelivered = (status: SendStatus): boolean => STATUS_NUMBERS[status] >= STATUS_NUMBERS[SendStatus.Delivered]; export const isSent = (status: SendStatus): boolean => STATUS_NUMBERS[status] >= STATUS_NUMBERS[SendStatus.Sent]; +export const isFailed = (status: SendStatus): boolean => + status === SendStatus.Failed; /** * `SendState` combines `SendStatus` and a timestamp. You can use it to show things to the diff --git a/ts/messages/getMessagesById.ts b/ts/messages/getMessagesById.ts new file mode 100644 index 000000000..96d0962ee --- /dev/null +++ b/ts/messages/getMessagesById.ts @@ -0,0 +1,46 @@ +// Copyright 2021 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +import * as log from '../logging/log'; +import type { MessageModel } from '../models/messages'; +import type { MessageAttributesType } from '../model-types.d'; +import * as Errors from '../types/errors'; + +export async function getMessagesById( + messageIds: ReadonlyArray +): Promise> { + const messagesFromMemory: Array = []; + const messageIdsToLookUpInDatabase: Array = []; + + messageIds.forEach(messageId => { + const message = window.MessageController.getById(messageId); + if (message) { + messagesFromMemory.push(message); + } else { + messageIdsToLookUpInDatabase.push(messageId); + } + }); + + let rawMessagesFromDatabase: Array; + try { + rawMessagesFromDatabase = await window.Signal.Data.getMessagesById( + messageIdsToLookUpInDatabase + ); + } catch (err: unknown) { + log.error( + `failed to load ${ + messageIdsToLookUpInDatabase.length + } message(s) from database. ${Errors.toLogFormat(err)}` + ); + return []; + } + + const messagesFromDatabase = rawMessagesFromDatabase.map(rawMessage => { + // We use `window.Whisper.Message` instead of `MessageModel` here to avoid a circular + // import. + const message = new window.Whisper.Message(rawMessage); + return window.MessageController.register(message.id, message); + }); + + return [...messagesFromMemory, ...messagesFromDatabase]; +} diff --git a/ts/models/conversations.ts b/ts/models/conversations.ts index 09dc14722..b54635cd4 100644 --- a/ts/models/conversations.ts +++ b/ts/models/conversations.ts @@ -29,6 +29,7 @@ import { CustomColorType, } from '../types/Colors'; import { MessageModel } from './messages'; +import { strictAssert } from '../util/assert'; import { isMuted } from '../util/isMuted'; import { isConversationSMSOnly } from '../util/isConversationSMSOnly'; import { isConversationUnregistered } from '../util/isConversationUnregistered'; @@ -82,6 +83,7 @@ import { isTapToView, getMessagePropStatus, } from '../state/selectors/message'; +import { normalMessageSendJobQueue } from '../jobs/normalMessageSendJobQueue'; import { Deletes } from '../messageModifiers/Deletes'; import { Reactions, ReactionModel } from '../messageModifiers/Reactions'; import { isAnnouncementGroupReady } from '../util/isAnnouncementGroupReady'; @@ -119,11 +121,6 @@ const ATTRIBUTES_THAT_DONT_INVALIDATE_PROPS_CACHE = new Set([ 'profileLastFetchedAt', ]); -type CustomError = Error & { - identifier?: string; - number?: string; -}; - type CachedIdenticon = { readonly url: string; readonly content: string; @@ -3111,6 +3108,10 @@ export class ConversationModel extends window.Backbone ); } + getRecipientConversationIds(): Set { + return new Set(map(this.getMembers(), conversation => conversation.id)); + } + async getQuoteAttachment( attachments?: Array, preview?: Array, @@ -3261,7 +3262,7 @@ export class ConversationModel extends window.Backbone }, }; - this.sendMessage(undefined, [], undefined, [], sticker); + this.enqueueMessageForSend(undefined, [], undefined, [], sticker); window.reduxActions.stickers.useSticker(packId, stickerId); } @@ -3577,7 +3578,7 @@ export class ConversationModel extends window.Backbone ); } - sendMessage( + async enqueueMessageForSend( body: string | undefined, attachments: Array, quote?: QuotedMessageType, @@ -3593,7 +3594,7 @@ export class ConversationModel extends window.Backbone sendHQImages?: boolean; timestamp?: number; } = {} - ): void { + ): Promise { if (this.isGroupV1AndDisabled()) { return; } @@ -3614,223 +3615,134 @@ export class ConversationModel extends window.Backbone const destination = this.getSendTarget()!; const recipients = this.getRecipients(); - if (timestamp) { - window.log.info(`sendMessage: Queueing send with timestamp ${timestamp}`); - } - this.queueJob('sendMessage', async () => { - const now = timestamp || Date.now(); + const now = timestamp || Date.now(); - await this.maybeApplyUniversalTimer(false); + await this.maybeApplyUniversalTimer(false); - const expireTimer = this.get('expireTimer'); + const expireTimer = this.get('expireTimer'); - window.log.info( - 'Sending message to conversation', - this.idForLogging(), - 'with timestamp', - now - ); + window.log.info( + 'Sending message to conversation', + this.idForLogging(), + 'with timestamp', + now + ); - const recipientMaybeConversations = map(recipients, identifier => - window.ConversationController.get(identifier) - ); - const recipientConversations = filter( - recipientMaybeConversations, - isNotNil - ); - const recipientConversationIds = concat( - map(recipientConversations, c => c.id), - [window.ConversationController.getOurConversationIdOrThrow()] - ); + const recipientMaybeConversations = map(recipients, identifier => + window.ConversationController.get(identifier) + ); + const recipientConversations = filter( + recipientMaybeConversations, + isNotNil + ); + const recipientConversationIds = concat( + map(recipientConversations, c => c.id), + [window.ConversationController.getOurConversationIdOrThrow()] + ); - // Here we move attachments to disk - const messageWithSchema = await upgradeMessageSchema({ - timestamp: now, - type: 'outgoing', - body, - conversationId: this.id, - quote, - preview, - attachments, - sent_at: now, - received_at: window.Signal.Util.incrementMessageCounter(), - received_at_ms: now, - expireTimer, - recipients, - sticker, - bodyRanges: mentions, - sendHQImages, - sendStateByConversationId: zipObject( - recipientConversationIds, - repeat({ - status: SendStatus.Pending, - updatedAt: now, - }) - ), - }); - - if (isDirectConversation(this.attributes)) { - messageWithSchema.destination = destination; - } - const attributes: MessageAttributesType = { - ...messageWithSchema, - id: window.getGuid(), - }; - - const model = new window.Whisper.Message(attributes); - const message = window.MessageController.register(model.id, model); - - const dbStart = Date.now(); - - await window.Signal.Data.saveMessage(message.attributes, { - forceSave: true, - }); - - const dbDuration = Date.now() - dbStart; - if (dbDuration > SEND_REPORTING_THRESHOLD_MS) { - window.log.info( - `ConversationModel(${this.idForLogging()}.sendMessage(${now}): ` + - `db save took ${dbDuration}ms` - ); - } - - const renderStart = Date.now(); - - this.addSingleMessage(model); - if (sticker) { - await addStickerPackReference(model.id, sticker.packId); - } - const messageId = message.id; - - const draftProperties = dontClearDraft - ? {} - : { - draft: null, - draftTimestamp: null, - lastMessage: model.getNotificationText(), - lastMessageStatus: 'sending' as const, - }; - - this.set({ - ...draftProperties, - active_at: now, - timestamp: now, - isArchived: false, - }); - - this.incrementSentMessageCount({ save: false }); - - const renderDuration = Date.now() - renderStart; - - if (renderDuration > SEND_REPORTING_THRESHOLD_MS) { - window.log.info( - `ConversationModel(${this.idForLogging()}.sendMessage(${now}): ` + - `render save took ${renderDuration}ms` - ); - } - - window.Signal.Data.updateConversation(this.attributes); - - // We're offline! - if (!window.textsecure.messaging) { - const errors = map(recipientConversationIds, conversationId => { - const error = new Error('Network is not available') as CustomError; - error.name = 'SendMessageNetworkError'; - error.identifier = conversationId; - return error; - }); - await message.saveErrors([...errors]); - return null; - } - - const attachmentsWithData = await Promise.all( - messageWithSchema.attachments?.map(loadAttachmentData) ?? [] - ); - - const { - body: messageBody, - attachments: finalAttachments, - } = window.Whisper.Message.getLongMessageAttachment({ - body, - attachments: attachmentsWithData, - now, - }); - - let profileKey: ArrayBuffer | undefined; - if (this.get('profileSharing')) { - profileKey = await ourProfileKeyService.get(); - } - - // Special-case the self-send case - we send only a sync message - if (isMe(this.attributes)) { - const dataMessage = await window.textsecure.messaging.getDataMessage({ - attachments: finalAttachments, - body: messageBody, - // deletedForEveryoneTimestamp - expireTimer, - preview, - profileKey, - quote, - // reaction - recipients: [destination], - sticker, - timestamp: now, - }); - return message.sendSyncMessageOnly(dataMessage); - } - - const conversationType = this.get('type'); - const options = await getSendOptions(this.attributes); - const { ContentHint } = Proto.UnidentifiedSenderMessage.Message; - - let promise; - if (conversationType === Message.GROUP) { - promise = window.Signal.Util.sendToGroup({ - groupSendOptions: { - attachments: finalAttachments, - expireTimer, - groupV1: this.getGroupV1Info(), - groupV2: this.getGroupV2Info(), - messageText: messageBody, - preview, - profileKey, - quote, - sticker, - timestamp: now, - mentions, - }, - conversation: this, - contentHint: ContentHint.RESENDABLE, - messageId, - sendOptions: options, - sendType: 'message', - }); - } else { - promise = window.textsecure.messaging.sendMessageToIdentifier({ - identifier: destination, - messageText: messageBody, - attachments: finalAttachments, - quote, - preview, - sticker, - reaction: null, - deletedForEveryoneTimestamp: undefined, - timestamp: now, - expireTimer, - contentHint: ContentHint.RESENDABLE, - groupId: undefined, - profileKey, - options, - }); - } - - return message.send( - handleMessageSend(promise, { - messageIds: [messageId], - sendType: 'message', + // Here we move attachments to disk + const messageWithSchema = await upgradeMessageSchema({ + timestamp: now, + type: 'outgoing', + body, + conversationId: this.id, + quote, + preview, + attachments, + sent_at: now, + received_at: window.Signal.Util.incrementMessageCounter(), + received_at_ms: now, + expireTimer, + recipients, + sticker, + bodyRanges: mentions, + sendHQImages, + sendStateByConversationId: zipObject( + recipientConversationIds, + repeat({ + status: SendStatus.Pending, + updatedAt: now, }) - ); + ), }); + + if (isDirectConversation(this.attributes)) { + messageWithSchema.destination = destination; + } + const attributes: MessageAttributesType = { + ...messageWithSchema, + id: window.getGuid(), + }; + + const model = new window.Whisper.Message(attributes); + const message = window.MessageController.register(model.id, model); + message.cachedOutgoingPreviewData = preview; + message.cachedOutgoingQuoteData = quote; + message.cachedOutgoingStickerData = sticker; + + const dbStart = Date.now(); + + strictAssert( + typeof message.attributes.timestamp === 'number', + 'Expected a timestamp' + ); + + await normalMessageSendJobQueue.add( + { messageId: message.id, conversationId: this.id }, + async jobToInsert => { + window.log.info( + `enqueueMessageForSend: saving message ${message.id} and job ${jobToInsert.id}` + ); + await window.Signal.Data.saveMessage(message.attributes, { + jobToInsert, + forceSave: true, + }); + } + ); + + const dbDuration = Date.now() - dbStart; + if (dbDuration > SEND_REPORTING_THRESHOLD_MS) { + window.log.info( + `ConversationModel(${this.idForLogging()}.sendMessage(${now}): ` + + `db save took ${dbDuration}ms` + ); + } + + const renderStart = Date.now(); + + this.addSingleMessage(model); + if (sticker) { + await addStickerPackReference(model.id, sticker.packId); + } + + const draftProperties = dontClearDraft + ? {} + : { + draft: null, + draftTimestamp: null, + lastMessage: model.getNotificationText(), + lastMessageStatus: 'sending' as const, + }; + + this.set({ + ...draftProperties, + active_at: now, + timestamp: now, + isArchived: false, + }); + + this.incrementSentMessageCount({ save: false }); + + const renderDuration = Date.now() - renderStart; + + if (renderDuration > SEND_REPORTING_THRESHOLD_MS) { + window.log.info( + `ConversationModel(${this.idForLogging()}.sendMessage(${now}): ` + + `render save took ${renderDuration}ms` + ); + } + + window.Signal.Data.updateConversation(this.attributes); } // Is this someone who is a contact, or are we sharing our profile with them? diff --git a/ts/models/messages.ts b/ts/models/messages.ts index 4f88b6885..eefb9d145 100644 --- a/ts/models/messages.ts +++ b/ts/models/messages.ts @@ -1,7 +1,7 @@ // Copyright 2020-2021 Signal Messenger, LLC // SPDX-License-Identifier: AGPL-3.0-only -import { isEmpty, isEqual, noop, omit, union } from 'lodash'; +import { isEmpty, isEqual, mapValues, noop, omit, union } from 'lodash'; import { CustomError, GroupV1Update, @@ -43,7 +43,6 @@ import { import * as Stickers from '../types/Stickers'; import { AttachmentType, isImage, isVideo } from '../types/Attachment'; import { IMAGE_WEBP, stringToMIMEType } from '../types/MIME'; -import { ourProfileKeyService } from '../services/ourProfileKey'; import { ReadStatus } from '../messages/MessageReadStatus'; import { SendActionType, @@ -112,6 +111,8 @@ import { ViewOnceOpenSyncs } from '../messageModifiers/ViewOnceOpenSyncs'; import * as AttachmentDownloads from '../messageModifiers/AttachmentDownloads'; import * as LinkPreview from '../types/LinkPreview'; import { SignalService as Proto } from '../protobuf'; +import { normalMessageSendJobQueue } from '../jobs/normalMessageSendJobQueue'; +import type { PreviewType as OutgoingPreviewType } from '../textsecure/SendMessage'; /* eslint-disable camelcase */ /* eslint-disable more/no-then */ @@ -134,10 +135,6 @@ const { } = window.Signal.Types; const { deleteExternalMessageFiles, - loadAttachmentData, - loadQuoteData, - loadPreviewData, - loadStickerData, upgradeMessageSchema, } = window.Signal.Migrations; const { getTextWithMentions, GoogleChrome } = window.Signal.Util; @@ -190,6 +187,12 @@ export class MessageModel extends window.Backbone.Model { syncPromise?: Promise; + cachedOutgoingPreviewData?: Array; + + cachedOutgoingQuoteData?: WhatIsThis; + + cachedOutgoingStickerData?: WhatIsThis; + initialize(attributes: unknown): void { if (_.isObject(attributes)) { this.set( @@ -1200,42 +1203,27 @@ export class MessageModel extends window.Backbone.Model { return window.ConversationController.getOrCreate(source, 'private'); } - // Send infrastructure - // One caller today: event handler for the 'Retry Send' entry in triple-dot menu - async retrySend(): Promise> { - if (!window.textsecure.messaging) { - window.log.error('retrySend: Cannot retry since we are offline!'); - return null; - } - + async retrySend(): Promise { const retryOptions = this.get('retryOptions'); - - this.set({ errors: undefined, retryOptions: undefined }); - if (retryOptions) { + if (!window.textsecure.messaging) { + window.log.error('retrySend: Cannot retry since we are offline!'); + return; + } + this.unset('errors'); + this.unset('retryOptions'); return this.sendUtilityMessageWithRetry(retryOptions); } // eslint-disable-next-line @typescript-eslint/no-non-null-assertion const conversation = this.getConversation()!; - const currentRecipients = new Set( - conversation - .getRecipients() - .map(identifier => - window.ConversationController.getConversationId(identifier) - ) - .filter(isNotNil) - ); - const profileKey = conversation.get('profileSharing') - ? await ourProfileKeyService.get() - : undefined; + const currentConversationRecipients = conversation.getRecipientConversationIds(); // Determine retry recipients and get their most up-to-date addressing information const oldSendStateByConversationId = this.get('sendStateByConversationId') || {}; - const recipients: Array = []; const newSendStateByConversationId = { ...oldSendStateByConversationId }; for (const [conversationId, sendState] of Object.entries( oldSendStateByConversationId @@ -1244,15 +1232,12 @@ export class MessageModel extends window.Backbone.Model { continue; } - const isStillInConversation = currentRecipients.has(conversationId); - if (!isStillInConversation) { - continue; - } - - const recipient = window.ConversationController.get( - conversationId - )?.getSendTarget(); - if (!recipient) { + const recipient = window.ConversationController.get(conversationId); + if ( + !recipient || + (!currentConversationRecipients.has(conversationId) && + !isMe(recipient.attributes)) + ) { continue; } @@ -1263,133 +1248,15 @@ export class MessageModel extends window.Backbone.Model { updatedAt: Date.now(), } ); - recipients.push(recipient); } this.set('sendStateByConversationId', newSendStateByConversationId); - await window.Signal.Data.saveMessage(this.attributes); - - if (!recipients.length) { - window.log.warn('retrySend: Nobody to send to!'); - return undefined; - } - - const attachmentsWithData = await Promise.all( - (this.get('attachments') || []).map(loadAttachmentData) - ); - const { - body, - attachments, - } = window.Whisper.Message.getLongMessageAttachment({ - body: this.get('body'), - attachments: attachmentsWithData, - now: this.get('sent_at'), - }); - - const quoteWithData = await loadQuoteData(this.get('quote')); - const previewWithData = await loadPreviewData(this.get('preview')); - const stickerWithData = await loadStickerData(this.get('sticker')); - const ourNumber = window.textsecure.storage.user.getNumber(); - - // Special-case the self-send case - we send only a sync message - if ( - recipients.length === 1 && - (recipients[0] === ourNumber || recipients[0] === this.OUR_UUID) - ) { - const dataMessage = await window.textsecure.messaging.getDataMessage({ - attachments, - body, - deletedForEveryoneTimestamp: this.get('deletedForEveryoneTimestamp'), - expireTimer: this.get('expireTimer'), - // flags - mentions: this.get('bodyRanges'), - preview: previewWithData, - profileKey, - quote: quoteWithData, - reaction: null, - recipients, - sticker: stickerWithData, - timestamp: this.get('sent_at'), - }); - - return this.sendSyncMessageOnly(dataMessage); - } - - let promise; - const options = await getSendOptions(conversation.attributes); - - const { ContentHint } = Proto.UnidentifiedSenderMessage.Message; - - if (isDirectConversation(conversation.attributes)) { - const [identifier] = recipients; - - promise = window.textsecure.messaging.sendMessageToIdentifier({ - identifier, - messageText: body, - attachments, - quote: quoteWithData, - preview: previewWithData, - sticker: stickerWithData, - reaction: null, - deletedForEveryoneTimestamp: this.get('deletedForEveryoneTimestamp'), - timestamp: this.get('sent_at'), - expireTimer: this.get('expireTimer'), - contentHint: ContentHint.RESENDABLE, - groupId: undefined, - profileKey, - options, - }); - } else { - const initialGroupV2 = conversation.getGroupV2Info(); - const groupId = conversation.get('groupId'); - if (!groupId) { - throw new Error("retrySend: Conversation didn't have groupId"); + await normalMessageSendJobQueue.add( + { messageId: this.id, conversationId: conversation.id }, + async jobToInsert => { + await window.Signal.Data.saveMessage(this.attributes, { jobToInsert }); } - - const groupV2 = initialGroupV2 - ? { - ...initialGroupV2, - members: recipients, - } - : undefined; - const groupV1 = groupV2 - ? undefined - : { - id: groupId, - members: recipients, - }; - - promise = window.Signal.Util.sendToGroup({ - groupSendOptions: { - messageText: body, - timestamp: this.get('sent_at'), - attachments, - quote: quoteWithData, - preview: previewWithData, - sticker: stickerWithData, - expireTimer: this.get('expireTimer'), - mentions: this.get('bodyRanges'), - profileKey, - groupV2, - groupV1, - }, - conversation, - contentHint: ContentHint.RESENDABLE, - // Important to ensure that we don't consider this recipient list to be the - // entire member list. - isPartialSend: true, - messageId: this.id, - sendOptions: options, - sendType: 'messageRetry', - }); - } - - return this.send( - handleMessageSend(promise, { - messageIds: [this.id], - sendType: 'messageRetry', - }) ); } @@ -1414,118 +1281,20 @@ export class MessageModel extends window.Backbone.Model { return isEmpty(withoutMe) || someSendStatus(withoutMe, isSent); } - // Called when the user ran into an error with a specific user, wants to send to them - // One caller today: ConversationView.forceSend() - async resend(identifier: string): Promise> { - const error = this.removeOutgoingErrors(identifier); - if (!error) { - window.log.warn( - 'resend: requested number was not present in errors. continuing.' - ); - } - - if (this.isErased()) { - window.log.warn('resend: message is erased; refusing to resend'); - return null; - } - - const profileKey = undefined; - const attachmentsWithData = await Promise.all( - (this.get('attachments') || []).map(loadAttachmentData) - ); - const { - body, - attachments, - } = window.Whisper.Message.getLongMessageAttachment({ - body: this.get('body'), - attachments: attachmentsWithData, - now: this.get('sent_at'), - }); - - const quoteWithData = await loadQuoteData(this.get('quote')); - const previewWithData = await loadPreviewData(this.get('preview')); - const stickerWithData = await loadStickerData(this.get('sticker')); - const ourNumber = window.textsecure.storage.user.getNumber(); - - // Special-case the self-send case - we send only a sync message - if (identifier === ourNumber || identifier === this.OUR_UUID) { - const dataMessage = await window.textsecure.messaging.getDataMessage({ - attachments, - body, - deletedForEveryoneTimestamp: this.get('deletedForEveryoneTimestamp'), - expireTimer: this.get('expireTimer'), - mentions: this.get('bodyRanges'), - preview: previewWithData, - profileKey, - quote: quoteWithData, - reaction: null, - recipients: [identifier], - sticker: stickerWithData, - timestamp: this.get('sent_at'), - }); - - return this.sendSyncMessageOnly(dataMessage); - } - - const { ContentHint } = Proto.UnidentifiedSenderMessage.Message; - const parentConversation = this.getConversation(); - const groupId = parentConversation?.get('groupId'); - - const recipientConversation = window.ConversationController.get(identifier); - const sendOptions = recipientConversation - ? await getSendOptions(recipientConversation.attributes) - : undefined; - const group = - groupId && isGroupV1(parentConversation?.attributes) - ? { - id: groupId, - type: Proto.GroupContext.Type.DELIVER, - } - : undefined; - - const timestamp = this.get('sent_at'); - const contentMessage = await window.textsecure.messaging.getContentMessage({ - attachments, - body, - expireTimer: this.get('expireTimer'), - group, - groupV2: parentConversation?.getGroupV2Info(), - preview: previewWithData, - quote: quoteWithData, - mentions: this.get('bodyRanges'), - recipients: [identifier], - sticker: stickerWithData, - timestamp, - }); - - if (parentConversation) { - const senderKeyInfo = parentConversation.get('senderKeyInfo'); - if (senderKeyInfo && senderKeyInfo.distributionId) { - const senderKeyDistributionMessage = await window.textsecure.messaging.getSenderKeyDistributionMessage( - senderKeyInfo.distributionId - ); - - contentMessage.senderKeyDistributionMessage = senderKeyDistributionMessage.serialize(); - } - } - - const promise = window.textsecure.messaging.sendMessageProtoAndWait({ - timestamp, - recipients: [identifier], - proto: contentMessage, - contentHint: ContentHint.RESENDABLE, - groupId: - groupId && isGroupV2(parentConversation?.attributes) - ? groupId - : undefined, - options: sendOptions, - }); - - return this.send( - handleMessageSend(promise, { - messageIds: [this.id], - sendType: 'messageRetry', - }) + /** + * Change any Pending send state to Failed. Note that this will not mark successful + * sends failed. + */ + public markFailed(): void { + const now = Date.now(); + this.set( + 'sendStateByConversationId', + mapValues(this.get('sendStateByConversationId') || {}, sendState => + sendStateReducer(sendState, { + type: SendActionType.Failed, + updatedAt: now, + }) + ) ); } @@ -1552,7 +1321,8 @@ export class MessageModel extends window.Backbone.Model { } async send( - promise: Promise + promise: Promise, + saveErrors?: (errors: Array) => void ): Promise> { const updateLeftPane = this.getConversation()?.debouncedUpdateLastMessage || noop; @@ -1655,7 +1425,7 @@ export class MessageModel extends window.Backbone.Model { window.ConversationController.get(error.identifier) || window.ConversationController.get(error.number); - if (conversation) { + if (conversation && !saveErrors) { const previousSendState = getOwn( sendStateByConversationId, conversation.id @@ -1719,8 +1489,12 @@ export class MessageModel extends window.Backbone.Model { attributesToUpdate.errors = []; this.set(attributesToUpdate); - // We skip save because we'll save in the next step. - this.saveErrors(errorsToSave, { skipSave: true }); + if (saveErrors) { + saveErrors(errorsToSave); + } else { + // We skip save because we'll save in the next step. + this.saveErrors(errorsToSave, { skipSave: true }); + } if (!this.doNotSave) { await window.Signal.Data.saveMessage(this.attributes); @@ -1734,6 +1508,14 @@ export class MessageModel extends window.Backbone.Model { await Promise.all(promises); + const isTotalSuccess: boolean = + result.success && !this.get('errors')?.length; + if (isTotalSuccess) { + delete this.cachedOutgoingPreviewData; + delete this.cachedOutgoingQuoteData; + delete this.cachedOutgoingStickerData; + } + updateLeftPane(); } @@ -1779,7 +1561,10 @@ export class MessageModel extends window.Backbone.Model { throw new Error(`Unsupported retriable type: ${options.type}`); } - async sendSyncMessageOnly(dataMessage: ArrayBuffer): Promise { + async sendSyncMessageOnly( + dataMessage: ArrayBuffer, + saveErrors?: (errors: Array) => void + ): Promise { // eslint-disable-next-line @typescript-eslint/no-non-null-assertion const conv = this.getConversation()!; this.set({ dataMessage }); @@ -1800,9 +1585,16 @@ export class MessageModel extends window.Backbone.Model { : undefined, }); } catch (result) { - const errors = (result && result.errors) || [new Error('Unknown error')]; - // We don't save because we're about to save below. - this.saveErrors(errors, { skipSave: true }); + const resultErrors = result?.errors; + const errors = Array.isArray(resultErrors) + ? resultErrors + : [new Error('Unknown error')]; + if (saveErrors) { + saveErrors(errors); + } else { + // We don't save because we're about to save below. + this.saveErrors(errors, { skipSave: true }); + } } finally { await window.Signal.Data.saveMessage(this.attributes); diff --git a/ts/sql/Client.ts b/ts/sql/Client.ts index f29aaec69..98e9cc4b9 100644 --- a/ts/sql/Client.ts +++ b/ts/sql/Client.ts @@ -40,6 +40,7 @@ import { MessageModelCollectionType, } from '../model-types.d'; import { StoredJob } from '../jobs/types'; +import { formatJobForInsert } from '../jobs/formatJobForInsert'; import { AttachmentDownloadJobType, @@ -206,6 +207,7 @@ const dataInterface: ClientInterface = { getMessageBySender, getMessageById, + getMessagesById, getAllMessageIds, getMessagesBySentAt, getExpiredMessages, @@ -1070,9 +1072,12 @@ async function getMessageCount(conversationId?: string) { async function saveMessage( data: MessageType, - options?: { forceSave?: boolean } + options: { jobToInsert?: Readonly; forceSave?: boolean } = {} ) { - const id = await channels.saveMessage(_cleanMessageData(data), options); + const id = await channels.saveMessage(_cleanMessageData(data), { + ...options, + jobToInsert: options.jobToInsert && formatJobForInsert(options.jobToInsert), + }); window.Whisper.ExpiringMessagesListener.update(); window.Whisper.TapToViewMessagesListener.update(); @@ -1124,6 +1129,13 @@ async function getMessageById( return new Message(message); } +async function getMessagesById(messageIds: Array) { + if (!messageIds.length) { + return []; + } + return channels.getMessagesById(messageIds); +} + // For testing only async function _getAllMessages({ MessageCollection, diff --git a/ts/sql/Interface.ts b/ts/sql/Interface.ts index 2d90ec69b..d9fd2fff8 100644 --- a/ts/sql/Interface.ts +++ b/ts/sql/Interface.ts @@ -307,9 +307,13 @@ export type DataInterface = { options?: { limit?: number } ) => Promise>; + getMessagesById: (messageIds: Array) => Promise>; saveMessage: ( data: MessageType, - options?: { forceSave?: boolean } + options?: { + jobToInsert?: StoredJob; + forceSave?: boolean; + } ) => Promise; saveMessages: ( arrayOfMessages: Array, diff --git a/ts/sql/Server.ts b/ts/sql/Server.ts index 6eff3523a..d3a882de7 100644 --- a/ts/sql/Server.ts +++ b/ts/sql/Server.ts @@ -196,6 +196,7 @@ const dataInterface: ServerInterface = { removeReactionFromConversation, getMessageBySender, getMessageById, + getMessagesById, _getAllMessages, getAllMessageIds, getMessagesBySentAt, @@ -2363,22 +2364,37 @@ function getInstance(): Database { return globalInstance; } -function batchMultiVarQuery( - values: Array, - query: (batch: Array) => void -): void { +function batchMultiVarQuery( + values: Array, + query: (batch: Array) => void +): []; +function batchMultiVarQuery( + values: Array, + query: (batch: Array) => Array +): Array; +function batchMultiVarQuery( + values: Array, + query: + | ((batch: Array) => void) + | ((batch: Array) => Array) +): Array { const db = getInstance(); if (values.length > MAX_VARIABLE_COUNT) { + const result: Array = []; db.transaction(() => { for (let i = 0; i < values.length; i += MAX_VARIABLE_COUNT) { const batch = values.slice(i, i + MAX_VARIABLE_COUNT); - query(batch); + const batchResult = query(batch); + if (Array.isArray(batchResult)) { + result.push(...batchResult); + } } })(); - return; + return result; } - query(values); + const result = query(values); + return Array.isArray(result) ? result : []; } const IDENTITY_KEYS_TABLE = 'identityKeys'; @@ -3577,11 +3593,15 @@ function hasUserInitiatedMessages(conversationId: string): boolean { function saveMessageSync( data: MessageType, - options?: { forceSave?: boolean; alreadyInTransaction?: boolean } + options?: { + jobToInsert?: StoredJob; + forceSave?: boolean; + alreadyInTransaction?: boolean; + } ): string { const db = getInstance(); - const { forceSave, alreadyInTransaction } = options || {}; + const { jobToInsert, forceSave, alreadyInTransaction } = options || {}; if (!alreadyInTransaction) { return db.transaction(() => { @@ -3670,6 +3690,10 @@ function saveMessageSync( ` ).run(payload); + if (jobToInsert) { + insertJobSync(db, jobToInsert); + } + return id; } @@ -3733,12 +3757,20 @@ function saveMessageSync( json: objectToJSON(toCreate), }); + if (jobToInsert) { + insertJobSync(db, jobToInsert); + } + return toCreate.id; } async function saveMessage( data: MessageType, - options?: { forceSave?: boolean; alreadyInTransaction?: boolean } + options?: { + jobToInsert?: StoredJob; + forceSave?: boolean; + alreadyInTransaction?: boolean; + } ): Promise { return saveMessageSync(data, options); } @@ -3795,6 +3827,25 @@ async function getMessageById(id: string): Promise { return jsonToObject(row.json); } +async function getMessagesById( + messageIds: Array +): Promise> { + const db = getInstance(); + + return batchMultiVarQuery( + messageIds, + (batch: Array): Array => { + const query = db.prepare( + `SELECT json FROM messages WHERE id IN (${Array(batch.length) + .fill('?') + .join(',')});` + ); + const rows: JSONRows = query.all(batch); + return rows.map(row => jsonToObject(row.json)); + } + ); +} + async function _getAllMessages(): Promise> { const db = getInstance(); const rows: JSONRows = db @@ -5902,9 +5953,7 @@ async function getJobsInQueue(queueType: string): Promise> { })); } -async function insertJob(job: Readonly): Promise { - const db = getInstance(); - +function insertJobSync(db: Database, job: Readonly): void { db.prepare( ` INSERT INTO jobs @@ -5920,6 +5969,11 @@ async function insertJob(job: Readonly): Promise { }); } +async function insertJob(job: Readonly): Promise { + const db = getInstance(); + return insertJobSync(db, job); +} + async function deleteJob(id: string): Promise { const db = getInstance(); diff --git a/ts/state/ducks/conversations.ts b/ts/state/ducks/conversations.ts index c4e6dba0d..8d899ead6 100644 --- a/ts/state/ducks/conversations.ts +++ b/ts/state/ducks/conversations.ts @@ -45,12 +45,16 @@ import { getGroupSizeRecommendedLimit, getGroupSizeHardLimit, } from '../../groups/limits'; +import { getMessagesById } from '../../messages/getMessagesById'; import { isMessageUnread } from '../../util/isMessageUnread'; import { toggleSelectedContactForGroupAddition } from '../../groups/toggleSelectedContactForGroupAddition'; import { GroupNameCollisionsWithIdsByTitle } from '../../util/groupMemberNameCollisions'; import { ContactSpoofingType } from '../../util/contactSpoofing'; import { writeProfile } from '../../services/writeProfile'; -import { getMe } from '../selectors/conversations'; +import { + getMe, + getMessageIdsPendingBecauseOfVerification, +} from '../selectors/conversations'; import { AvatarDataType, getDefaultAvatars } from '../../types/Avatar'; import { getAvatarData } from '../../util/getAvatarData'; import { isSameAvatarData } from '../../util/isSameAvatarData'; @@ -302,6 +306,15 @@ export type ConversationsStateType = { composer?: ComposerStateType; contactSpoofingReview?: ContactSpoofingReviewStateType; + /** + * Each key is a conversation ID. Each value is an array of message IDs stopped by that + * conversation being unverified. + */ + outboundMessagesPendingConversationVerification: Record< + string, + Array + >; + // Note: it's very important that both of these locations are always kept up to date messagesLookup: MessageLookupType; messagesByConversation: MessagesByConversationType; @@ -336,14 +349,21 @@ export const getConversationCallMode = ( export const COLORS_CHANGED = 'conversations/COLORS_CHANGED'; export const COLOR_SELECTED = 'conversations/COLOR_SELECTED'; +const CANCEL_MESSAGES_PENDING_CONVERSATION_VERIFICATION = + 'conversations/CANCEL_MESSAGES_PENDING_CONVERSATION_VERIFICATION'; const COMPOSE_TOGGLE_EDITING_AVATAR = 'conversations/compose/COMPOSE_TOGGLE_EDITING_AVATAR'; const COMPOSE_ADD_AVATAR = 'conversations/compose/ADD_AVATAR'; const COMPOSE_REMOVE_AVATAR = 'conversations/compose/REMOVE_AVATAR'; const COMPOSE_REPLACE_AVATAR = 'conversations/compose/REPLACE_AVATAR'; const CUSTOM_COLOR_REMOVED = 'conversations/CUSTOM_COLOR_REMOVED'; +const MESSAGE_STOPPED_BY_MISSING_VERIFICATION = + 'conversations/MESSAGE_STOPPED_BY_MISSING_VERIFICATION'; const REPLACE_AVATARS = 'conversations/REPLACE_AVATARS'; +type CancelMessagesPendingConversationVerificationActionType = { + type: typeof CANCEL_MESSAGES_PENDING_CONVERSATION_VERIFICATION; +}; type CantAddContactToGroupActionType = { type: 'CANT_ADD_CONTACT_TO_GROUP'; payload: { @@ -465,6 +485,13 @@ export type MessageSelectedActionType = { conversationId: string; }; }; +type MessageStoppedByMissingVerificationActionType = { + type: typeof MESSAGE_STOPPED_BY_MISSING_VERIFICATION; + payload: { + messageId: string; + untrustedConversationIds: ReadonlyArray; + }; +}; export type MessageChangedActionType = { type: 'MESSAGE_CHANGED'; payload: { @@ -656,6 +683,7 @@ type ReplaceAvatarsActionType = { }; }; export type ConversationActionType = + | CancelMessagesPendingConversationVerificationActionType | CantAddContactToGroupActionType | ClearChangedMessagesActionType | ClearGroupCreationErrorActionType @@ -679,6 +707,7 @@ export type ConversationActionType = | CreateGroupPendingActionType | CreateGroupRejectedActionType | CustomColorRemovedActionType + | MessageStoppedByMissingVerificationActionType | MessageChangedActionType | MessageDeletedActionType | MessageSelectedActionType @@ -716,6 +745,7 @@ export type ConversationActionType = // Action Creators export const actions = { + cancelMessagesPendingConversationVerification, cantAddContactToGroup, clearChangedMessages, clearGroupCreationError, @@ -737,6 +767,7 @@ export const actions = { createGroup, deleteAvatarFromDisk, doubleCheckMissingQuoteReference, + messageStoppedByMissingVerification, messageChanged, messageDeleted, messageSizeChanged, @@ -775,6 +806,7 @@ export const actions = { startSettingGroupMetadata, toggleConversationInChooseMembers, toggleComposeEditingAvatar, + verifyConversationsStoppingMessageSend, }; function filterAvatarData( @@ -1074,6 +1106,26 @@ function toggleComposeEditingAvatar(): ToggleComposeEditingAvatarActionType { }; } +function verifyConversationsStoppingMessageSend(): ThunkAction< + void, + RootStateType, + unknown, + never +> { + return async (_dispatch, getState) => { + const conversationIds = Object.keys( + getState().conversations.outboundMessagesPendingConversationVerification + ); + + await Promise.all( + conversationIds.map(async conversationId => { + const conversation = window.ConversationController.get(conversationId); + await conversation?.setVerifiedDefault(); + }) + ); + }; +} + function composeSaveAvatarToDisk( avatarData: AvatarDataType ): ThunkAction { @@ -1128,6 +1180,31 @@ function composeReplaceAvatar( }; } +function cancelMessagesPendingConversationVerification(): ThunkAction< + void, + RootStateType, + unknown, + CancelMessagesPendingConversationVerificationActionType +> { + return async (dispatch, getState) => { + const messageIdsPending = getMessageIdsPendingBecauseOfVerification( + getState() + ); + const messagesStopped = await getMessagesById([...messageIdsPending]); + messagesStopped.forEach(message => { + message.markFailed(); + }); + + dispatch({ + type: CANCEL_MESSAGES_PENDING_CONVERSATION_VERIFICATION, + }); + + await window.Signal.Data.saveMessages( + messagesStopped.map(message => message.attributes) + ); + }; +} + function cantAddContactToGroup( conversationId: string ): CantAddContactToGroupActionType { @@ -1162,9 +1239,22 @@ function conversationChanged( id: string, data: ConversationType ): ThunkAction { - return dispatch => { + return async (dispatch, getState) => { calling.groupMembersChanged(id); + if (!data.isUntrusted) { + const messageIdsPending = + getOwn( + getState().conversations + .outboundMessagesPendingConversationVerification, + id + ) ?? []; + const messagesPending = await getMessagesById(messageIdsPending); + messagesPending.forEach(message => { + message.retrySend(); + }); + } + dispatch({ type: 'CONVERSATION_CHANGED', payload: { @@ -1264,6 +1354,19 @@ function selectMessage( }; } +function messageStoppedByMissingVerification( + messageId: string, + untrustedConversationIds: ReadonlyArray +): MessageStoppedByMissingVerificationActionType { + return { + type: MESSAGE_STOPPED_BY_MISSING_VERIFICATION, + payload: { + messageId, + untrustedConversationIds, + }, + }; +} + function messageChanged( id: string, conversationId: string, @@ -1651,6 +1754,7 @@ export function getEmptyState(): ConversationsStateType { conversationsByE164: {}, conversationsByUuid: {}, conversationsByGroupId: {}, + outboundMessagesPendingConversationVerification: {}, messagesByConversation: {}, messagesLookup: {}, selectedMessageCounter: 0, @@ -1799,6 +1903,13 @@ export function reducer( state: Readonly = getEmptyState(), action: Readonly ): ConversationsStateType { + if (action.type === CANCEL_MESSAGES_PENDING_CONVERSATION_VERIFICATION) { + return { + ...state, + outboundMessagesPendingConversationVerification: {}, + }; + } + if (action.type === 'CANT_ADD_CONTACT_TO_GROUP') { const { composer } = state; if (composer?.step !== ComposerStep.ChooseGroupMembers) { @@ -1887,6 +1998,9 @@ export function reducer( [id]: data, }, ...updateConversationLookups(data, undefined, state), + outboundMessagesPendingConversationVerification: data.isUntrusted + ? state.outboundMessagesPendingConversationVerification + : omit(state.outboundMessagesPendingConversationVerification, id), }; } if (action.type === 'CONVERSATION_CHANGED') { @@ -1933,6 +2047,9 @@ export function reducer( [id]: data, }, ...updateConversationLookups(data, existing, state), + outboundMessagesPendingConversationVerification: data.isUntrusted + ? state.outboundMessagesPendingConversationVerification + : omit(state.outboundMessagesPendingConversationVerification, id), }; } if (action.type === 'CONVERSATION_REMOVED') { @@ -2037,6 +2154,31 @@ export function reducer( selectedMessageCounter: state.selectedMessageCounter + 1, }; } + if (action.type === MESSAGE_STOPPED_BY_MISSING_VERIFICATION) { + const { messageId, untrustedConversationIds } = action.payload; + + const newOutboundMessagesPendingConversationVerification = { + ...state.outboundMessagesPendingConversationVerification, + }; + untrustedConversationIds.forEach(conversationId => { + const existingPendingMessageIds = + getOwn( + newOutboundMessagesPendingConversationVerification, + conversationId + ) ?? []; + if (!existingPendingMessageIds.includes(messageId)) { + newOutboundMessagesPendingConversationVerification[conversationId] = [ + ...existingPendingMessageIds, + messageId, + ]; + } + }); + + return { + ...state, + outboundMessagesPendingConversationVerification: newOutboundMessagesPendingConversationVerification, + }; + } if (action.type === 'MESSAGE_CHANGED') { const { id, conversationId, data } = action.payload; const existingConversation = state.messagesByConversation[conversationId]; diff --git a/ts/state/selectors/conversations.ts b/ts/state/selectors/conversations.ts index e150ecf4f..87e384d14 100644 --- a/ts/state/selectors/conversations.ts +++ b/ts/state/selectors/conversations.ts @@ -18,6 +18,7 @@ import { PreJoinConversationType, } from '../ducks/conversations'; import { getOwn } from '../../util/getOwn'; +import { isNotNil } from '../../util/isNotNil'; import { deconstructLookup } from '../../util/deconstructLookup'; import { PropsDataType as TimelinePropsType } from '../../components/conversation/Timeline'; import { TimelineItemType } from '../../components/conversation/TimelineItem'; @@ -27,6 +28,7 @@ import { filterAndSortConversationsByTitle } from '../../util/filterAndSortConve import { ContactNameColors, ContactNameColorType } from '../../types/Colors'; import { AvatarDataType } from '../../types/Avatar'; import { isInSystemContacts } from '../../util/isInSystemContacts'; +import { sortByTitle } from '../../util/sortByTitle'; import { isGroupV2 } from '../../util/whatTypeOfConversation'; import { @@ -956,3 +958,51 @@ export const getGroupAdminsSelector = createSelector( }; } ); + +const getOutboundMessagesPendingConversationVerification = createSelector( + getConversations, + ( + conversations: Readonly + ): Record> => + conversations.outboundMessagesPendingConversationVerification +); + +const getConversationIdsStoppingMessageSendBecauseOfVerification = createSelector( + getOutboundMessagesPendingConversationVerification, + (outboundMessagesPendingConversationVerification): Array => + Object.keys(outboundMessagesPendingConversationVerification) +); + +export const getConversationsStoppingMessageSendBecauseOfVerification = createSelector( + getConversationByIdSelector, + getConversationIdsStoppingMessageSendBecauseOfVerification, + ( + conversationSelector: (id: string) => undefined | ConversationType, + conversationIds: ReadonlyArray + ): Array => { + const conversations = conversationIds + .map(conversationId => conversationSelector(conversationId)) + .filter(isNotNil); + return sortByTitle(conversations); + } +); + +export const getMessageIdsPendingBecauseOfVerification = createSelector( + getOutboundMessagesPendingConversationVerification, + (outboundMessagesPendingConversationVerification): Set => { + const result = new Set(); + Object.values(outboundMessagesPendingConversationVerification).forEach( + messageGroup => { + messageGroup.forEach(messageId => { + result.add(messageId); + }); + } + ); + return result; + } +); + +export const getNumberOfMessagesPendingBecauseOfVerification = createSelector( + getMessageIdsPendingBecauseOfVerification, + (messageIds: Readonly>): number => messageIds.size +); diff --git a/ts/state/selectors/message.ts b/ts/state/selectors/message.ts index 91f02eb3f..feabc8ffd 100644 --- a/ts/state/selectors/message.ts +++ b/ts/state/selectors/message.ts @@ -67,6 +67,7 @@ import { import { SendStatus, isDelivered, + isFailed, isMessageJustForMe, isRead, isSent, @@ -1234,7 +1235,10 @@ export function getMessagePropStatus( sendStateByConversationId[ourConversationId]?.status ?? SendStatus.Pending; const sent = isSent(status); - if (hasErrors(message)) { + if ( + hasErrors(message) || + someSendStatus(sendStateByConversationId, isFailed) + ) { return sent ? 'partial-sent' : 'error'; } return sent ? 'viewed' : 'sending'; @@ -1248,7 +1252,10 @@ export function getMessagePropStatus( SendStatus.Pending ); - if (hasErrors(message)) { + if ( + hasErrors(message) || + someSendStatus(sendStateByConversationId, isFailed) + ) { return isSent(highestSuccessfulStatus) ? 'partial-sent' : 'error'; } if (isViewed(highestSuccessfulStatus)) { diff --git a/ts/state/smart/App.tsx b/ts/state/smart/App.tsx index 4ec85ff83..68abcefbc 100644 --- a/ts/state/smart/App.tsx +++ b/ts/state/smart/App.tsx @@ -4,18 +4,34 @@ import React from 'react'; import { connect } from 'react-redux'; -import { App, PropsType } from '../../components/App'; +import { App } from '../../components/App'; import { SmartCallManager } from './CallManager'; import { SmartGlobalModalContainer } from './GlobalModalContainer'; +import { SmartSafetyNumberViewer } from './SafetyNumberViewer'; import { StateType } from '../reducer'; -import { getTheme } from '../selectors/user'; +import { getIntl, getTheme } from '../selectors/user'; +import { + getConversationsStoppingMessageSendBecauseOfVerification, + getNumberOfMessagesPendingBecauseOfVerification, +} from '../selectors/conversations'; import { mapDispatchToProps } from '../actions'; +import type { SafetyNumberProps } from '../../components/SafetyNumberChangeDialog'; -const mapStateToProps = (state: StateType): PropsType => { +const mapStateToProps = (state: StateType) => { return { ...state.app, + conversationsStoppingMessageSendBecauseOfVerification: getConversationsStoppingMessageSendBecauseOfVerification( + state + ), + i18n: getIntl(state), + numberOfMessagesPendingBecauseOfVerification: getNumberOfMessagesPendingBecauseOfVerification( + state + ), renderCallManager: () => , renderGlobalModalContainer: () => , + renderSafetyNumber: (props: SafetyNumberProps) => ( + + ), theme: getTheme(state), }; }; diff --git a/ts/state/smart/MessageDetail.tsx b/ts/state/smart/MessageDetail.tsx index bd8e072e8..2e352e190 100644 --- a/ts/state/smart/MessageDetail.tsx +++ b/ts/state/smart/MessageDetail.tsx @@ -32,7 +32,6 @@ const mapStateToProps = ( receivedAt, sentAt, - sendAnyway, showSafetyNumber, displayTapToViewMessage, @@ -71,7 +70,6 @@ const mapStateToProps = ( i18n: getIntl(state), interactionMode: getInteractionMode(state), - sendAnyway, showSafetyNumber, displayTapToViewMessage, diff --git a/ts/test-both/messages/MessageSendState_test.ts b/ts/test-both/messages/MessageSendState_test.ts index 6546c5224..4b783403a 100644 --- a/ts/test-both/messages/MessageSendState_test.ts +++ b/ts/test-both/messages/MessageSendState_test.ts @@ -12,6 +12,7 @@ import { SendStateByConversationId, SendStatus, isDelivered, + isFailed, isMessageJustForMe, isRead, isSent, @@ -106,6 +107,20 @@ describe('message send state utilities', () => { }); }); + describe('isFailed', () => { + it('returns true for failed statuses', () => { + assert.isTrue(isFailed(SendStatus.Failed)); + }); + + it('returns false for non-failed statuses', () => { + assert.isFalse(isFailed(SendStatus.Viewed)); + assert.isFalse(isFailed(SendStatus.Read)); + assert.isFalse(isFailed(SendStatus.Delivered)); + assert.isFalse(isFailed(SendStatus.Sent)); + assert.isFalse(isFailed(SendStatus.Pending)); + }); + }); + describe('someSendStatus', () => { it('returns false if there are no send states', () => { const alwaysTrue = () => true; diff --git a/ts/test-both/state/selectors/conversations_test.ts b/ts/test-both/state/selectors/conversations_test.ts index 1a516da9f..b2a842fb0 100644 --- a/ts/test-both/state/selectors/conversations_test.ts +++ b/ts/test-both/state/selectors/conversations_test.ts @@ -27,11 +27,14 @@ import { getConversationByIdSelector, getConversationsByTitleSelector, getConversationSelector, + getConversationsStoppingMessageSendBecauseOfVerification, getFilteredCandidateContactsForNewGroup, getFilteredComposeContacts, getFilteredComposeGroups, getInvitedContactsForNewlyCreatedGroup, getMaximumGroupSizeModalState, + getMessageIdsPendingBecauseOfVerification, + getNumberOfMessagesPendingBecauseOfVerification, getPlaceholderContact, getRecommendedGroupSizeModalState, getSelectedConversation, @@ -266,6 +269,100 @@ describe('both/state/selectors/conversations', () => { }); }); + describe('#getConversationsStoppingMessageSendBecauseOfVerification', () => { + it('returns an empty array if there are no conversations stopping send', () => { + const state = getEmptyRootState(); + + assert.isEmpty( + getConversationsStoppingMessageSendBecauseOfVerification(state) + ); + }); + + it('returns all conversations stopping message send', () => { + const convo1 = makeConversation('abc'); + const convo2 = makeConversation('def'); + const state = { + ...getEmptyRootState(), + conversations: { + ...getEmptyState(), + conversationLookup: { + def: convo2, + abc: convo1, + }, + outboundMessagesPendingConversationVerification: { + def: ['message 2', 'message 3'], + abc: ['message 1', 'message 2'], + }, + }, + }; + + assert.deepEqual( + getConversationsStoppingMessageSendBecauseOfVerification(state), + [convo1, convo2] + ); + }); + }); + + describe('#getMessageIdsPendingBecauseOfVerification', () => { + it('returns an empty set if there are no conversations stopping send', () => { + const state = getEmptyRootState(); + + assert.deepEqual( + getMessageIdsPendingBecauseOfVerification(state), + new Set() + ); + }); + + it('returns a set of unique pending messages', () => { + const state = { + ...getEmptyRootState(), + conversations: { + ...getEmptyState(), + outboundMessagesPendingConversationVerification: { + abc: ['message 2', 'message 3'], + def: ['message 1', 'message 2'], + ghi: ['message 4'], + }, + }, + }; + + assert.deepEqual( + getMessageIdsPendingBecauseOfVerification(state), + new Set(['message 1', 'message 2', 'message 3', 'message 4']) + ); + }); + }); + + describe('#getNumberOfMessagesPendingBecauseOfVerification', () => { + it('returns 0 if there are no conversations stopping send', () => { + const state = getEmptyRootState(); + + assert.strictEqual( + getNumberOfMessagesPendingBecauseOfVerification(state), + 0 + ); + }); + + it('returns a count of unique pending messages', () => { + const state = { + ...getEmptyRootState(), + conversations: { + ...getEmptyState(), + outboundMessagesPendingConversationVerification: { + abc: ['message 2', 'message 3'], + def: ['message 1', 'message 2'], + ghi: ['message 4'], + }, + }, + }; + + assert.strictEqual( + getNumberOfMessagesPendingBecauseOfVerification(state), + 4 + ); + }); + }); + describe('#getInvitedContactsForNewlyCreatedGroup', () => { it('returns an empty array if there are no invited contacts', () => { const state = getEmptyRootState(); diff --git a/ts/test-electron/state/ducks/conversations_test.ts b/ts/test-electron/state/ducks/conversations_test.ts index fcc3fa29e..72fb4db7b 100644 --- a/ts/test-electron/state/ducks/conversations_test.ts +++ b/ts/test-electron/state/ducks/conversations_test.ts @@ -45,6 +45,7 @@ const { closeRecommendedGroupSizeModal, createGroup, messageSizeChanged, + messageStoppedByMissingVerification, openConversationInternal, repairNewestMessage, repairOldestMessage, @@ -888,6 +889,35 @@ describe('both/state/ducks/conversations', () => { }); }); + describe('MESSAGE_STOPPED_BY_MISSING_VERIFICATION', () => { + it('adds messages that need conversation verification, removing duplicates', () => { + const first = reducer( + getEmptyState(), + messageStoppedByMissingVerification('message 1', ['convo 1']) + ); + const second = reducer( + first, + messageStoppedByMissingVerification('message 1', ['convo 2']) + ); + const third = reducer( + second, + messageStoppedByMissingVerification('message 2', [ + 'convo 1', + 'convo 3', + ]) + ); + + assert.deepStrictEqual( + third.outboundMessagesPendingConversationVerification, + { + 'convo 1': ['message 1', 'message 2'], + 'convo 2': ['message 1'], + 'convo 3': ['message 2'], + } + ); + }); + }); + describe('REPAIR_NEWEST_MESSAGE', () => { it('updates newest', () => { const action = repairNewestMessage(conversationId); diff --git a/ts/test-node/jobs/JobQueueDatabaseStore_test.ts b/ts/test-node/jobs/JobQueueDatabaseStore_test.ts index 5923d9015..aab2d9d48 100644 --- a/ts/test-node/jobs/JobQueueDatabaseStore_test.ts +++ b/ts/test-node/jobs/JobQueueDatabaseStore_test.ts @@ -92,6 +92,32 @@ describe('JobQueueDatabaseStore', () => { assert.deepEqual(events, ['insert', 'yielded job']); }); + it('can skip the database', async () => { + const store = new JobQueueDatabaseStore(fakeDatabase); + + const streamPromise = (async () => { + // We don't actually care about using the variable from the async iterable. + // eslint-disable-next-line @typescript-eslint/no-unused-vars + for await (const _job of store.stream('test queue')) { + break; + } + })(); + + await store.insert( + { + id: 'abc', + timestamp: 1234, + queueType: 'test queue', + data: { hi: 5 }, + }, + { shouldInsertIntoDatabase: false } + ); + + await streamPromise; + + sinon.assert.notCalled(fakeDatabase.insertJob); + }); + it("doesn't insert jobs until the initial fetch has completed", async () => { const events: Array = []; diff --git a/ts/test-node/jobs/formatJobForInsert_test.ts b/ts/test-node/jobs/formatJobForInsert_test.ts new file mode 100644 index 000000000..7c23c88de --- /dev/null +++ b/ts/test-node/jobs/formatJobForInsert_test.ts @@ -0,0 +1,27 @@ +// Copyright 2021 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +import { assert } from 'chai'; + +import { formatJobForInsert } from '../../jobs/formatJobForInsert'; + +describe('formatJobForInsert', () => { + it('removes non-essential properties', () => { + const input = { + id: 'abc123', + timestamp: 1234, + queueType: 'test queue', + data: { foo: 'bar' }, + extra: 'ignored', + alsoIgnored: true, + }; + const output = formatJobForInsert(input); + + assert.deepEqual(output, { + id: 'abc123', + timestamp: 1234, + queueType: 'test queue', + data: { foo: 'bar' }, + }); + }); +}); diff --git a/ts/textsecure/SendMessage.ts b/ts/textsecure/SendMessage.ts index c2d779d1d..bcaf7d506 100644 --- a/ts/textsecure/SendMessage.ts +++ b/ts/textsecure/SendMessage.ts @@ -77,7 +77,7 @@ export type SendOptionsType = { online?: boolean; }; -type PreviewType = { +export type PreviewType = { url: string; title: string; image?: AttachmentType; diff --git a/ts/views/conversation_view.ts b/ts/views/conversation_view.ts index 873f5a1de..7c7c8fe95 100644 --- a/ts/views/conversation_view.ts +++ b/ts/views/conversation_view.ts @@ -145,7 +145,6 @@ type MessageActionsType = { ) => unknown; replyToMessage: (messageId: string) => unknown; retrySend: (messageId: string) => unknown; - sendAnyway: (contactId: string, messageId: string) => unknown; showContactDetail: (options: { contact: EmbeddedContactType; signalAccount?: string; @@ -1057,9 +1056,6 @@ export class ConversationView extends window.Backbone.View { const downloadNewVersion = () => { this.downloadNewVersion(); }; - const sendAnyway = (contactId: string, messageId: string) => { - this.forceSend({ contactId, messageId }); - }; const showSafetyNumber = (contactId: string) => { this.showSafetyNumber(contactId); }; @@ -1092,7 +1088,6 @@ export class ConversationView extends window.Backbone.View { reactToMessage, replyToMessage, retrySend, - sendAnyway, showContactDetail, showContactModal, showSafetyNumber, @@ -2657,7 +2652,7 @@ export class ConversationView extends window.Backbone.View { } : undefined; - conversation.sendMessage( + conversation.enqueueMessageForSend( undefined, // body [], undefined, // quote @@ -2683,7 +2678,7 @@ export class ConversationView extends window.Backbone.View { ) ); - conversation.sendMessage( + conversation.enqueueMessageForSend( messageBody || undefined, attachmentsToSend, undefined, // quote @@ -2945,49 +2940,6 @@ export class ConversationView extends window.Backbone.View { view.render(); } - // eslint-disable-next-line class-methods-use-this - forceSend({ - contactId, - messageId, - }: Readonly<{ contactId: string; messageId: string }>): void { - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - const contact = window.ConversationController.get(contactId)!; - const message = window.MessageController.getById(messageId); - if (!message) { - throw new Error(`forceSend: Message ${messageId} missing!`); - } - - window.showConfirmationDialog({ - confirmStyle: 'negative', - message: window.i18n('identityKeyErrorOnSend', { - name1: contact.getTitle(), - name2: contact.getTitle(), - }), - okText: window.i18n('sendAnyway'), - resolve: async () => { - await contact.updateVerified(); - - if (contact.isUnverified()) { - await contact.setVerifiedDefault(); - } - - const untrusted = await contact.isUntrusted(); - if (untrusted) { - contact.setApproved(); - } - - const sendTarget = contact.getSendTarget(); - if (!sendTarget) { - throw new Error( - `forceSend: Contact ${contact.idForLogging()} had no sendTarget!` - ); - } - - message.resend(sendTarget); - }, - }); - } - showSafetyNumber(id?: string): void { let conversation: undefined | ConversationModel; @@ -4200,7 +4152,7 @@ export class ConversationView extends window.Backbone.View { window.log.info('Send pre-checks took', sendDelta, 'milliseconds'); batchedUpdates(() => { - model.sendMessage( + model.enqueueMessageForSend( message, attachments, this.quote,