Retry outbound "normal" messages for up to a day

This commit is contained in:
Evan Hahn 2021-08-31 15:58:39 -05:00 committed by GitHub
parent 62cf51c060
commit a85dd1be36
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
30 changed files with 1414 additions and 603 deletions

View file

@ -415,6 +415,20 @@
"message": "The following people may have reinstalled or changed devices. Verify your safety number with them to ensure privacy.", "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" "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": { "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.", "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", "description": "Shown when user clicks on a failed recipient in the message detail view after an identity key change",

View file

@ -940,6 +940,7 @@ export async function startApp(): Promise<void> {
), ),
messagesByConversation: {}, messagesByConversation: {},
messagesLookup: {}, messagesLookup: {},
outboundMessagesPendingConversationVerification: {},
selectedConversationId: undefined, selectedConversationId: undefined,
selectedMessage: undefined, selectedMessage: undefined,
selectedMessageCounter: 0, selectedMessageCounter: 0,

View file

@ -1,7 +1,7 @@
// Copyright 2021 Signal Messenger, LLC // Copyright 2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only // SPDX-License-Identifier: AGPL-3.0-only
import React, { useEffect } from 'react'; import React, { ComponentProps, useEffect } from 'react';
import classNames from 'classnames'; import classNames from 'classnames';
import { AppViewType } from '../state/ducks/app'; import { AppViewType } from '../state/ducks/app';
@ -11,20 +11,25 @@ import { StandaloneRegistration } from './StandaloneRegistration';
import { ThemeType } from '../types/Util'; import { ThemeType } from '../types/Util';
import { usePageVisibility } from '../util/hooks'; import { usePageVisibility } from '../util/hooks';
export type PropsType = { type PropsType = {
appView: AppViewType; appView: AppViewType;
hasInitialLoadCompleted: boolean;
renderCallManager: () => JSX.Element; renderCallManager: () => JSX.Element;
renderGlobalModalContainer: () => JSX.Element; renderGlobalModalContainer: () => JSX.Element;
theme: ThemeType; theme: ThemeType;
}; } & ComponentProps<typeof Inbox>;
export const App = ({ export const App = ({
appView, appView,
cancelMessagesPendingConversationVerification,
conversationsStoppingMessageSendBecauseOfVerification,
hasInitialLoadCompleted, hasInitialLoadCompleted,
i18n,
numberOfMessagesPendingBecauseOfVerification,
renderCallManager, renderCallManager,
renderGlobalModalContainer, renderGlobalModalContainer,
renderSafetyNumber,
theme, theme,
verifyConversationsStoppingMessageSend,
}: PropsType): JSX.Element => { }: PropsType): JSX.Element => {
let contents; let contents;
@ -33,7 +38,25 @@ export const App = ({
} else if (appView === AppViewType.Standalone) { } else if (appView === AppViewType.Standalone) {
contents = <StandaloneRegistration />; contents = <StandaloneRegistration />;
} else if (appView === AppViewType.Inbox) { } else if (appView === AppViewType.Inbox) {
contents = <Inbox hasInitialLoadCompleted={hasInitialLoadCompleted} />; contents = (
<Inbox
cancelMessagesPendingConversationVerification={
cancelMessagesPendingConversationVerification
}
conversationsStoppingMessageSendBecauseOfVerification={
conversationsStoppingMessageSendBecauseOfVerification
}
hasInitialLoadCompleted={hasInitialLoadCompleted}
i18n={i18n}
numberOfMessagesPendingBecauseOfVerification={
numberOfMessagesPendingBecauseOfVerification
}
renderSafetyNumber={renderSafetyNumber}
verifyConversationsStoppingMessageSend={
verifyConversationsStoppingMessageSend
}
/>
);
} }
// This are here so that themes are properly applied to anything that is // This are here so that themes are properly applied to anything that is

View file

@ -1,8 +1,14 @@
// Copyright 2021 Signal Messenger, LLC // Copyright 2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only // SPDX-License-Identifier: AGPL-3.0-only
import React, { useEffect, useRef } from 'react'; import React, { ReactNode, useEffect, useRef } from 'react';
import * as Backbone from 'backbone'; 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 & { type InboxViewType = Backbone.View & {
onEmpty?: () => void; onEmpty?: () => void;
@ -14,10 +20,24 @@ type InboxViewOptionsType = Backbone.ViewOptions & {
}; };
export type PropsType = { export type PropsType = {
cancelMessagesPendingConversationVerification: () => void;
conversationsStoppingMessageSendBecauseOfVerification: Array<ConversationType>;
hasInitialLoadCompleted: boolean; 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<HTMLDivElement | null>(null); const hostRef = useRef<HTMLDivElement | null>(null);
const viewRef = useRef<InboxViewType | undefined>(undefined); const viewRef = useRef<InboxViewType | undefined>(undefined);
@ -47,5 +67,30 @@ export const Inbox = ({ hasInitialLoadCompleted }: PropsType): JSX.Element => {
} }
}, [hasInitialLoadCompleted, viewRef]); }, [hasInitialLoadCompleted, viewRef]);
return <div className="inbox index" ref={hostRef} />; let safetyNumberChangeDialog: ReactNode;
if (conversationsStoppingMessageSendBecauseOfVerification.length) {
const confirmText: string =
numberOfMessagesPendingBecauseOfVerification === 1
? i18n('safetyNumberChangeDialog__pending-messages--1')
: i18n('safetyNumberChangeDialog__pending-messages--many', [
numberOfMessagesPendingBecauseOfVerification.toString(),
]);
safetyNumberChangeDialog = (
<SafetyNumberChangeDialog
confirmText={confirmText}
contacts={conversationsStoppingMessageSendBecauseOfVerification}
i18n={i18n}
onCancel={cancelMessagesPendingConversationVerification}
onConfirm={verifyConversationsStoppingMessageSend}
renderSafetyNumber={renderSafetyNumber}
/>
);
}
return (
<>
<div className="inbox index" ref={hostRef} />
{safetyNumberChangeDialog}
</>
);
}; };

View file

