Retry outbound "normal" messages for up to a day
This commit is contained in:
parent
62cf51c060
commit
a85dd1be36
30 changed files with 1414 additions and 603 deletions
|
@ -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",
|
||||
|
|
|
@ -940,6 +940,7 @@ export async function startApp(): Promise<void> {
|
|||
),
|
||||
messagesByConversation: {},
|
||||
messagesLookup: {},
|
||||
outboundMessagesPendingConversationVerification: {},
|
||||
selectedConversationId: undefined,
|
||||
selectedMessage: undefined,
|
||||
selectedMessageCounter: 0,
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -61,7 +61,6 @@ const createProps = (overrideProps: Partial<Props> = {}): Props => ({
|
|||
i18n,
|
||||
interactionMode: 'keyboard',
|
||||
|
||||
sendAnyway: action('onSendAnyway'),
|
||||
showSafetyNumber: action('onShowSafetyNumber'),
|
||||
|
||||
checkForAccount: action('checkForAccount'),
|
||||
|
|
|
@ -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 ? (
|
||||
|
|
|
@ -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>) {
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
|
|
19
ts/jobs/formatJobForInsert.ts
Normal file
19
ts/jobs/formatJobForInsert.ts
Normal 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,
|
||||
});
|
|
@ -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();
|
||||
|
|
521
ts/jobs/normalMessageSendJobQueue.ts
Normal file
521
ts/jobs/normalMessageSendJobQueue.ts
Normal 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,
|
||||
}
|
||||
);
|
||||
}
|
|
@ -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
|
||||
|
|
46
ts/messages/getMessagesById.ts
Normal file
46
ts/messages/getMessagesById.ts
Normal 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];
|
||||
}
|
|
@ -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?
|
||||
|
|
|
@ -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);
|
||||
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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>,
|
||||
|
|
|
@ -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();
|
||||
|
||||
|
|
|
@ -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];
|
||||
|
|
|
@ -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
|
||||
);
|
||||
|
|
|
@ -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)) {
|
||||
|
|
|
@ -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),
|
||||
};
|
||||
};
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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();
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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> = [];
|
||||
|
||||
|
|
27
ts/test-node/jobs/formatJobForInsert_test.ts
Normal file
27
ts/test-node/jobs/formatJobForInsert_test.ts
Normal 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' },
|
||||
});
|
||||
});
|
||||
});
|
|
@ -77,7 +77,7 @@ export type SendOptionsType = {
|
|||
online?: boolean;
|
||||
};
|
||||
|
||||
type PreviewType = {
|
||||
export type PreviewType = {
|
||||
url: string;
|
||||
title: string;
|
||||
image?: AttachmentType;
|
||||
|
|
|
@ -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,
|
||||
|
|
Loading…
Reference in a new issue