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.",
"description": "Shown on confirmation dialog when user attempts to send a message"
},
"safetyNumberChangeDialog__pending-messages--1": {
"message": "Send pending message",
"description": "Shown on confirmation dialog when user attempts to send a message in the outbox"
},
"safetyNumberChangeDialog__pending-messages--many": {
"message": "Send $count$ pending messages",
"description": "Shown on confirmation dialog when user attempts to send a message in the outbox",
"placeholders": {
"count": {
"content": "$1",
"example": 123
}
}
},
"identityKeyErrorOnSend": {
"message": "Your safety number with $name1$ has changed. This could either mean that someone is trying to intercept your communication or that $name2$ has simply reinstalled Signal. You may wish to verify your safety number with this contact.",
"description": "Shown when user clicks on a failed recipient in the message detail view after an identity key change",

View file

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

View file

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

View file

@ -1,8 +1,14 @@
// Copyright 2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import React, { useEffect, useRef } from 'react';
import React, { ReactNode, useEffect, useRef } from 'react';
import * as Backbone from 'backbone';
import {
SafetyNumberChangeDialog,
SafetyNumberProps,
} from './SafetyNumberChangeDialog';
import type { ConversationType } from '../state/ducks/conversations';
import type { LocalizerType } from '../types/Util';
type InboxViewType = Backbone.View & {
onEmpty?: () => void;
@ -14,10 +20,24 @@ type InboxViewOptionsType = Backbone.ViewOptions & {
};
export type PropsType = {
cancelMessagesPendingConversationVerification: () => void;
conversationsStoppingMessageSendBecauseOfVerification: Array<ConversationType>;
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 viewRef = useRef<InboxViewType | undefined>(undefined);
@ -47,5 +67,30 @@ export const Inbox = ({ hasInitialLoadCompleted }: PropsType): JSX.Element => {
}
}, [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,
interactionMode: 'keyboard',
sendAnyway: action('onSendAnyway'),
showSafetyNumber: action('onShowSafetyNumber'),
checkForAccount: action('checkForAccount'),

View file

@ -53,7 +53,6 @@ export type PropsData = {
receivedAt: number;
sentAt: number;
sendAnyway: (contactId: string, messageId: string) => unknown;
showSafetyNumber: (contactId: string) => void;
i18n: LocalizerType;
} & Pick<MessagePropsType, 'interactionMode'>;
@ -145,7 +144,7 @@ export class MessageDetail extends React.Component<Props> {
}
public renderContact(contact: Contact): JSX.Element {
const { i18n, message, showSafetyNumber, sendAnyway } = this.props;
const { i18n, showSafetyNumber } = this.props;
const errors = contact.errors || [];
const errorComponent = contact.isOutgoingKeyError ? (
@ -157,13 +156,6 @@ export class MessageDetail extends React.Component<Props> {
>
{i18n('showSafetyNumber')}
</button>
<button
type="button"
className="module-message-detail__contact__send-anyway"
onClick={() => sendAnyway(contact.id, message.id)}
>
{i18n('sendAnyway')}
</button>
</div>
) : null;
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.
*/
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) {
throw new Error(
`${this.logPrefix} has not started streaming. Make sure to call streamJobs().`
);
}
}
protected createJob(data: Readonly<T>): Job<T> {
const id = uuid();
const timestamp = Date.now();
@ -158,11 +169,7 @@ export abstract class JobQueue<T> {
}
})();
log.info(`${this.logPrefix} added new job ${id}`);
const job = new Job(id, timestamp, this.queueType, data, completion);
await this.store.insert(job);
return job;
return new Job(id, timestamp, this.queueType, data, completion);
}
private async enqueueStoredJob(storedJob: Readonly<StoredJob>) {

View file

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

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 { normalMessageSendJobQueue } from './normalMessageSendJobQueue';
import { readSyncJobQueue } from './readSyncJobQueue';
import { removeStorageKeyJobQueue } from './removeStorageKeyJobQueue';
import { reportSpamJobQueue } from './reportSpamJobQueue';
@ -19,6 +20,7 @@ export function initializeAllJobQueues({
}): void {
reportSpamJobQueue.initialize({ server });
normalMessageSendJobQueue.streamJobs();
readSyncJobQueue.streamJobs();
removeStorageKeyJobQueue.streamJobs();
reportSpamJobQueue.streamJobs();

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];
export const isSent = (status: SendStatus): boolean =>
STATUS_NUMBERS[status] >= STATUS_NUMBERS[SendStatus.Sent];
export const isFailed = (status: SendStatus): boolean =>
status === SendStatus.Failed;
/**
* `SendState` combines `SendStatus` and a timestamp. You can use it to show things to the

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,
} from '../types/Colors';
import { MessageModel } from './messages';
import { strictAssert } from '../util/assert';
import { isMuted } from '../util/isMuted';
import { isConversationSMSOnly } from '../util/isConversationSMSOnly';
import { isConversationUnregistered } from '../util/isConversationUnregistered';
@ -82,6 +83,7 @@ import {
isTapToView,
getMessagePropStatus,
} from '../state/selectors/message';
import { normalMessageSendJobQueue } from '../jobs/normalMessageSendJobQueue';
import { Deletes } from '../messageModifiers/Deletes';
import { Reactions, ReactionModel } from '../messageModifiers/Reactions';
import { isAnnouncementGroupReady } from '../util/isAnnouncementGroupReady';
@ -119,11 +121,6 @@ const ATTRIBUTES_THAT_DONT_INVALIDATE_PROPS_CACHE = new Set([
'profileLastFetchedAt',
]);
type CustomError = Error & {
identifier?: string;
number?: string;
};
type CachedIdenticon = {
readonly url: string;
readonly content: string;
@ -3111,6 +3108,10 @@ export class ConversationModel extends window.Backbone
);
}
getRecipientConversationIds(): Set<string> {
return new Set(map(this.getMembers(), conversation => conversation.id));
}
async getQuoteAttachment(
attachments?: 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);
}
@ -3577,7 +3578,7 @@ export class ConversationModel extends window.Backbone
);
}
sendMessage(
async enqueueMessageForSend(
body: string | undefined,
attachments: Array<AttachmentType>,
quote?: QuotedMessageType,
@ -3593,7 +3594,7 @@ export class ConversationModel extends window.Backbone
sendHQImages?: boolean;
timestamp?: number;
} = {}
): void {
): Promise<void> {
if (this.isGroupV1AndDisabled()) {
return;
}
@ -3614,223 +3615,134 @@ export class ConversationModel extends window.Backbone
const destination = this.getSendTarget()!;
const recipients = this.getRecipients();
if (timestamp) {
window.log.info(`sendMessage: Queueing send with timestamp ${timestamp}`);
}
this.queueJob('sendMessage', async () => {
const now = timestamp || Date.now();
const now = timestamp || Date.now();
await this.maybeApplyUniversalTimer(false);
await this.maybeApplyUniversalTimer(false);
const expireTimer = this.get('expireTimer');
const expireTimer = this.get('expireTimer');
window.log.info(
'Sending message to conversation',
this.idForLogging(),
'with timestamp',
now
);
window.log.info(
'Sending message to conversation',
this.idForLogging(),
'with timestamp',
now
);
const recipientMaybeConversations = map(recipients, identifier =>
window.ConversationController.get(identifier)
);
const recipientConversations = filter(
recipientMaybeConversations,
isNotNil
);
const recipientConversationIds = concat(
map(recipientConversations, c => c.id),
[window.ConversationController.getOurConversationIdOrThrow()]
);
const recipientMaybeConversations = map(recipients, identifier =>
window.ConversationController.get(identifier)
);
const recipientConversations = filter(
recipientMaybeConversations,
isNotNil
);
const recipientConversationIds = concat(
map(recipientConversations, c => c.id),
[window.ConversationController.getOurConversationIdOrThrow()]
);
// Here we move attachments to disk
const messageWithSchema = await upgradeMessageSchema({
timestamp: now,
type: 'outgoing',
body,
conversationId: this.id,
quote,
preview,
attachments,
sent_at: now,
received_at: window.Signal.Util.incrementMessageCounter(),
received_at_ms: now,
expireTimer,
recipients,
sticker,
bodyRanges: mentions,
sendHQImages,
sendStateByConversationId: zipObject(
recipientConversationIds,
repeat({
status: SendStatus.Pending,
updatedAt: now,
})
),
});
if (isDirectConversation(this.attributes)) {
messageWithSchema.destination = destination;
}
const attributes: MessageAttributesType = {
...messageWithSchema,
id: window.getGuid(),
};
const model = new window.Whisper.Message(attributes);
const message = window.MessageController.register(model.id, model);
const dbStart = Date.now();
await window.Signal.Data.saveMessage(message.attributes, {
forceSave: true,
});
const dbDuration = Date.now() - dbStart;
if (dbDuration > SEND_REPORTING_THRESHOLD_MS) {
window.log.info(
`ConversationModel(${this.idForLogging()}.sendMessage(${now}): ` +
`db save took ${dbDuration}ms`
);
}
const renderStart = Date.now();
this.addSingleMessage(model);
if (sticker) {
await addStickerPackReference(model.id, sticker.packId);
}
const messageId = message.id;
const draftProperties = dontClearDraft
? {}
: {
draft: null,
draftTimestamp: null,
lastMessage: model.getNotificationText(),
lastMessageStatus: 'sending' as const,
};
this.set({
...draftProperties,
active_at: now,
timestamp: now,
isArchived: false,
});
this.incrementSentMessageCount({ save: false });
const renderDuration = Date.now() - renderStart;
if (renderDuration > SEND_REPORTING_THRESHOLD_MS) {
window.log.info(
`ConversationModel(${this.idForLogging()}.sendMessage(${now}): ` +
`render save took ${renderDuration}ms`
);
}
window.Signal.Data.updateConversation(this.attributes);
// We're offline!
if (!window.textsecure.messaging) {
const errors = map(recipientConversationIds, conversationId => {
const error = new Error('Network is not available') as CustomError;
error.name = 'SendMessageNetworkError';
error.identifier = conversationId;
return error;
});
await message.saveErrors([...errors]);
return null;
}
const attachmentsWithData = await Promise.all(
messageWithSchema.attachments?.map(loadAttachmentData) ?? []
);
const {
body: messageBody,
attachments: finalAttachments,
} = window.Whisper.Message.getLongMessageAttachment({
body,
attachments: attachmentsWithData,
now,
});
let profileKey: ArrayBuffer | undefined;
if (this.get('profileSharing')) {
profileKey = await ourProfileKeyService.get();
}
// Special-case the self-send case - we send only a sync message
if (isMe(this.attributes)) {
const dataMessage = await window.textsecure.messaging.getDataMessage({
attachments: finalAttachments,
body: messageBody,
// deletedForEveryoneTimestamp
expireTimer,
preview,
profileKey,
quote,
// reaction
recipients: [destination],
sticker,
timestamp: now,
});
return message.sendSyncMessageOnly(dataMessage);
}
const conversationType = this.get('type');
const options = await getSendOptions(this.attributes);
const { ContentHint } = Proto.UnidentifiedSenderMessage.Message;
let promise;
if (conversationType === Message.GROUP) {
promise = window.Signal.Util.sendToGroup({
groupSendOptions: {
attachments: finalAttachments,
expireTimer,
groupV1: this.getGroupV1Info(),
groupV2: this.getGroupV2Info(),
messageText: messageBody,
preview,
profileKey,
quote,
sticker,
timestamp: now,
mentions,
},
conversation: this,
contentHint: ContentHint.RESENDABLE,
messageId,
sendOptions: options,
sendType: 'message',
});
} else {
promise = window.textsecure.messaging.sendMessageToIdentifier({
identifier: destination,
messageText: messageBody,
attachments: finalAttachments,
quote,
preview,
sticker,
reaction: null,
deletedForEveryoneTimestamp: undefined,
timestamp: now,
expireTimer,
contentHint: ContentHint.RESENDABLE,
groupId: undefined,
profileKey,
options,
});
}
return message.send(
handleMessageSend(promise, {
messageIds: [messageId],
sendType: 'message',
// Here we move attachments to disk
const messageWithSchema = await upgradeMessageSchema({
timestamp: now,
type: 'outgoing',
body,
conversationId: this.id,
quote,
preview,
attachments,
sent_at: now,
received_at: window.Signal.Util.incrementMessageCounter(),
received_at_ms: now,
expireTimer,
recipients,
sticker,
bodyRanges: mentions,
sendHQImages,
sendStateByConversationId: zipObject(
recipientConversationIds,
repeat({
status: SendStatus.Pending,
updatedAt: now,
})
);
),
});
if (isDirectConversation(this.attributes)) {
messageWithSchema.destination = destination;
}
const attributes: MessageAttributesType = {
...messageWithSchema,
id: window.getGuid(),
};
const model = new window.Whisper.Message(attributes);
const message = window.MessageController.register(model.id, model);
message.cachedOutgoingPreviewData = preview;
message.cachedOutgoingQuoteData = quote;
message.cachedOutgoingStickerData = sticker;
const dbStart = Date.now();
strictAssert(
typeof message.attributes.timestamp === 'number',
'Expected a timestamp'
);
await normalMessageSendJobQueue.add(
{ messageId: message.id, conversationId: this.id },
async jobToInsert => {
window.log.info(
`enqueueMessageForSend: saving message ${message.id} and job ${jobToInsert.id}`
);
await window.Signal.Data.saveMessage(message.attributes, {
jobToInsert,
forceSave: true,
});
}
);
const dbDuration = Date.now() - dbStart;
if (dbDuration > SEND_REPORTING_THRESHOLD_MS) {
window.log.info(
`ConversationModel(${this.idForLogging()}.sendMessage(${now}): ` +
`db save took ${dbDuration}ms`
);
}
const renderStart = Date.now();
this.addSingleMessage(model);
if (sticker) {
await addStickerPackReference(model.id, sticker.packId);
}
const draftProperties = dontClearDraft
? {}
: {
draft: null,
draftTimestamp: null,
lastMessage: model.getNotificationText(),
lastMessageStatus: 'sending' as const,
};
this.set({
...draftProperties,
active_at: now,
timestamp: now,
isArchived: false,
});
this.incrementSentMessageCount({ save: false });
const renderDuration = Date.now() - renderStart;
if (renderDuration > SEND_REPORTING_THRESHOLD_MS) {
window.log.info(
`ConversationModel(${this.idForLogging()}.sendMessage(${now}): ` +
`render save took ${renderDuration}ms`
);
}
window.Signal.Data.updateConversation(this.attributes);
}
// Is this someone who is a contact, or are we sharing our profile with them?

View file

@ -1,7 +1,7 @@
// Copyright 2020-2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import { isEmpty, isEqual, noop, omit, union } from 'lodash';
import { isEmpty, isEqual, mapValues, noop, omit, union } from 'lodash';
import {
CustomError,
GroupV1Update,
@ -43,7 +43,6 @@ import {
import * as Stickers from '../types/Stickers';
import { AttachmentType, isImage, isVideo } from '../types/Attachment';
import { IMAGE_WEBP, stringToMIMEType } from '../types/MIME';
import { ourProfileKeyService } from '../services/ourProfileKey';
import { ReadStatus } from '../messages/MessageReadStatus';
import {
SendActionType,
@ -112,6 +111,8 @@ import { ViewOnceOpenSyncs } from '../messageModifiers/ViewOnceOpenSyncs';
import * as AttachmentDownloads from '../messageModifiers/AttachmentDownloads';
import * as LinkPreview from '../types/LinkPreview';
import { SignalService as Proto } from '../protobuf';
import { normalMessageSendJobQueue } from '../jobs/normalMessageSendJobQueue';
import type { PreviewType as OutgoingPreviewType } from '../textsecure/SendMessage';
/* eslint-disable camelcase */
/* eslint-disable more/no-then */
@ -134,10 +135,6 @@ const {
} = window.Signal.Types;
const {
deleteExternalMessageFiles,
loadAttachmentData,
loadQuoteData,
loadPreviewData,
loadStickerData,
upgradeMessageSchema,
} = window.Signal.Migrations;
const { getTextWithMentions, GoogleChrome } = window.Signal.Util;
@ -190,6 +187,12 @@ export class MessageModel extends window.Backbone.Model<MessageAttributesType> {
syncPromise?: Promise<CallbackResultType | void>;
cachedOutgoingPreviewData?: Array<OutgoingPreviewType>;
cachedOutgoingQuoteData?: WhatIsThis;
cachedOutgoingStickerData?: WhatIsThis;
initialize(attributes: unknown): void {
if (_.isObject(attributes)) {
this.set(
@ -1200,42 +1203,27 @@ export class MessageModel extends window.Backbone.Model<MessageAttributesType> {
return window.ConversationController.getOrCreate(source, 'private');
}
// Send infrastructure
// One caller today: event handler for the 'Retry Send' entry in triple-dot menu
async retrySend(): Promise<string | null | void | Array<void>> {
if (!window.textsecure.messaging) {
window.log.error('retrySend: Cannot retry since we are offline!');
return null;
}
async retrySend(): Promise<void> {
const retryOptions = this.get('retryOptions');
this.set({ errors: undefined, retryOptions: undefined });
if (retryOptions) {
if (!window.textsecure.messaging) {
window.log.error('retrySend: Cannot retry since we are offline!');
return;
}
this.unset('errors');
this.unset('retryOptions');
return this.sendUtilityMessageWithRetry(retryOptions);
}
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
const conversation = this.getConversation()!;
const currentRecipients = new Set<string>(
conversation
.getRecipients()
.map(identifier =>
window.ConversationController.getConversationId(identifier)
)
.filter(isNotNil)
);
const profileKey = conversation.get('profileSharing')
? await ourProfileKeyService.get()
: undefined;
const currentConversationRecipients = conversation.getRecipientConversationIds();
// Determine retry recipients and get their most up-to-date addressing information
const oldSendStateByConversationId =
this.get('sendStateByConversationId') || {};
const recipients: Array<string> = [];
const newSendStateByConversationId = { ...oldSendStateByConversationId };
for (const [conversationId, sendState] of Object.entries(
oldSendStateByConversationId
@ -1244,15 +1232,12 @@ export class MessageModel extends window.Backbone.Model<MessageAttributesType> {
continue;
}
const isStillInConversation = currentRecipients.has(conversationId);
if (!isStillInConversation) {
continue;
}
const recipient = window.ConversationController.get(
conversationId
)?.getSendTarget();
if (!recipient) {
const recipient = window.ConversationController.get(conversationId);
if (
!recipient ||
(!currentConversationRecipients.has(conversationId) &&
!isMe(recipient.attributes))
) {
continue;
}
@ -1263,133 +1248,15 @@ export class MessageModel extends window.Backbone.Model<MessageAttributesType> {
updatedAt: Date.now(),
}
);
recipients.push(recipient);
}
this.set('sendStateByConversationId', newSendStateByConversationId);
await window.Signal.Data.saveMessage(this.attributes);
if (!recipients.length) {
window.log.warn('retrySend: Nobody to send to!');
return undefined;
}
const attachmentsWithData = await Promise.all(
(this.get('attachments') || []).map(loadAttachmentData)
);
const {
body,
attachments,
} = window.Whisper.Message.getLongMessageAttachment({
body: this.get('body'),
attachments: attachmentsWithData,
now: this.get('sent_at'),
});
const quoteWithData = await loadQuoteData(this.get('quote'));
const previewWithData = await loadPreviewData(this.get('preview'));
const stickerWithData = await loadStickerData(this.get('sticker'));
const ourNumber = window.textsecure.storage.user.getNumber();
// Special-case the self-send case - we send only a sync message
if (
recipients.length === 1 &&
(recipients[0] === ourNumber || recipients[0] === this.OUR_UUID)
) {
const dataMessage = await window.textsecure.messaging.getDataMessage({
attachments,
body,
deletedForEveryoneTimestamp: this.get('deletedForEveryoneTimestamp'),
expireTimer: this.get('expireTimer'),
// flags
mentions: this.get('bodyRanges'),
preview: previewWithData,
profileKey,
quote: quoteWithData,
reaction: null,
recipients,
sticker: stickerWithData,
timestamp: this.get('sent_at'),
});
return this.sendSyncMessageOnly(dataMessage);
}
let promise;
const options = await getSendOptions(conversation.attributes);
const { ContentHint } = Proto.UnidentifiedSenderMessage.Message;
if (isDirectConversation(conversation.attributes)) {
const [identifier] = recipients;
promise = window.textsecure.messaging.sendMessageToIdentifier({
identifier,
messageText: body,
attachments,
quote: quoteWithData,
preview: previewWithData,
sticker: stickerWithData,
reaction: null,
deletedForEveryoneTimestamp: this.get('deletedForEveryoneTimestamp'),
timestamp: this.get('sent_at'),
expireTimer: this.get('expireTimer'),
contentHint: ContentHint.RESENDABLE,
groupId: undefined,
profileKey,
options,
});
} else {
const initialGroupV2 = conversation.getGroupV2Info();
const groupId = conversation.get('groupId');
if (!groupId) {
throw new Error("retrySend: Conversation didn't have groupId");
await normalMessageSendJobQueue.add(
{ messageId: this.id, conversationId: conversation.id },
async jobToInsert => {
await window.Signal.Data.saveMessage(this.attributes, { jobToInsert });
}
const groupV2 = initialGroupV2
? {
...initialGroupV2,
members: recipients,
}
: undefined;
const groupV1 = groupV2
? undefined
: {
id: groupId,
members: recipients,
};
promise = window.Signal.Util.sendToGroup({
groupSendOptions: {
messageText: body,
timestamp: this.get('sent_at'),
attachments,
quote: quoteWithData,
preview: previewWithData,
sticker: stickerWithData,
expireTimer: this.get('expireTimer'),
mentions: this.get('bodyRanges'),
profileKey,
groupV2,
groupV1,
},
conversation,
contentHint: ContentHint.RESENDABLE,
// Important to ensure that we don't consider this recipient list to be the
// entire member list.
isPartialSend: true,
messageId: this.id,
sendOptions: options,
sendType: 'messageRetry',
});
}
return this.send(
handleMessageSend(promise, {
messageIds: [this.id],
sendType: 'messageRetry',
})
);
}
@ -1414,118 +1281,20 @@ export class MessageModel extends window.Backbone.Model<MessageAttributesType> {
return isEmpty(withoutMe) || someSendStatus(withoutMe, isSent);
}
// Called when the user ran into an error with a specific user, wants to send to them
// One caller today: ConversationView.forceSend()
async resend(identifier: string): Promise<void | null | Array<void>> {
const error = this.removeOutgoingErrors(identifier);
if (!error) {
window.log.warn(
'resend: requested number was not present in errors. continuing.'
);
}
if (this.isErased()) {
window.log.warn('resend: message is erased; refusing to resend');
return null;
}
const profileKey = undefined;
const attachmentsWithData = await Promise.all(
(this.get('attachments') || []).map(loadAttachmentData)
);
const {
body,
attachments,
} = window.Whisper.Message.getLongMessageAttachment({
body: this.get('body'),
attachments: attachmentsWithData,
now: this.get('sent_at'),
});
const quoteWithData = await loadQuoteData(this.get('quote'));
const previewWithData = await loadPreviewData(this.get('preview'));
const stickerWithData = await loadStickerData(this.get('sticker'));
const ourNumber = window.textsecure.storage.user.getNumber();
// Special-case the self-send case - we send only a sync message
if (identifier === ourNumber || identifier === this.OUR_UUID) {
const dataMessage = await window.textsecure.messaging.getDataMessage({
attachments,
body,
deletedForEveryoneTimestamp: this.get('deletedForEveryoneTimestamp'),
expireTimer: this.get('expireTimer'),
mentions: this.get('bodyRanges'),
preview: previewWithData,
profileKey,
quote: quoteWithData,
reaction: null,
recipients: [identifier],
sticker: stickerWithData,
timestamp: this.get('sent_at'),
});
return this.sendSyncMessageOnly(dataMessage);
}
const { ContentHint } = Proto.UnidentifiedSenderMessage.Message;
const parentConversation = this.getConversation();
const groupId = parentConversation?.get('groupId');
const recipientConversation = window.ConversationController.get(identifier);
const sendOptions = recipientConversation
? await getSendOptions(recipientConversation.attributes)
: undefined;
const group =
groupId && isGroupV1(parentConversation?.attributes)
? {
id: groupId,
type: Proto.GroupContext.Type.DELIVER,
}
: undefined;
const timestamp = this.get('sent_at');
const contentMessage = await window.textsecure.messaging.getContentMessage({
attachments,
body,
expireTimer: this.get('expireTimer'),
group,
groupV2: parentConversation?.getGroupV2Info(),
preview: previewWithData,
quote: quoteWithData,
mentions: this.get('bodyRanges'),
recipients: [identifier],
sticker: stickerWithData,
timestamp,
});
if (parentConversation) {
const senderKeyInfo = parentConversation.get('senderKeyInfo');
if (senderKeyInfo && senderKeyInfo.distributionId) {
const senderKeyDistributionMessage = await window.textsecure.messaging.getSenderKeyDistributionMessage(
senderKeyInfo.distributionId
);
contentMessage.senderKeyDistributionMessage = senderKeyDistributionMessage.serialize();
}
}
const promise = window.textsecure.messaging.sendMessageProtoAndWait({
timestamp,
recipients: [identifier],
proto: contentMessage,
contentHint: ContentHint.RESENDABLE,
groupId:
groupId && isGroupV2(parentConversation?.attributes)
? groupId
: undefined,
options: sendOptions,
});
return this.send(
handleMessageSend(promise, {
messageIds: [this.id],
sendType: 'messageRetry',
})
/**
* Change any Pending send state to Failed. Note that this will not mark successful
* sends failed.
*/
public markFailed(): void {
const now = Date.now();
this.set(
'sendStateByConversationId',
mapValues(this.get('sendStateByConversationId') || {}, sendState =>
sendStateReducer(sendState, {
type: SendActionType.Failed,
updatedAt: now,
})
)
);
}
@ -1552,7 +1321,8 @@ export class MessageModel extends window.Backbone.Model<MessageAttributesType> {
}
async send(
promise: Promise<CallbackResultType | void | null>
promise: Promise<CallbackResultType | void | null>,
saveErrors?: (errors: Array<Error>) => void
): Promise<void | Array<void>> {
const updateLeftPane =
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.number);
if (conversation) {
if (conversation && !saveErrors) {
const previousSendState = getOwn(
sendStateByConversationId,
conversation.id
@ -1719,8 +1489,12 @@ export class MessageModel extends window.Backbone.Model<MessageAttributesType> {
attributesToUpdate.errors = [];
this.set(attributesToUpdate);
// We skip save because we'll save in the next step.
this.saveErrors(errorsToSave, { skipSave: true });
if (saveErrors) {
saveErrors(errorsToSave);
} else {
// We skip save because we'll save in the next step.
this.saveErrors(errorsToSave, { skipSave: true });
}
if (!this.doNotSave) {
await window.Signal.Data.saveMessage(this.attributes);
@ -1734,6 +1508,14 @@ export class MessageModel extends window.Backbone.Model<MessageAttributesType> {
await Promise.all(promises);
const isTotalSuccess: boolean =
result.success && !this.get('errors')?.length;
if (isTotalSuccess) {
delete this.cachedOutgoingPreviewData;
delete this.cachedOutgoingQuoteData;
delete this.cachedOutgoingStickerData;
}
updateLeftPane();
}
@ -1779,7 +1561,10 @@ export class MessageModel extends window.Backbone.Model<MessageAttributesType> {
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
const conv = this.getConversation()!;
this.set({ dataMessage });
@ -1800,9 +1585,16 @@ export class MessageModel extends window.Backbone.Model<MessageAttributesType> {
: undefined,
});
} catch (result) {
const errors = (result && result.errors) || [new Error('Unknown error')];
// We don't save because we're about to save below.
this.saveErrors(errors, { skipSave: true });
const resultErrors = result?.errors;
const errors = Array.isArray(resultErrors)
? resultErrors
: [new Error('Unknown error')];
if (saveErrors) {
saveErrors(errors);
} else {
// We don't save because we're about to save below.
this.saveErrors(errors, { skipSave: true });
}
} finally {
await window.Signal.Data.saveMessage(this.attributes);

View file

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

View file

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

View file

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

View file

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

View file

@ -18,6 +18,7 @@ import {
PreJoinConversationType,
} from '../ducks/conversations';
import { getOwn } from '../../util/getOwn';
import { isNotNil } from '../../util/isNotNil';
import { deconstructLookup } from '../../util/deconstructLookup';
import { PropsDataType as TimelinePropsType } from '../../components/conversation/Timeline';
import { TimelineItemType } from '../../components/conversation/TimelineItem';
@ -27,6 +28,7 @@ import { filterAndSortConversationsByTitle } from '../../util/filterAndSortConve
import { ContactNameColors, ContactNameColorType } from '../../types/Colors';
import { AvatarDataType } from '../../types/Avatar';
import { isInSystemContacts } from '../../util/isInSystemContacts';
import { sortByTitle } from '../../util/sortByTitle';
import { isGroupV2 } from '../../util/whatTypeOfConversation';
import {
@ -956,3 +958,51 @@ export const getGroupAdminsSelector = createSelector(
};
}
);
const getOutboundMessagesPendingConversationVerification = createSelector(
getConversations,
(
conversations: Readonly<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 {
SendStatus,
isDelivered,
isFailed,
isMessageJustForMe,
isRead,
isSent,
@ -1234,7 +1235,10 @@ export function getMessagePropStatus(
sendStateByConversationId[ourConversationId]?.status ??
SendStatus.Pending;
const sent = isSent(status);
if (hasErrors(message)) {
if (
hasErrors(message) ||
someSendStatus(sendStateByConversationId, isFailed)
) {
return sent ? 'partial-sent' : 'error';
}
return sent ? 'viewed' : 'sending';
@ -1248,7 +1252,10 @@ export function getMessagePropStatus(
SendStatus.Pending
);
if (hasErrors(message)) {
if (
hasErrors(message) ||
someSendStatus(sendStateByConversationId, isFailed)
) {
return isSent(highestSuccessfulStatus) ? 'partial-sent' : 'error';
}
if (isViewed(highestSuccessfulStatus)) {

View file

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

View file

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

View file

@ -12,6 +12,7 @@ import {
SendStateByConversationId,
SendStatus,
isDelivered,
isFailed,
isMessageJustForMe,
isRead,
isSent,
@ -106,6 +107,20 @@ describe('message send state utilities', () => {
});
});
describe('isFailed', () => {
it('returns true for failed statuses', () => {
assert.isTrue(isFailed(SendStatus.Failed));
});
it('returns false for non-failed statuses', () => {
assert.isFalse(isFailed(SendStatus.Viewed));
assert.isFalse(isFailed(SendStatus.Read));
assert.isFalse(isFailed(SendStatus.Delivered));
assert.isFalse(isFailed(SendStatus.Sent));
assert.isFalse(isFailed(SendStatus.Pending));
});
});
describe('someSendStatus', () => {
it('returns false if there are no send states', () => {
const alwaysTrue = () => true;

View file

@ -27,11 +27,14 @@ import {
getConversationByIdSelector,
getConversationsByTitleSelector,
getConversationSelector,
getConversationsStoppingMessageSendBecauseOfVerification,
getFilteredCandidateContactsForNewGroup,
getFilteredComposeContacts,
getFilteredComposeGroups,
getInvitedContactsForNewlyCreatedGroup,
getMaximumGroupSizeModalState,
getMessageIdsPendingBecauseOfVerification,
getNumberOfMessagesPendingBecauseOfVerification,
getPlaceholderContact,
getRecommendedGroupSizeModalState,
getSelectedConversation,
@ -266,6 +269,100 @@ describe('both/state/selectors/conversations', () => {
});
});
describe('#getConversationsStoppingMessageSendBecauseOfVerification', () => {
it('returns an empty array if there are no conversations stopping send', () => {
const state = getEmptyRootState();
assert.isEmpty(
getConversationsStoppingMessageSendBecauseOfVerification(state)
);
});
it('returns all conversations stopping message send', () => {
const convo1 = makeConversation('abc');
const convo2 = makeConversation('def');
const state = {
...getEmptyRootState(),
conversations: {
...getEmptyState(),
conversationLookup: {
def: convo2,
abc: convo1,
},
outboundMessagesPendingConversationVerification: {
def: ['message 2', 'message 3'],
abc: ['message 1', 'message 2'],
},
},
};
assert.deepEqual(
getConversationsStoppingMessageSendBecauseOfVerification(state),
[convo1, convo2]
);
});
});
describe('#getMessageIdsPendingBecauseOfVerification', () => {
it('returns an empty set if there are no conversations stopping send', () => {
const state = getEmptyRootState();
assert.deepEqual(
getMessageIdsPendingBecauseOfVerification(state),
new Set()
);
});
it('returns a set of unique pending messages', () => {
const state = {
...getEmptyRootState(),
conversations: {
...getEmptyState(),
outboundMessagesPendingConversationVerification: {
abc: ['message 2', 'message 3'],
def: ['message 1', 'message 2'],
ghi: ['message 4'],
},
},
};
assert.deepEqual(
getMessageIdsPendingBecauseOfVerification(state),
new Set(['message 1', 'message 2', 'message 3', 'message 4'])
);
});
});
describe('#getNumberOfMessagesPendingBecauseOfVerification', () => {
it('returns 0 if there are no conversations stopping send', () => {
const state = getEmptyRootState();
assert.strictEqual(
getNumberOfMessagesPendingBecauseOfVerification(state),
0
);
});
it('returns a count of unique pending messages', () => {
const state = {
...getEmptyRootState(),
conversations: {
...getEmptyState(),
outboundMessagesPendingConversationVerification: {
abc: ['message 2', 'message 3'],
def: ['message 1', 'message 2'],
ghi: ['message 4'],
},
},
};
assert.strictEqual(
getNumberOfMessagesPendingBecauseOfVerification(state),
4
);
});
});
describe('#getInvitedContactsForNewlyCreatedGroup', () => {
it('returns an empty array if there are no invited contacts', () => {
const state = getEmptyRootState();

View file

@ -45,6 +45,7 @@ const {
closeRecommendedGroupSizeModal,
createGroup,
messageSizeChanged,
messageStoppedByMissingVerification,
openConversationInternal,
repairNewestMessage,
repairOldestMessage,
@ -888,6 +889,35 @@ describe('both/state/ducks/conversations', () => {
});
});
describe('MESSAGE_STOPPED_BY_MISSING_VERIFICATION', () => {
it('adds messages that need conversation verification, removing duplicates', () => {
const first = reducer(
getEmptyState(),
messageStoppedByMissingVerification('message 1', ['convo 1'])
);
const second = reducer(
first,
messageStoppedByMissingVerification('message 1', ['convo 2'])
);
const third = reducer(
second,
messageStoppedByMissingVerification('message 2', [
'convo 1',
'convo 3',
])
);
assert.deepStrictEqual(
third.outboundMessagesPendingConversationVerification,
{
'convo 1': ['message 1', 'message 2'],
'convo 2': ['message 1'],
'convo 3': ['message 2'],
}
);
});
});
describe('REPAIR_NEWEST_MESSAGE', () => {
it('updates newest', () => {
const action = repairNewestMessage(conversationId);

View file

@ -92,6 +92,32 @@ describe('JobQueueDatabaseStore', () => {
assert.deepEqual(events, ['insert', 'yielded job']);
});
it('can skip the database', async () => {
const store = new JobQueueDatabaseStore(fakeDatabase);
const streamPromise = (async () => {
// We don't actually care about using the variable from the async iterable.
// eslint-disable-next-line @typescript-eslint/no-unused-vars
for await (const _job of store.stream('test queue')) {
break;
}
})();
await store.insert(
{
id: 'abc',
timestamp: 1234,
queueType: 'test queue',
data: { hi: 5 },
},
{ shouldInsertIntoDatabase: false }
);
await streamPromise;
sinon.assert.notCalled(fakeDatabase.insertJob);
});
it("doesn't insert jobs until the initial fetch has completed", async () => {
const events: Array<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;
};
type PreviewType = {
export type PreviewType = {
url: string;
title: string;
image?: AttachmentType;

View file

@ -145,7 +145,6 @@ type MessageActionsType = {
) => unknown;
replyToMessage: (messageId: string) => unknown;
retrySend: (messageId: string) => unknown;
sendAnyway: (contactId: string, messageId: string) => unknown;
showContactDetail: (options: {
contact: EmbeddedContactType;
signalAccount?: string;
@ -1057,9 +1056,6 @@ export class ConversationView extends window.Backbone.View<ConversationModel> {
const downloadNewVersion = () => {
this.downloadNewVersion();
};
const sendAnyway = (contactId: string, messageId: string) => {
this.forceSend({ contactId, messageId });
};
const showSafetyNumber = (contactId: string) => {
this.showSafetyNumber(contactId);
};
@ -1092,7 +1088,6 @@ export class ConversationView extends window.Backbone.View<ConversationModel> {
reactToMessage,
replyToMessage,
retrySend,
sendAnyway,
showContactDetail,
showContactModal,
showSafetyNumber,
@ -2657,7 +2652,7 @@ export class ConversationView extends window.Backbone.View<ConversationModel> {
}
: undefined;
conversation.sendMessage(
conversation.enqueueMessageForSend(
undefined, // body
[],
undefined, // quote
@ -2683,7 +2678,7 @@ export class ConversationView extends window.Backbone.View<ConversationModel> {
)
);
conversation.sendMessage(
conversation.enqueueMessageForSend(
messageBody || undefined,
attachmentsToSend,
undefined, // quote
@ -2945,49 +2940,6 @@ export class ConversationView extends window.Backbone.View<ConversationModel> {
view.render();
}
// eslint-disable-next-line class-methods-use-this
forceSend({
contactId,
messageId,
}: Readonly<{ contactId: string; messageId: string }>): void {
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
const contact = window.ConversationController.get(contactId)!;
const message = window.MessageController.getById(messageId);
if (!message) {
throw new Error(`forceSend: Message ${messageId} missing!`);
}
window.showConfirmationDialog({
confirmStyle: 'negative',
message: window.i18n('identityKeyErrorOnSend', {
name1: contact.getTitle(),
name2: contact.getTitle(),
}),
okText: window.i18n('sendAnyway'),
resolve: async () => {
await contact.updateVerified();
if (contact.isUnverified()) {
await contact.setVerifiedDefault();
}
const untrusted = await contact.isUntrusted();
if (untrusted) {
contact.setApproved();
}
const sendTarget = contact.getSendTarget();
if (!sendTarget) {
throw new Error(
`forceSend: Contact ${contact.idForLogging()} had no sendTarget!`
);
}
message.resend(sendTarget);
},
});
}
showSafetyNumber(id?: string): void {
let conversation: undefined | ConversationModel;
@ -4200,7 +4152,7 @@ export class ConversationView extends window.Backbone.View<ConversationModel> {
window.log.info('Send pre-checks took', sendDelta, 'milliseconds');
batchedUpdates(() => {
model.sendMessage(
model.enqueueMessageForSend(
message,
attachments,
this.quote,