@ -61,7 +61,6 @@ const createProps = (overrideProps: Partial<Props> = {}): Props => ({
i18n, i18n,
interactionMode: 'keyboard', interactionMode: 'keyboard',
sendAnyway: action('onSendAnyway'),
showSafetyNumber: action('onShowSafetyNumber'), showSafetyNumber: action('onShowSafetyNumber'),
checkForAccount: action('checkForAccount'), checkForAccount: action('checkForAccount'),

View file

@ -53,7 +53,6 @@ export type PropsData = {
receivedAt: number; receivedAt: number;
sentAt: number; sentAt: number;
sendAnyway: (contactId: string, messageId: string) => unknown;
showSafetyNumber: (contactId: string) => void; showSafetyNumber: (contactId: string) => void;
i18n: LocalizerType; i18n: LocalizerType;
} & Pick<MessagePropsType, 'interactionMode'>; } & Pick<MessagePropsType, 'interactionMode'>;
@ -145,7 +144,7 @@ export class MessageDetail extends React.Component<Props> {
} }
public renderContact(contact: Contact): JSX.Element { public renderContact(contact: Contact): JSX.Element {
const { i18n, message, showSafetyNumber, sendAnyway } = this.props; const { i18n, showSafetyNumber } = this.props;
const errors = contact.errors || []; const errors = contact.errors || [];
const errorComponent = contact.isOutgoingKeyError ? ( const errorComponent = contact.isOutgoingKeyError ? (
@ -157,13 +156,6 @@ export class MessageDetail extends React.Component<Props> {
> >
{i18n('showSafetyNumber')} {i18n('showSafetyNumber')}
</button> </button>
<button
type="button"
className="module-message-detail__contact__send-anyway"
onClick={() => sendAnyway(contact.id, message.id)}
>
{i18n('sendAnyway')}
</button>
</div> </div>
) : null; ) : null;
const unidentifiedDeliveryComponent = contact.isUnidentifiedDelivery ? ( const unidentifiedDeliveryComponent = contact.isUnidentifiedDelivery ? (

View file

@ -136,12 +136,23 @@ export abstract class JobQueue<T> {
* If `streamJobs` has not been called yet, this will throw an error. * If `streamJobs` has not been called yet, this will throw an error.
*/ */
async add(data: Readonly<T>): Promise<Job<T>> { async add(data: Readonly<T>): Promise<Job<T>> {
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) { if (!this.started) {
throw new Error( throw new Error(
`${this.logPrefix} has not started streaming. Make sure to call streamJobs().` `${this.logPrefix} has not started streaming. Make sure to call streamJobs().`
); );
} }
}
protected createJob(data: Readonly<T>): Job<T> {
const id = uuid(); const id = uuid();
const timestamp = Date.now(); const timestamp = Date.now();
@ -158,11 +169,7 @@ export abstract class JobQueue<T> {
} }
})(); })();
log.info(`${this.logPrefix} added new job ${id}`); return new Job(id, timestamp, this.queueType, data, completion);
const job = new Job(id, timestamp, this.queueType, data, completion);
await this.store.insert(job);
return job;
} }
private async enqueueStoredJob(storedJob: Readonly<StoredJob>) { private async enqueueStoredJob(storedJob: Readonly<StoredJob>) {

View file

@ -1,10 +1,11 @@
// Copyright 2021 Signal Messenger, LLC // Copyright 2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only // SPDX-License-Identifier: AGPL-3.0-only
import { pick, noop } from 'lodash'; import { noop } from 'lodash';
import { AsyncQueue } from '../util/AsyncQueue'; import { AsyncQueue } from '../util/AsyncQueue';
import { concat, wrapPromise } from '../util/asyncIterables'; import { concat, wrapPromise } from '../util/asyncIterables';
import { JobQueueStore, StoredJob } from './types'; import { JobQueueStore, StoredJob } from './types';
import { formatJobForInsert } from './formatJobForInsert';
import databaseInterface from '../sql/Client'; import databaseInterface from '../sql/Client';
import * as log from '../logging/log'; import * as log from '../logging/log';
@ -23,7 +24,12 @@ export class JobQueueDatabaseStore implements JobQueueStore {
constructor(private readonly db: Database) {} constructor(private readonly db: Database) {}
async insert(job: Readonly<StoredJob>): Promise<void> { async insert(
job: Readonly<StoredJob>,
{
shouldInsertIntoDatabase = true,
}: Readonly<{ shouldInsertIntoDatabase?: boolean }> = {}
): Promise<void> {
log.info( log.info(
`JobQueueDatabaseStore adding job ${job.id} to queue ${JSON.stringify( `JobQueueDatabaseStore adding job ${job.id} to queue ${JSON.stringify(
job.queueType job.queueType
@ -40,9 +46,9 @@ export class JobQueueDatabaseStore implements JobQueueStore {
} }
await initialFetchPromise; await initialFetchPromise;
await this.db.insertJob( if (shouldInsertIntoDatabase) {
pick(job, ['id', 'timestamp', 'queueType', 'data']) await this.db.insertJob(formatJobForInsert(job));
); }
this.getQueue(job.queueType).add(job); this.getQueue(job.queueType).add(job);
} }

View file

@ -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 | ParsedJob<unknown>>
): StoredJob => ({
id: job.id,
timestamp: job.timestamp,
queueType: job.queueType,
data: job.data,
});

View file

@ -3,6 +3,7 @@
import type { WebAPIType } from '../textsecure/WebAPI'; import type { WebAPIType } from '../textsecure/WebAPI';
import { normalMessageSendJobQueue } from './normalMessageSendJobQueue';
import { readSyncJobQueue } from './readSyncJobQueue'; import { readSyncJobQueue } from './readSyncJobQueue';
import { removeStorageKeyJobQueue } from './removeStorageKeyJobQueue'; import { removeStorageKeyJobQueue } from './removeStorageKeyJobQueue';
import { reportSpamJobQueue } from './reportSpamJobQueue'; import { reportSpamJobQueue } from './reportSpamJobQueue';
@ -19,6 +20,7 @@ export function initializeAllJobQueues({
}): void { }): void {
reportSpamJobQueue.initialize({ server }); reportSpamJobQueue.initialize({ server });
normalMessageSendJobQueue.streamJobs();
readSyncJobQueue.streamJobs(); readSyncJobQueue.streamJobs();
removeStorageKeyJobQueue.streamJobs(); removeStorageKeyJobQueue.streamJobs();
reportSpamJobQueue.streamJobs(); reportSpamJobQueue.streamJobs();

View file

@ -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<NormalMessageSendJobData> {
private readonly queues = new Map<string, PQueue>();
/**
* 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<NormalMessageSendJobData>,
insert?: (job: ParsedJob<NormalMessageSendJobData>) => Promise<void>
): Promise<Job<NormalMessageSendJobData>> {
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<void>): Promise<void> {
return this.getQueue(queueKey).add(fn);
}
protected async run(
{
data,
timestamp,
}: Readonly<{ data: NormalMessageSendJobData; timestamp: number }>,
{ attempt, log }: Readonly<{ attempt: number; log: LoggerType }>
): Promise<void> {
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<Error> = [];
// 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<Error>) => {
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<unknown>;
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<CallbackResultType>;
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<string>;
recipientIdentifiersWithoutMe: Array<string>;
untrustedConversationIds: Array<string>;
} {
const allRecipientIdentifiers: Array<string> = [];
const recipientIdentifiersWithoutMe: Array<string> = [];
const untrustedConversationIds: Array<string> = [];
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<AttachmentType>;
body: undefined | string;
deletedForEveryoneTimestamp: undefined | number;
expireTimer: undefined | number;
mentions: undefined | BodyRangesType;
messageTimestamp: number;
preview: Array<PreviewType>;
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<Error>
): Promise<void> {
message.markFailed();
message.saveErrors(errors, { skipSave: true });
await window.Signal.Data.saveMessage(message.attributes);
}
function didSendToEveryone(message: Readonly<MessageModel>): boolean {
const sendStateByConversationId =
message.get('sendStateByConversationId') || {};
return Object.values(sendStateByConversationId).every(sendState =>
isSent(sendState.status)
);
}
function updateRecipients(
groupInfo: undefined | GroupV1InfoType,
recipients: Array<string>
): undefined | GroupV1InfoType;
function updateRecipients(
groupInfo: undefined | GroupV2InfoType,
recipients: Array<string>
): undefined | GroupV2InfoType;
function updateRecipients(
groupInfo: undefined | GroupV1InfoType | GroupV2InfoType,
recipients: Array<string>
): undefined | GroupV1InfoType | GroupV2InfoType {
return (
groupInfo && {
...groupInfo,
members: recipients,
}
);
}

View file

@ -59,6 +59,8 @@ export const isDelivered = (status: SendStatus): boolean =>
STATUS_NUMBERS[status] >= STATUS_NUMBERS[SendStatus.Delivered]; STATUS_NUMBERS[status] >= STATUS_NUMBERS[SendStatus.Delivered];
export const isSent = (status: SendStatus): boolean => export const isSent = (status: SendStatus): boolean =>
STATUS_NUMBERS[status] >= STATUS_NUMBERS[SendStatus.Sent]; 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 * `SendState` combines `SendStatus` and a timestamp. You can use it to show things to the

View file

@ -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<string>
): Promise<Array<MessageModel>> {
const messagesFromMemory: Array<MessageModel> = [];
const messageIdsToLookUpInDatabase: Array<string> = [];
messageIds.forEach(messageId => {
const message = window.MessageController.getById(messageId);
if (message) {
messagesFromMemory.push(message);
} else {
messageIdsToLookUpInDatabase.push(messageId);
}
});
let rawMessagesFromDatabase: Array<MessageAttributesType>;
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];
}

View file

@ -29,6 +29,7 @@ import {
CustomColorType, CustomColorType,
} from '../types/Colors'; } from '../types/Colors';
import { MessageModel } from './messages'; import { MessageModel } from './messages';
import { strictAssert } from '../util/assert';
import { isMuted } from '../util/isMuted'; import { isMuted } from '../util/isMuted';
import { isConversationSMSOnly } from '../util/isConversationSMSOnly'; import { isConversationSMSOnly } from '../util/isConversationSMSOnly';
import { isConversationUnregistered } from '../util/isConversationUnregistered'; import { isConversationUnregistered } from '../util/isConversationUnregistered';
@ -82,6 +83,7 @@ import {
isTapToView, isTapToView,
getMessagePropStatus, getMessagePropStatus,
} from '../state/selectors/message'; } from '../state/selectors/message';
import { normalMessageSendJobQueue } from '../jobs/normalMessageSendJobQueue';
import { Deletes } from '../messageModifiers/Deletes'; import { Deletes } from '../messageModifiers/Deletes';
import { Reactions, ReactionModel } from '../messageModifiers/Reactions'; import { Reactions, ReactionModel } from '../messageModifiers/Reactions';
import { isAnnouncementGroupReady } from '../util/isAnnouncementGroupReady'; import { isAnnouncementGroupReady } from '../util/isAnnouncementGroupReady';
@ -119,11 +121,6 @@ const ATTRIBUTES_THAT_DONT_INVALIDATE_PROPS_CACHE = new Set([
'profileLastFetchedAt', 'profileLastFetchedAt',
]); ]);
type CustomError = Error & {
identifier?: string;
number?: string;
};
type CachedIdenticon = { type CachedIdenticon = {
readonly url: string; readonly url: string;
readonly content: string; readonly content: string;
@ -3111,6 +3108,10 @@ export class ConversationModel extends window.Backbone
); );
} }
getRecipientConversationIds(): Set<string> {
return new Set(map(this.getMembers(), conversation => conversation.id));
}
async getQuoteAttachment( async getQuoteAttachment(
attachments?: Array<WhatIsThis>, attachments?: Array<WhatIsThis>,
preview?: Array<WhatIsThis>, preview?: Array<WhatIsThis>,
@ -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); window.reduxActions.stickers.useSticker(packId, stickerId);
} }
@ -3577,7 +3578,7 @@ export class ConversationModel extends window.Backbone
); );
} }
sendMessage( async enqueueMessageForSend(
body: string | undefined, body: string | undefined,
attachments: Array<AttachmentType>, attachments: Array<AttachmentType>,
quote?: QuotedMessageType, quote?: QuotedMessageType,
@ -3593,7 +3594,7 @@ export class ConversationModel extends window.Backbone
sendHQImages?: boolean; sendHQImages?: boolean;
timestamp?: number; timestamp?: number;
} = {} } = {}
): void { ): Promise<void> {
if (this.isGroupV1AndDisabled()) { if (this.isGroupV1AndDisabled()) {
return; return;
} }
@ -3614,223 +3615,134 @@ export class ConversationModel extends window.Backbone
const destination = this.getSendTarget()!; const destination = this.getSendTarget()!;
const recipients = this.getRecipients(); const recipients = this.getRecipients();
if (timestamp) { const now = timestamp || Date.now();
window.log.info(`sendMessage: Queueing send with timestamp ${timestamp}`);
}
this.queueJob('sendMessage', async () => {
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( window.log.info(
'Sending message to conversation', 'Sending message to conversation',
this.idForLogging(), this.idForLogging(),
'with timestamp', 'with timestamp',
now now
); );
const recipientMaybeConversations = map(recipients, identifier => const recipientMaybeConversations = map(recipients, identifier =>
window.ConversationController.get(identifier) window.ConversationController.get(identifier)
); );
const recipientConversations = filter( const recipientConversations = filter(
recipientMaybeConversations, recipientMaybeConversations,
isNotNil isNotNil
); );
const recipientConversationIds = concat( const recipientConversationIds = concat(
map(recipientConversations, c => c.id), map(recipientConversations, c => c.id),
[window.ConversationController.getOurConversationIdOrThrow()] [window.ConversationController.getOurConversationIdOrThrow()]
); );
// Here we move attachments to disk // Here we move attachments to disk
const messageWithSchema = await upgradeMessageSchema({ const messageWithSchema = await upgradeMessageSchema({
timestamp: now, timestamp: now,
type: 'outgoing', type: 'outgoing',
body, body,
conversationId: this.id, conversationId: this.id,
quote, quote,
preview, preview,
attachments, attachments,
sent_at: now, sent_at: now,
received_at: window.Signal.Util.incrementMessageCounter(), received_at: window.Signal.Util.incrementMessageCounter(),
received_at_ms: now, received_at_ms: now,
expireTimer, expireTimer,
recipients, recipients,
sticker, sticker,
bodyRanges: mentions, bodyRanges: mentions,
sendHQImages, sendHQImages,
sendStateByConversationId: zipObject( sendStateByConversationId: zipObject(
recipientConversationIds, recipientConversationIds,
repeat({ repeat({
status: SendStatus.Pending, status: SendStatus.Pending,
updatedAt: now, 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',
}) })
); ),
}); });
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? // Is this someone who is a contact, or are we sharing our profile with them?

View file

@ -1,7 +1,7 @@
// Copyright 2020-2021 Signal Messenger, LLC // Copyright 2020-2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only // 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 { import {
CustomError, CustomError,
GroupV1Update, GroupV1Update,
@ -43,7 +43,6 @@ import {
import * as Stickers from '../types/Stickers'; import * as Stickers from '../types/Stickers';
import { AttachmentType, isImage, isVideo } from '../types/Attachment'; import { AttachmentType, isImage, isVideo } from '../types/Attachment';
import { IMAGE_WEBP, stringToMIMEType } from '../types/MIME'; import { IMAGE_WEBP, stringToMIMEType } from '../types/MIME';
import { ourProfileKeyService } from '../services/ourProfileKey';
import { ReadStatus } from '../messages/MessageReadStatus'; import { ReadStatus } from '../messages/MessageReadStatus';
import { import {
SendActionType, SendActionType,
@ -112,6 +111,8 @@ import { ViewOnceOpenSyncs } from '../messageModifiers/ViewOnceOpenSyncs';
import * as AttachmentDownloads from '../messageModifiers/AttachmentDownloads'; import * as AttachmentDownloads from '../messageModifiers/AttachmentDownloads';
import * as LinkPreview from '../types/LinkPreview'; import * as LinkPreview from '../types/LinkPreview';
import { SignalService as Proto } from '../protobuf'; import { SignalService as Proto } from '../protobuf';
import { normalMessageSendJobQueue } from '../jobs/normalMessageSendJobQueue';
import type { PreviewType as OutgoingPreviewType } from '../textsecure/SendMessage';
/* eslint-disable camelcase */ /* eslint-disable camelcase */
/* eslint-disable more/no-then */ /* eslint-disable more/no-then */
@ -134,10 +135,6 @@ const {
} = window.Signal.Types; } = window.Signal.Types;
const { const {
deleteExternalMessageFiles, deleteExternalMessageFiles,
loadAttachmentData,
loadQuoteData,
loadPreviewData,
loadStickerData,
upgradeMessageSchema, upgradeMessageSchema,
} = window.Signal.Migrations; } = window.Signal.Migrations;
const { getTextWithMentions, GoogleChrome } = window.Signal.Util; const { getTextWithMentions, GoogleChrome } = window.Signal.Util;
@ -190,6 +187,12 @@ export class MessageModel extends window.Backbone.Model<MessageAttributesType> {
syncPromise?: Promise<CallbackResultType | void>; syncPromise?: Promise<CallbackResultType | void>;
cachedOutgoingPreviewData?: Array<OutgoingPreviewType>;
cachedOutgoingQuoteData?: WhatIsThis;
cachedOutgoingStickerData?: WhatIsThis;
initialize(attributes: unknown): void { initialize(attributes: unknown): void {
if (_.isObject(attributes)) { if (_.isObject(attributes)) {
this.set( this.set(
@ -1200,42 +1203,27 @@ export class MessageModel extends window.Backbone.Model<MessageAttributesType> {
return window.ConversationController.getOrCreate(source, 'private'); return window.ConversationController.getOrCreate(source, 'private');
} }
// Send infrastructure async retrySend(): Promise<void> {
// One caller today: event handler for the 'Retry Send' entry in triple-dot menu
async retrySend(): Promise<string | null | void | Array<void>> {
if (!window.textsecure.messaging) {
window.log.error('retrySend: Cannot retry since we are offline!');
return null;
}
const retryOptions = this.get('retryOptions'); const retryOptions = this.get('retryOptions');
this.set({ errors: undefined, retryOptions: undefined });
if (retryOptions) { 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); return this.sendUtilityMessageWithRetry(retryOptions);
} }
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
const conversation = this.getConversation()!; const conversation = this.getConversation()!;
const currentRecipients = new Set<string>(
conversation
.getRecipients()
.map(identifier =>
window.ConversationController.getConversationId(identifier)
)
.filter(isNotNil)
);
const profileKey = conversation.get('profileSharing') const currentConversationRecipients = conversation.getRecipientConversationIds();
? await ourProfileKeyService.get()
: undefined;
// Determine retry recipients and get their most up-to-date addressing information // Determine retry recipients and get their most up-to-date addressing information
const oldSendStateByConversationId = const oldSendStateByConversationId =
this.get('sendStateByConversationId') || {}; this.get('sendStateByConversationId') || {};
const recipients: Array<string> = [];
const newSendStateByConversationId = { ...oldSendStateByConversationId }; const newSendStateByConversationId = { ...oldSendStateByConversationId };
for (const [conversationId, sendState] of Object.entries( for (const [conversationId, sendState] of Object.entries(
oldSendStateByConversationId oldSendStateByConversationId
@ -1244,15 +1232,12 @@ export class MessageModel extends window.Backbone.Model<MessageAttributesType> {
continue; continue;
} }
const isStillInConversation = currentRecipients.has(conversationId); const recipient = window.ConversationController.get(conversationId);
if (!isStillInConversation) { if (
continue; !recipient ||
} (!currentConversationRecipients.has(conversationId) &&
!isMe(recipient.attributes))
const recipient = window.ConversationController.get( ) {
conversationId
)?.getSendTarget();
if (!recipient) {
continue; continue;
} }
@ -1263,133 +1248,15 @@ export class MessageModel extends window.Backbone.Model<MessageAttributesType> {
updatedAt: Date.now(), updatedAt: Date.now(),
} }
); );
recipients.push(recipient);
} }
this.set('sendStateByConversationId', newSendStateByConversationId); this.set('sendStateByConversationId', newSendStateByConversationId);
await window.Signal.Data.saveMessage(this.attributes); await normalMessageSendJobQueue.add(
{ messageId: this.id, conversationId: conversation.id },
if (!recipients.length) { async jobToInsert => {
window.log.warn('retrySend: Nobody to send to!'); await window.Signal.Data.saveMessage(this.attributes, { jobToInsert });
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");
} }
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<MessageAttributesType> {
return isEmpty(withoutMe) || someSendStatus(withoutMe, isSent); 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() * Change any Pending send state to Failed. Note that this will not mark successful
async resend(identifier: string): Promise<void | null | Array<void>> { * sends failed.
const error = this.removeOutgoingErrors(identifier); */
if (!error) { public markFailed(): void {
window.log.warn( const now = Date.now();
'resend: requested number was not present in errors. continuing.' this.set(
); 'sendStateByConversationId',
} mapValues(this.get('sendStateByConversationId') || {}, sendState =>
sendStateReducer(sendState, {
if (this.isErased()) { type: SendActionType.Failed,
window.log.warn('resend: message is erased; refusing to resend'); updatedAt: now,
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',
})
); );
} }
@ -1552,7 +1321,8 @@ export class MessageModel extends window.Backbone.Model<MessageAttributesType> {
} }
async send( async send(
promise: Promise<CallbackResultType | void | null> promise: Promise<CallbackResultType | void | null>,
saveErrors?: (errors: Array<Error>) => void
): Promise<void | Array<void>> { ): Promise<void | Array<void>> {
const updateLeftPane = const updateLeftPane =
this.getConversation()?.debouncedUpdateLastMessage || noop; this.getConversation()?.debouncedUpdateLastMessage || noop;
@ -1655,7 +1425,7 @@ export class MessageModel extends window.Backbone.Model<MessageAttributesType> {
window.ConversationController.get(error.identifier) || window.ConversationController.get(error.identifier) ||
window.ConversationController.get(error.number); window.ConversationController.get(error.number);
if (conversation) { if (conversation && !saveErrors) {
const previousSendState = getOwn( const previousSendState = getOwn(
sendStateByConversationId, sendStateByConversationId,
conversation.id conversation.id
@ -1719,8 +1489,12 @@ export class MessageModel extends window.Backbone.Model<MessageAttributesType> {
attributesToUpdate.errors = []; attributesToUpdate.errors = [];
this.set(attributesToUpdate); this.set(attributesToUpdate);
// We skip save because we'll save in the next step. if (saveErrors) {
this.saveErrors(errorsToSave, { skipSave: true }); saveErrors(errorsToSave);
} else {
// We skip save because we'll save in the next step.
this.saveErrors(errorsToSave, { skipSave: true });
}
if (!this.doNotSave) { if (!this.doNotSave) {
await window.Signal.Data.saveMessage(this.attributes); await window.Signal.Data.saveMessage(this.attributes);
@ -1734,6 +1508,14 @@ export class MessageModel extends window.Backbone.Model<MessageAttributesType> {
await Promise.all(promises); 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(); updateLeftPane();
} }
@ -1779,7 +1561,10 @@ export class MessageModel extends window.Backbone.Model<MessageAttributesType> {
throw new Error(`Unsupported retriable type: ${options.type}`); throw new Error(`Unsupported retriable type: ${options.type}`);
} }
async sendSyncMessageOnly(dataMessage: ArrayBuffer): Promise<void> { async sendSyncMessageOnly(
dataMessage: ArrayBuffer,
saveErrors?: (errors: Array<Error>) => void
): Promise<void> {
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
const conv = this.getConversation()!; const conv = this.getConversation()!;
this.set({ dataMessage }); this.set({ dataMessage });
@ -1800,9 +1585,16 @@ export class MessageModel extends window.Backbone.Model<MessageAttributesType> {
: undefined, : undefined,
}); });
} catch (result) { } catch (result) {
const errors = (result && result.errors) || [new Error('Unknown error')]; const resultErrors = result?.errors;
// We don't save because we're about to save below. const errors = Array.isArray(resultErrors)
this.saveErrors(errors, { skipSave: true }); ? 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 { } finally {
await window.Signal.Data.saveMessage(this.attributes); await window.Signal.Data.saveMessage(this.attributes);

View file

@ -40,6 +40,7 @@ import {
MessageModelCollectionType, MessageModelCollectionType,
} from '../model-types.d'; } from '../model-types.d';
import { StoredJob } from '../jobs/types'; import { StoredJob } from '../jobs/types';
import { formatJobForInsert } from '../jobs/formatJobForInsert';
import { import {
AttachmentDownloadJobType, AttachmentDownloadJobType,
@ -206,6 +207,7 @@ const dataInterface: ClientInterface = {
getMessageBySender, getMessageBySender,
getMessageById, getMessageById,
getMessagesById,
getAllMessageIds, getAllMessageIds,
getMessagesBySentAt, getMessagesBySentAt,
getExpiredMessages, getExpiredMessages,
@ -1070,9 +1072,12 @@ async function getMessageCount(conversationId?: string) {
async function saveMessage( async function saveMessage(
data: MessageType, data: MessageType,
options?: { forceSave?: boolean } options: { jobToInsert?: Readonly<StoredJob>; 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.ExpiringMessagesListener.update();
window.Whisper.TapToViewMessagesListener.update(); window.Whisper.TapToViewMessagesListener.update();
@ -1124,6 +1129,13 @@ async function getMessageById(
return new Message(message); return new Message(message);
} }
async function getMessagesById(messageIds: Array<string>) {
if (!messageIds.length) {
return [];
}
return channels.getMessagesById(messageIds);
}
// For testing only // For testing only
async function _getAllMessages({ async function _getAllMessages({
MessageCollection, MessageCollection,

View file

@ -307,9 +307,13 @@ export type DataInterface = {
options?: { limit?: number } options?: { limit?: number }
) => Promise<Array<ConversationType>>; ) => Promise<Array<ConversationType>>;
getMessagesById: (messageIds: Array<string>) => Promise<Array<MessageType>>;
saveMessage: ( saveMessage: (
data: MessageType, data: MessageType,
options?: { forceSave?: boolean } options?: {
jobToInsert?: StoredJob;
forceSave?: boolean;
}
) => Promise<string>; ) => Promise<string>;
saveMessages: ( saveMessages: (
arrayOfMessages: Array<MessageType>, arrayOfMessages: Array<MessageType>,

View file

@ -196,6 +196,7 @@ const dataInterface: ServerInterface = {
removeReactionFromConversation, removeReactionFromConversation,
getMessageBySender, getMessageBySender,
getMessageById, getMessageById,
getMessagesById,
_getAllMessages, _getAllMessages,
getAllMessageIds, getAllMessageIds,
getMessagesBySentAt, getMessagesBySentAt,
@ -2363,22 +2364,37 @@ function getInstance(): Database {
return globalInstance; return globalInstance;
} }
function batchMultiVarQuery<T>( function batchMultiVarQuery<ValueT>(
values: Array<T>, values: Array<ValueT>,
query: (batch: Array<T>) => void query: (batch: Array<ValueT>) => void
): void { ): [];
function batchMultiVarQuery<ValueT, ResultT>(
values: Array<ValueT>,
query: (batch: Array<ValueT>) => Array<ResultT>
): Array<ResultT>;
function batchMultiVarQuery<ValueT, ResultT>(
values: Array<ValueT>,
query:
| ((batch: Array<ValueT>) => void)
| ((batch: Array<ValueT>) => Array<ResultT>)
): Array<ResultT> {
const db = getInstance(); const db = getInstance();
if (values.length > MAX_VARIABLE_COUNT) { if (values.length > MAX_VARIABLE_COUNT) {
const result: Array<ResultT> = [];
db.transaction(() => { db.transaction(() => {
for (let i = 0; i < values.length; i += MAX_VARIABLE_COUNT) { for (let i = 0; i < values.length; i += MAX_VARIABLE_COUNT) {
const batch = values.slice(i, 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'; const IDENTITY_KEYS_TABLE = 'identityKeys';
@ -3577,11 +3593,15 @@ function hasUserInitiatedMessages(conversationId: string): boolean {
function saveMessageSync( function saveMessageSync(
data: MessageType, data: MessageType,
options?: { forceSave?: boolean; alreadyInTransaction?: boolean } options?: {
jobToInsert?: StoredJob;
forceSave?: boolean;
alreadyInTransaction?: boolean;
}
): string { ): string {
const db = getInstance(); const db = getInstance();
const { forceSave, alreadyInTransaction } = options || {}; const { jobToInsert, forceSave, alreadyInTransaction } = options || {};
if (!alreadyInTransaction) { if (!alreadyInTransaction) {
return db.transaction(() => { return db.transaction(() => {
@ -3670,6 +3690,10 @@ function saveMessageSync(
` `
).run(payload); ).run(payload);
if (jobToInsert) {
insertJobSync(db, jobToInsert);
}
return id; return id;
} }
@ -3733,12 +3757,20 @@ function saveMessageSync(
json: objectToJSON(toCreate), json: objectToJSON(toCreate),
}); });
if (jobToInsert) {
insertJobSync(db, jobToInsert);
}
return toCreate.id; return toCreate.id;
} }
async function saveMessage( async function saveMessage(
data: MessageType, data: MessageType,
options?: { forceSave?: boolean; alreadyInTransaction?: boolean } options?: {
jobToInsert?: StoredJob;
forceSave?: boolean;
alreadyInTransaction?: boolean;
}
): Promise<string> { ): Promise<string> {
return saveMessageSync(data, options); return saveMessageSync(data, options);
} }
@ -3795,6 +3827,25 @@ async function getMessageById(id: string): Promise<MessageType | undefined> {
return jsonToObject(row.json); return jsonToObject(row.json);
} }
async function getMessagesById(
messageIds: Array<string>
): Promise<Array<MessageType>> {
const db = getInstance();
return batchMultiVarQuery(
messageIds,
(batch: Array<string>): Array<MessageType> => {
const query = db.prepare<ArrayQuery>(
`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<Array<MessageType>> { async function _getAllMessages(): Promise<Array<MessageType>> {
const db = getInstance(); const db = getInstance();
const rows: JSONRows = db const rows: JSONRows = db
@ -5902,9 +5953,7 @@ async function getJobsInQueue(queueType: string): Promise<Array<StoredJob>> {
})); }));
} }
async function insertJob(job: Readonly<StoredJob>): Promise<void> { function insertJobSync(db: Database, job: Readonly<StoredJob>): void {
const db = getInstance();
db.prepare<Query>( db.prepare<Query>(
` `
INSERT INTO jobs INSERT INTO jobs
@ -5920,6 +5969,11 @@ async function insertJob(job: Readonly<StoredJob>): Promise<void> {
}); });
} }
async function insertJob(job: Readonly<StoredJob>): Promise<void> {
const db = getInstance();
return insertJobSync(db, job);
}
async function deleteJob(id: string): Promise<void> { async function deleteJob(id: string): Promise<void> {
const db = getInstance(); const db = getInstance();

View file

@ -45,12 +45,16 @@ import {
getGroupSizeRecommendedLimit, getGroupSizeRecommendedLimit,
getGroupSizeHardLimit, getGroupSizeHardLimit,
} from '../../groups/limits'; } from '../../groups/limits';
import { getMessagesById } from '../../messages/getMessagesById';
import { isMessageUnread } from '../../util/isMessageUnread'; import { isMessageUnread } from '../../util/isMessageUnread';
import { toggleSelectedContactForGroupAddition } from '../../groups/toggleSelectedContactForGroupAddition'; import { toggleSelectedContactForGroupAddition } from '../../groups/toggleSelectedContactForGroupAddition';
import { GroupNameCollisionsWithIdsByTitle } from '../../util/groupMemberNameCollisions'; import { GroupNameCollisionsWithIdsByTitle } from '../../util/groupMemberNameCollisions';
import { ContactSpoofingType } from '../../util/contactSpoofing'; import { ContactSpoofingType } from '../../util/contactSpoofing';
import { writeProfile } from '../../services/writeProfile'; import { writeProfile } from '../../services/writeProfile';
import { getMe } from '../selectors/conversations'; import {
getMe,
getMessageIdsPendingBecauseOfVerification,
} from '../selectors/conversations';
import { AvatarDataType, getDefaultAvatars } from '../../types/Avatar'; import { AvatarDataType, getDefaultAvatars } from '../../types/Avatar';
import { getAvatarData } from '../../util/getAvatarData'; import { getAvatarData } from '../../util/getAvatarData';
import { isSameAvatarData } from '../../util/isSameAvatarData'; import { isSameAvatarData } from '../../util/isSameAvatarData';
@ -302,6 +306,15 @@ export type ConversationsStateType = {
composer?: ComposerStateType; composer?: ComposerStateType;
contactSpoofingReview?: ContactSpoofingReviewStateType; 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<string>
>;
// Note: it's very important that both of these locations are always kept up to date // Note: it's very important that both of these locations are always kept up to date
messagesLookup: MessageLookupType; messagesLookup: MessageLookupType;
messagesByConversation: MessagesByConversationType; messagesByConversation: MessagesByConversationType;
@ -336,14 +349,21 @@ export const getConversationCallMode = (
export const COLORS_CHANGED = 'conversations/COLORS_CHANGED'; export const COLORS_CHANGED = 'conversations/COLORS_CHANGED';
export const COLOR_SELECTED = 'conversations/COLOR_SELECTED'; export const COLOR_SELECTED = 'conversations/COLOR_SELECTED';
const CANCEL_MESSAGES_PENDING_CONVERSATION_VERIFICATION =
'conversations/CANCEL_MESSAGES_PENDING_CONVERSATION_VERIFICATION';
const COMPOSE_TOGGLE_EDITING_AVATAR = const COMPOSE_TOGGLE_EDITING_AVATAR =
'conversations/compose/COMPOSE_TOGGLE_EDITING_AVATAR'; 'conversations/compose/COMPOSE_TOGGLE_EDITING_AVATAR';
const COMPOSE_ADD_AVATAR = 'conversations/compose/ADD_AVATAR'; const COMPOSE_ADD_AVATAR = 'conversations/compose/ADD_AVATAR';
const COMPOSE_REMOVE_AVATAR = 'conversations/compose/REMOVE_AVATAR'; const COMPOSE_REMOVE_AVATAR = 'conversations/compose/REMOVE_AVATAR';
const COMPOSE_REPLACE_AVATAR = 'conversations/compose/REPLACE_AVATAR'; const COMPOSE_REPLACE_AVATAR = 'conversations/compose/REPLACE_AVATAR';
const CUSTOM_COLOR_REMOVED = 'conversations/CUSTOM_COLOR_REMOVED'; 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'; const REPLACE_AVATARS = 'conversations/REPLACE_AVATARS';
type CancelMessagesPendingConversationVerificationActionType = {
type: typeof CANCEL_MESSAGES_PENDING_CONVERSATION_VERIFICATION;
};
type CantAddContactToGroupActionType = { type CantAddContactToGroupActionType = {
type: 'CANT_ADD_CONTACT_TO_GROUP'; type: 'CANT_ADD_CONTACT_TO_GROUP';
payload: { payload: {
@ -465,6 +485,13 @@ export type MessageSelectedActionType = {
conversationId: string; conversationId: string;
}; };
}; };
type MessageStoppedByMissingVerificationActionType = {
type: typeof MESSAGE_STOPPED_BY_MISSING_VERIFICATION;
payload: {
messageId: string;
untrustedConversationIds: ReadonlyArray<string>;
};
};
export type MessageChangedActionType = { export type MessageChangedActionType = {
type: 'MESSAGE_CHANGED'; type: 'MESSAGE_CHANGED';
payload: { payload: {
@ -656,6 +683,7 @@ type ReplaceAvatarsActionType = {
}; };
}; };
export type ConversationActionType = export type ConversationActionType =
| CancelMessagesPendingConversationVerificationActionType
| CantAddContactToGroupActionType | CantAddContactToGroupActionType
| ClearChangedMessagesActionType | ClearChangedMessagesActionType
| ClearGroupCreationErrorActionType | ClearGroupCreationErrorActionType
@ -679,6 +707,7 @@ export type ConversationActionType =
| CreateGroupPendingActionType | CreateGroupPendingActionType
| CreateGroupRejectedActionType | CreateGroupRejectedActionType
| CustomColorRemovedActionType | CustomColorRemovedActionType
| MessageStoppedByMissingVerificationActionType
| MessageChangedActionType | MessageChangedActionType
| MessageDeletedActionType | MessageDeletedActionType
| MessageSelectedActionType | MessageSelectedActionType
@ -716,6 +745,7 @@ export type ConversationActionType =
// Action Creators // Action Creators
export const actions = { export const actions = {
cancelMessagesPendingConversationVerification,
cantAddContactToGroup, cantAddContactToGroup,
clearChangedMessages, clearChangedMessages,
clearGroupCreationError, clearGroupCreationError,
@ -737,6 +767,7 @@ export const actions = {
createGroup, createGroup,
deleteAvatarFromDisk, deleteAvatarFromDisk,
doubleCheckMissingQuoteReference, doubleCheckMissingQuoteReference,
messageStoppedByMissingVerification,
messageChanged, messageChanged,
messageDeleted, messageDeleted,
messageSizeChanged, messageSizeChanged,
@ -775,6 +806,7 @@ export const actions = {
startSettingGroupMetadata, startSettingGroupMetadata,
toggleConversationInChooseMembers, toggleConversationInChooseMembers,
toggleComposeEditingAvatar, toggleComposeEditingAvatar,
verifyConversationsStoppingMessageSend,
}; };
function filterAvatarData( 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( function composeSaveAvatarToDisk(
avatarData: AvatarDataType avatarData: AvatarDataType
): ThunkAction<void, RootStateType, unknown, ComposeSaveAvatarActionType> { ): ThunkAction<void, RootStateType, unknown, ComposeSaveAvatarActionType> {
@ -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( function cantAddContactToGroup(
conversationId: string conversationId: string
): CantAddContactToGroupActionType { ): CantAddContactToGroupActionType {
@ -1162,9 +1239,22 @@ function conversationChanged(
id: string, id: string,
data: ConversationType data: ConversationType
): ThunkAction<void, RootStateType, unknown, ConversationChangedActionType> { ): ThunkAction<void, RootStateType, unknown, ConversationChangedActionType> {
return dispatch => { return async (dispatch, getState) => {
calling.groupMembersChanged(id); calling.groupMembersChanged(id);
if (!data.isUntrusted) {
const messageIdsPending =
getOwn(
getState().conversations
.outboundMessagesPendingConversationVerification,
id
) ?? [];
const messagesPending = await getMessagesById(messageIdsPending);
messagesPending.forEach(message => {
message.retrySend();
});
}
dispatch({ dispatch({
type: 'CONVERSATION_CHANGED', type: 'CONVERSATION_CHANGED',
payload: { payload: {
@ -1264,6 +1354,19 @@ function selectMessage(
}; };
} }
function messageStoppedByMissingVerification(
messageId: string,
untrustedConversationIds: ReadonlyArray<string>
): MessageStoppedByMissingVerificationActionType {
return {
type: MESSAGE_STOPPED_BY_MISSING_VERIFICATION,
payload: {
messageId,
untrustedConversationIds,
},
};
}
function messageChanged( function messageChanged(
id: string, id: string,
conversationId: string, conversationId: string,
@ -1651,6 +1754,7 @@ export function getEmptyState(): ConversationsStateType {
conversationsByE164: {}, conversationsByE164: {},
conversationsByUuid: {}, conversationsByUuid: {},
conversationsByGroupId: {}, conversationsByGroupId: {},
outboundMessagesPendingConversationVerification: {},
messagesByConversation: {}, messagesByConversation: {},
messagesLookup: {}, messagesLookup: {},
selectedMessageCounter: 0, selectedMessageCounter: 0,
@ -1799,6 +1903,13 @@ export function reducer(
state: Readonly<ConversationsStateType> = getEmptyState(), state: Readonly<ConversationsStateType> = getEmptyState(),
action: Readonly<ConversationActionType> action: Readonly<ConversationActionType>
): ConversationsStateType { ): ConversationsStateType {
if (action.type === CANCEL_MESSAGES_PENDING_CONVERSATION_VERIFICATION) {
return {
...state,
outboundMessagesPendingConversationVerification: {},
};
}
if (action.type === 'CANT_ADD_CONTACT_TO_GROUP') { if (action.type === 'CANT_ADD_CONTACT_TO_GROUP') {
const { composer } = state; const { composer } = state;
if (composer?.step !== ComposerStep.ChooseGroupMembers) { if (composer?.step !== ComposerStep.ChooseGroupMembers) {
@ -1887,6 +1998,9 @@ export function reducer(
[id]: data, [id]: data,
}, },
...updateConversationLookups(data, undefined, state), ...updateConversationLookups(data, undefined, state),
outboundMessagesPendingConversationVerification: data.isUntrusted
? state.outboundMessagesPendingConversationVerification
: omit(state.outboundMessagesPendingConversationVerification, id),
}; };
} }
if (action.type === 'CONVERSATION_CHANGED') { if (action.type === 'CONVERSATION_CHANGED') {
@ -1933,6 +2047,9 @@ export function reducer(
[id]: data, [id]: data,
}, },
...updateConversationLookups(data, existing, state), ...updateConversationLookups(data, existing, state),
outboundMessagesPendingConversationVerification: data.isUntrusted
? state.outboundMessagesPendingConversationVerification
: omit(state.outboundMessagesPendingConversationVerification, id),
}; };
} }
if (action.type === 'CONVERSATION_REMOVED') { if (action.type === 'CONVERSATION_REMOVED') {
@ -2037,6 +2154,31 @@ export function reducer(
selectedMessageCounter: state.selectedMessageCounter + 1, 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') { if (action.type === 'MESSAGE_CHANGED') {
const { id, conversationId, data } = action.payload; const { id, conversationId, data } = action.payload;
const existingConversation = state.messagesByConversation[conversationId]; const existingConversation = state.messagesByConversation[conversationId];

View file

@ -18,6 +18,7 @@ import {
PreJoinConversationType, PreJoinConversationType,
} from '../ducks/conversations'; } from '../ducks/conversations';
import { getOwn } from '../../util/getOwn'; import { getOwn } from '../../util/getOwn';
import { isNotNil } from '../../util/isNotNil';
import { deconstructLookup } from '../../util/deconstructLookup'; import { deconstructLookup } from '../../util/deconstructLookup';
import { PropsDataType as TimelinePropsType } from '../../components/conversation/Timeline'; import { PropsDataType as TimelinePropsType } from '../../components/conversation/Timeline';
import { TimelineItemType } from '../../components/conversation/TimelineItem'; import { TimelineItemType } from '../../components/conversation/TimelineItem';
@ -27,6 +28,7 @@ import { filterAndSortConversationsByTitle } from '../../util/filterAndSortConve
import { ContactNameColors, ContactNameColorType } from '../../types/Colors'; import { ContactNameColors, ContactNameColorType } from '../../types/Colors';
import { AvatarDataType } from '../../types/Avatar'; import { AvatarDataType } from '../../types/Avatar';
import { isInSystemContacts } from '../../util/isInSystemContacts'; import { isInSystemContacts } from '../../util/isInSystemContacts';
import { sortByTitle } from '../../util/sortByTitle';
import { isGroupV2 } from '../../util/whatTypeOfConversation'; import { isGroupV2 } from '../../util/whatTypeOfConversation';
import { import {
@ -956,3 +958,51 @@ export const getGroupAdminsSelector = createSelector(
}; };
} }
); );
const getOutboundMessagesPendingConversationVerification = createSelector(
getConversations,
(
conversations: Readonly<ConversationsStateType>
): Record<string, Array<string>> =>
conversations.outboundMessagesPendingConversationVerification
);
const getConversationIdsStoppingMessageSendBecauseOfVerification = createSelector(
getOutboundMessagesPendingConversationVerification,
(outboundMessagesPendingConversationVerification): Array<string> =>
Object.keys(outboundMessagesPendingConversationVerification)
);
export const getConversationsStoppingMessageSendBecauseOfVerification = createSelector(
getConversationByIdSelector,
getConversationIdsStoppingMessageSendBecauseOfVerification,
(
conversationSelector: (id: string) => undefined | ConversationType,
conversationIds: ReadonlyArray<string>
): Array<ConversationType> => {
const conversations = conversationIds
.map(conversationId => conversationSelector(conversationId))
.filter(isNotNil);
return sortByTitle(conversations);
}
);
export const getMessageIdsPendingBecauseOfVerification = createSelector(
getOutboundMessagesPendingConversationVerification,
(outboundMessagesPendingConversationVerification): Set<string> => {
const result = new Set<string>();
Object.values(outboundMessagesPendingConversationVerification).forEach(
messageGroup => {
messageGroup.forEach(messageId => {
result.add(messageId);
});
}
);
return result;
}
);
export const getNumberOfMessagesPendingBecauseOfVerification = createSelector(
getMessageIdsPendingBecauseOfVerification,
(messageIds: Readonly<Set<string>>): number => messageIds.size
);

View file

@ -67,6 +67,7 @@ import {
import { import {
SendStatus, SendStatus,
isDelivered, isDelivered,
isFailed,
isMessageJustForMe, isMessageJustForMe,
isRead, isRead,
isSent, isSent,
@ -1234,7 +1235,10 @@ export function getMessagePropStatus(
sendStateByConversationId[ourConversationId]?.status ?? sendStateByConversationId[ourConversationId]?.status ??
SendStatus.Pending; SendStatus.Pending;
const sent = isSent(status); const sent = isSent(status);
if (hasErrors(message)) { if (
hasErrors(message) ||
someSendStatus(sendStateByConversationId, isFailed)
) {
return sent ? 'partial-sent' : 'error'; return sent ? 'partial-sent' : 'error';
} }
return sent ? 'viewed' : 'sending'; return sent ? 'viewed' : 'sending';
@ -1248,7 +1252,10 @@ export function getMessagePropStatus(
SendStatus.Pending SendStatus.Pending
); );
if (hasErrors(message)) { if (
hasErrors(message) ||
someSendStatus(sendStateByConversationId, isFailed)
) {
return isSent(highestSuccessfulStatus) ? 'partial-sent' : 'error'; return isSent(highestSuccessfulStatus) ? 'partial-sent' : 'error';
} }
if (isViewed(highestSuccessfulStatus)) { if (isViewed(highestSuccessfulStatus)) {

View file

@ -4,18 +4,34 @@
import React from 'react'; import React from 'react';
import { connect } from 'react-redux'; import { connect } from 'react-redux';
import { App, PropsType } from '../../components/App'; import { App } from '../../components/App';
import { SmartCallManager } from './CallManager'; import { SmartCallManager } from './CallManager';
import { SmartGlobalModalContainer } from './GlobalModalContainer'; import { SmartGlobalModalContainer } from './GlobalModalContainer';
import { SmartSafetyNumberViewer } from './SafetyNumberViewer';
import { StateType } from '../reducer'; 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 { mapDispatchToProps } from '../actions';
import type { SafetyNumberProps } from '../../components/SafetyNumberChangeDialog';
const mapStateToProps = (state: StateType): PropsType => { const mapStateToProps = (state: StateType) => {
return { return {
...state.app, ...state.app,
conversationsStoppingMessageSendBecauseOfVerification: getConversationsStoppingMessageSendBecauseOfVerification(
state
),
i18n: getIntl(state),
numberOfMessagesPendingBecauseOfVerification: getNumberOfMessagesPendingBecauseOfVerification(
state
),
renderCallManager: () => <SmartCallManager />, renderCallManager: () => <SmartCallManager />,
renderGlobalModalContainer: () => <SmartGlobalModalContainer />, renderGlobalModalContainer: () => <SmartGlobalModalContainer />,
renderSafetyNumber: (props: SafetyNumberProps) => (
<SmartSafetyNumberViewer {...props} />
),
theme: getTheme(state), theme: getTheme(state),
}; };
}; };

View file

@ -32,7 +32,6 @@ const mapStateToProps = (
receivedAt, receivedAt,
sentAt, sentAt,
sendAnyway,
showSafetyNumber, showSafetyNumber,
displayTapToViewMessage, displayTapToViewMessage,
@ -71,7 +70,6 @@ const mapStateToProps = (
i18n: getIntl(state), i18n: getIntl(state),
interactionMode: getInteractionMode(state), interactionMode: getInteractionMode(state),
sendAnyway,
showSafetyNumber, showSafetyNumber,
displayTapToViewMessage, displayTapToViewMessage,

View file

@ -12,6 +12,7 @@ import {
SendStateByConversationId, SendStateByConversationId,
SendStatus, SendStatus,
isDelivered, isDelivered,
isFailed,
isMessageJustForMe, isMessageJustForMe,
isRead, isRead,
isSent, 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', () => { describe('someSendStatus', () => {
it('returns false if there are no send states', () => { it('returns false if there are no send states', () => {
const alwaysTrue = () => true; const alwaysTrue = () => true;

View file

@ -27,11 +27,14 @@ import {
getConversationByIdSelector, getConversationByIdSelector,
getConversationsByTitleSelector, getConversationsByTitleSelector,
getConversationSelector, getConversationSelector,
getConversationsStoppingMessageSendBecauseOfVerification,
getFilteredCandidateContactsForNewGroup, getFilteredCandidateContactsForNewGroup,
getFilteredComposeContacts, getFilteredComposeContacts,
getFilteredComposeGroups, getFilteredComposeGroups,
getInvitedContactsForNewlyCreatedGroup, getInvitedContactsForNewlyCreatedGroup,
getMaximumGroupSizeModalState, getMaximumGroupSizeModalState,
getMessageIdsPendingBecauseOfVerification,
getNumberOfMessagesPendingBecauseOfVerification,
getPlaceholderContact, getPlaceholderContact,
getRecommendedGroupSizeModalState, getRecommendedGroupSizeModalState,
getSelectedConversation, 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', () => { describe('#getInvitedContactsForNewlyCreatedGroup', () => {
it('returns an empty array if there are no invited contacts', () => { it('returns an empty array if there are no invited contacts', () => {
const state = getEmptyRootState(); const state = getEmptyRootState();

View file

@ -45,6 +45,7 @@ const {
closeRecommendedGroupSizeModal, closeRecommendedGroupSizeModal,
createGroup, createGroup,
messageSizeChanged, messageSizeChanged,
messageStoppedByMissingVerification,
openConversationInternal, openConversationInternal,
repairNewestMessage, repairNewestMessage,
repairOldestMessage, 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', () => { describe('REPAIR_NEWEST_MESSAGE', () => {
it('updates newest', () => { it('updates newest', () => {
const action = repairNewestMessage(conversationId); const action = repairNewestMessage(conversationId);

View file

@ -92,6 +92,32 @@ describe('JobQueueDatabaseStore', () => {
assert.deepEqual(events, ['insert', 'yielded job']); 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 () => { it("doesn't insert jobs until the initial fetch has completed", async () => {
const events: Array<string> = []; const events: Array<string> = [];

View file

@ -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' },
});
});
});

View file

@ -77,7 +77,7 @@ export type SendOptionsType = {
online?: boolean; online?: boolean;
}; };
type PreviewType = { export type PreviewType = {
url: string; url: string;
title: string; title: string;
image?: AttachmentType; image?: AttachmentType;

View file

@ -145,7 +145,6 @@ type MessageActionsType = {
) => unknown; ) => unknown;
replyToMessage: (messageId: string) => unknown; replyToMessage: (messageId: string) => unknown;
retrySend: (messageId: string) => unknown; retrySend: (messageId: string) => unknown;
sendAnyway: (contactId: string, messageId: string) => unknown;
showContactDetail: (options: { showContactDetail: (options: {
contact: EmbeddedContactType; contact: EmbeddedContactType;
signalAccount?: string; signalAccount?: string;
@ -1057,9 +1056,6 @@ export class ConversationView extends window.Backbone.View<ConversationModel> {
const downloadNewVersion = () => { const downloadNewVersion = () => {
this.downloadNewVersion(); this.downloadNewVersion();
}; };
const sendAnyway = (contactId: string, messageId: string) => {
this.forceSend({ contactId, messageId });
};
const showSafetyNumber = (contactId: string) => { const showSafetyNumber = (contactId: string) => {
this.showSafetyNumber(contactId); this.showSafetyNumber(contactId);
}; };
@ -1092,7 +1088,6 @@ export class ConversationView extends window.Backbone.View<ConversationModel> {
reactToMessage, reactToMessage,
replyToMessage, replyToMessage,
retrySend, retrySend,
sendAnyway,
showContactDetail, showContactDetail,
showContactModal, showContactModal,
showSafetyNumber, showSafetyNumber,
@ -2657,7 +2652,7 @@ export class ConversationView extends window.Backbone.View<ConversationModel> {
} }
: undefined; : undefined;
conversation.sendMessage( conversation.enqueueMessageForSend(
undefined, // body undefined, // body
[], [],
undefined, // quote undefined, // quote
@ -2683,7 +2678,7 @@ export class ConversationView extends window.Backbone.View<ConversationModel> {
) )
); );
conversation.sendMessage( conversation.enqueueMessageForSend(
messageBody || undefined, messageBody || undefined,
attachmentsToSend, attachmentsToSend,
undefined, // quote undefined, // quote
@ -2945,49 +2940,6 @@ export class ConversationView extends window.Backbone.View<ConversationModel> {
view.render(); 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 { showSafetyNumber(id?: string): void {
let conversation: undefined | ConversationModel; let conversation: undefined | ConversationModel;
@ -4200,7 +4152,7 @@ export class ConversationView extends window.Backbone.View<ConversationModel> {
window.log.info('Send pre-checks took', sendDelta, 'milliseconds'); window.log.info('Send pre-checks took', sendDelta, 'milliseconds');
batchedUpdates(() => { batchedUpdates(() => {
model.sendMessage( model.enqueueMessageForSend(
message, message,
attachments, attachments,
this.quote, this.quote,