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.",
|
"message": "The following people may have reinstalled or changed devices. Verify your safety number with them to ensure privacy.",
|
||||||
"description": "Shown on confirmation dialog when user attempts to send a message"
|
"description": "Shown on confirmation dialog when user attempts to send a message"
|
||||||
},
|
},
|
||||||
|
"safetyNumberChangeDialog__pending-messages--1": {
|
||||||
|
"message": "Send pending message",
|
||||||
|
"description": "Shown on confirmation dialog when user attempts to send a message in the outbox"
|
||||||
|
},
|
||||||
|
"safetyNumberChangeDialog__pending-messages--many": {
|
||||||
|
"message": "Send $count$ pending messages",
|
||||||
|
"description": "Shown on confirmation dialog when user attempts to send a message in the outbox",
|
||||||
|
"placeholders": {
|
||||||
|
"count": {
|
||||||
|
"content": "$1",
|
||||||
|
"example": 123
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"identityKeyErrorOnSend": {
|
"identityKeyErrorOnSend": {
|
||||||
"message": "Your safety number with $name1$ has changed. This could either mean that someone is trying to intercept your communication or that $name2$ has simply reinstalled Signal. You may wish to verify your safety number with this contact.",
|
"message": "Your safety number with $name1$ has changed. This could either mean that someone is trying to intercept your communication or that $name2$ has simply reinstalled Signal. You may wish to verify your safety number with this contact.",
|
||||||
"description": "Shown when user clicks on a failed recipient in the message detail view after an identity key change",
|
"description": "Shown when user clicks on a failed recipient in the message detail view after an identity key change",
|
||||||
|
|
|
@ -940,6 +940,7 @@ export async function startApp(): Promise<void> {
|
||||||
),
|
),
|
||||||
messagesByConversation: {},
|
messagesByConversation: {},
|
||||||
messagesLookup: {},
|
messagesLookup: {},
|
||||||
|
outboundMessagesPendingConversationVerification: {},
|
||||||
selectedConversationId: undefined,
|
selectedConversationId: undefined,
|
||||||
selectedMessage: undefined,
|
selectedMessage: undefined,
|
||||||
selectedMessageCounter: 0,
|
selectedMessageCounter: 0,
|
||||||
|
|
|
@ -1,7 +1,7 @@
|
||||||
// Copyright 2021 Signal Messenger, LLC
|
// Copyright 2021 Signal Messenger, LLC
|
||||||
// SPDX-License-Identifier: AGPL-3.0-only
|
// SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
|
||||||
import React, { useEffect } from 'react';
|
import React, { ComponentProps, useEffect } from 'react';
|
||||||
import classNames from 'classnames';
|
import classNames from 'classnames';
|
||||||
|
|
||||||
import { AppViewType } from '../state/ducks/app';
|
import { AppViewType } from '../state/ducks/app';
|
||||||
|
@ -11,20 +11,25 @@ import { StandaloneRegistration } from './StandaloneRegistration';
|
||||||
import { ThemeType } from '../types/Util';
|
import { ThemeType } from '../types/Util';
|
||||||
import { usePageVisibility } from '../util/hooks';
|
import { usePageVisibility } from '../util/hooks';
|
||||||
|
|
||||||
export type PropsType = {
|
type PropsType = {
|
||||||
appView: AppViewType;
|
appView: AppViewType;
|
||||||
hasInitialLoadCompleted: boolean;
|
|
||||||
renderCallManager: () => JSX.Element;
|
renderCallManager: () => JSX.Element;
|
||||||
renderGlobalModalContainer: () => JSX.Element;
|
renderGlobalModalContainer: () => JSX.Element;
|
||||||
theme: ThemeType;
|
theme: ThemeType;
|
||||||
};
|
} & ComponentProps<typeof Inbox>;
|
||||||
|
|
||||||
export const App = ({
|
export const App = ({
|
||||||
appView,
|
appView,
|
||||||
|
cancelMessagesPendingConversationVerification,
|
||||||
|
conversationsStoppingMessageSendBecauseOfVerification,
|
||||||
hasInitialLoadCompleted,
|
hasInitialLoadCompleted,
|
||||||
|
i18n,
|
||||||
|
numberOfMessagesPendingBecauseOfVerification,
|
||||||
renderCallManager,
|
renderCallManager,
|
||||||
renderGlobalModalContainer,
|
renderGlobalModalContainer,
|
||||||
|
renderSafetyNumber,
|
||||||
theme,
|
theme,
|
||||||
|
verifyConversationsStoppingMessageSend,
|
||||||
}: PropsType): JSX.Element => {
|
}: PropsType): JSX.Element => {
|
||||||
let contents;
|
let contents;
|
||||||
|
|
||||||
|
@ -33,7 +38,25 @@ export const App = ({
|
||||||
} else if (appView === AppViewType.Standalone) {
|
} else if (appView === AppViewType.Standalone) {
|
||||||
contents = <StandaloneRegistration />;
|
contents = <StandaloneRegistration />;
|
||||||
} else if (appView === AppViewType.Inbox) {
|
} else if (appView === AppViewType.Inbox) {
|
||||||
contents = <Inbox hasInitialLoadCompleted={hasInitialLoadCompleted} />;
|
contents = (
|
||||||
|
<Inbox
|
||||||
|
cancelMessagesPendingConversationVerification={
|
||||||
|
cancelMessagesPendingConversationVerification
|
||||||
|
}
|
||||||
|
conversationsStoppingMessageSendBecauseOfVerification={
|
||||||
|
conversationsStoppingMessageSendBecauseOfVerification
|
||||||
|
}
|
||||||
|
hasInitialLoadCompleted={hasInitialLoadCompleted}
|
||||||
|
i18n={i18n}
|
||||||
|
numberOfMessagesPendingBecauseOfVerification={
|
||||||
|
numberOfMessagesPendingBecauseOfVerification
|
||||||
|
}
|
||||||
|
renderSafetyNumber={renderSafetyNumber}
|
||||||
|
verifyConversationsStoppingMessageSend={
|
||||||
|
verifyConversationsStoppingMessageSend
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// This are here so that themes are properly applied to anything that is
|
// This are here so that themes are properly applied to anything that is
|
||||||
|
|
|
@ -1,8 +1,14 @@
|
||||||
// Copyright 2021 Signal Messenger, LLC
|
// Copyright 2021 Signal Messenger, LLC
|
||||||
// SPDX-License-Identifier: AGPL-3.0-only
|
// SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
|
||||||
import React, { useEffect, useRef } from 'react';
|
import React, { ReactNode, useEffect, useRef } from 'react';
|
||||||
import * as Backbone from 'backbone';
|
import * as Backbone from 'backbone';
|
||||||
|
import {
|
||||||
|
SafetyNumberChangeDialog,
|
||||||
|
SafetyNumberProps,
|
||||||
|
} from './SafetyNumberChangeDialog';
|
||||||
|
import type { ConversationType } from '../state/ducks/conversations';
|
||||||
|
import type { LocalizerType } from '../types/Util';
|
||||||
|
|
||||||
type InboxViewType = Backbone.View & {
|
type InboxViewType = Backbone.View & {
|
||||||
onEmpty?: () => void;
|
onEmpty?: () => void;
|
||||||
|
@ -14,10 +20,24 @@ type InboxViewOptionsType = Backbone.ViewOptions & {
|
||||||
};
|
};
|
||||||
|
|
||||||
export type PropsType = {
|
export type PropsType = {
|
||||||
|
cancelMessagesPendingConversationVerification: () => void;
|
||||||
|
conversationsStoppingMessageSendBecauseOfVerification: Array<ConversationType>;
|
||||||
hasInitialLoadCompleted: boolean;
|
hasInitialLoadCompleted: boolean;
|
||||||
|
i18n: LocalizerType;
|
||||||
|
numberOfMessagesPendingBecauseOfVerification: number;
|
||||||
|
renderSafetyNumber: (props: SafetyNumberProps) => JSX.Element;
|
||||||
|
verifyConversationsStoppingMessageSend: () => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const Inbox = ({ hasInitialLoadCompleted }: PropsType): JSX.Element => {
|
export const Inbox = ({
|
||||||
|
cancelMessagesPendingConversationVerification,
|
||||||
|
conversationsStoppingMessageSendBecauseOfVerification,
|
||||||
|
hasInitialLoadCompleted,
|
||||||
|
i18n,
|
||||||
|
numberOfMessagesPendingBecauseOfVerification,
|
||||||
|
renderSafetyNumber,
|
||||||
|
verifyConversationsStoppingMessageSend,
|
||||||
|
}: PropsType): JSX.Element => {
|
||||||
const hostRef = useRef<HTMLDivElement | null>(null);
|
const hostRef = useRef<HTMLDivElement | null>(null);
|
||||||
const viewRef = useRef<InboxViewType | undefined>(undefined);
|
const viewRef = useRef<InboxViewType | undefined>(undefined);
|
||||||
|
|
||||||
|
@ -47,5 +67,30 @@ export const Inbox = ({ hasInitialLoadCompleted }: PropsType): JSX.Element => {
|
||||||
}
|
}
|
||||||
}, [hasInitialLoadCompleted, viewRef]);
|
}, [hasInitialLoadCompleted, viewRef]);
|
||||||
|
|
||||||
return <div className="inbox index" ref={hostRef} />;
|
let safetyNumberChangeDialog: ReactNode;
|
||||||
|
if (conversationsStoppingMessageSendBecauseOfVerification.length) {
|
||||||
|
const confirmText: string =
|
||||||
|
numberOfMessagesPendingBecauseOfVerification === 1
|
||||||
|
? i18n('safetyNumberChangeDialog__pending-messages--1')
|
||||||
|
: i18n('safetyNumberChangeDialog__pending-messages--many', [
|
||||||
|
numberOfMessagesPendingBecauseOfVerification.toString(),
|
||||||
|
]);
|
||||||
|
safetyNumberChangeDialog = (
|
||||||
|
<SafetyNumberChangeDialog
|
||||||
|
confirmText={confirmText}
|
||||||
|
contacts={conversationsStoppingMessageSendBecauseOfVerification}
|
||||||
|
i18n={i18n}
|
||||||
|
onCancel={cancelMessagesPendingConversationVerification}
|
||||||
|
onConfirm={verifyConversationsStoppingMessageSend}
|
||||||
|
renderSafetyNumber={renderSafetyNumber}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="inbox index" ref={hostRef} />
|
||||||
|
{safetyNumberChangeDialog}
|
||||||
|
</>
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|
|
@ -61,7 +61,6 @@ const createProps = (overrideProps: Partial<Props> = {}): Props => ({
|
||||||
i18n,
|
i18n,
|
||||||
interactionMode: 'keyboard',
|
interactionMode: 'keyboard',
|
||||||
|
|
||||||
sendAnyway: action('onSendAnyway'),
|
|
||||||
showSafetyNumber: action('onShowSafetyNumber'),
|
showSafetyNumber: action('onShowSafetyNumber'),
|
||||||
|
|
||||||
checkForAccount: action('checkForAccount'),
|
checkForAccount: action('checkForAccount'),
|
||||||
|
|
|
@ -53,7 +53,6 @@ export type PropsData = {
|
||||||
receivedAt: number;
|
receivedAt: number;
|
||||||
sentAt: number;
|
sentAt: number;
|
||||||
|
|
||||||
sendAnyway: (contactId: string, messageId: string) => unknown;
|
|
||||||
showSafetyNumber: (contactId: string) => void;
|
showSafetyNumber: (contactId: string) => void;
|
||||||
i18n: LocalizerType;
|
i18n: LocalizerType;
|
||||||
} & Pick<MessagePropsType, 'interactionMode'>;
|
} & Pick<MessagePropsType, 'interactionMode'>;
|
||||||
|
@ -145,7 +144,7 @@ export class MessageDetail extends React.Component<Props> {
|
||||||
}
|
}
|
||||||
|
|
||||||
public renderContact(contact: Contact): JSX.Element {
|
public renderContact(contact: Contact): JSX.Element {
|
||||||
const { i18n, message, showSafetyNumber, sendAnyway } = this.props;
|
const { i18n, showSafetyNumber } = this.props;
|
||||||
const errors = contact.errors || [];
|
const errors = contact.errors || [];
|
||||||
|
|
||||||
const errorComponent = contact.isOutgoingKeyError ? (
|
const errorComponent = contact.isOutgoingKeyError ? (
|
||||||
|
@ -157,13 +156,6 @@ export class MessageDetail extends React.Component<Props> {
|
||||||
>
|
>
|
||||||
{i18n('showSafetyNumber')}
|
{i18n('showSafetyNumber')}
|
||||||
</button>
|
</button>
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="module-message-detail__contact__send-anyway"
|
|
||||||
onClick={() => sendAnyway(contact.id, message.id)}
|
|
||||||
>
|
|
||||||
{i18n('sendAnyway')}
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
) : null;
|
) : null;
|
||||||
const unidentifiedDeliveryComponent = contact.isUnidentifiedDelivery ? (
|
const unidentifiedDeliveryComponent = contact.isUnidentifiedDelivery ? (
|
||||||
|
|
|
@ -136,12 +136,23 @@ export abstract class JobQueue<T> {
|
||||||
* If `streamJobs` has not been called yet, this will throw an error.
|
* If `streamJobs` has not been called yet, this will throw an error.
|
||||||
*/
|
*/
|
||||||
async add(data: Readonly<T>): Promise<Job<T>> {
|
async add(data: Readonly<T>): Promise<Job<T>> {
|
||||||
|
this.throwIfNotStarted();
|
||||||
|
|
||||||
|
const job = this.createJob(data);
|
||||||
|
await this.store.insert(job);
|
||||||
|
log.info(`${this.logPrefix} added new job ${job.id}`);
|
||||||
|
return job;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected throwIfNotStarted(): void {
|
||||||
if (!this.started) {
|
if (!this.started) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
`${this.logPrefix} has not started streaming. Make sure to call streamJobs().`
|
`${this.logPrefix} has not started streaming. Make sure to call streamJobs().`
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
protected createJob(data: Readonly<T>): Job<T> {
|
||||||
const id = uuid();
|
const id = uuid();
|
||||||
const timestamp = Date.now();
|
const timestamp = Date.now();
|
||||||
|
|
||||||
|
@ -158,11 +169,7 @@ export abstract class JobQueue<T> {
|
||||||
}
|
}
|
||||||
})();
|
})();
|
||||||
|
|
||||||
log.info(`${this.logPrefix} added new job ${id}`);
|
return new Job(id, timestamp, this.queueType, data, completion);
|
||||||
|
|
||||||
const job = new Job(id, timestamp, this.queueType, data, completion);
|
|
||||||
await this.store.insert(job);
|
|
||||||
return job;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private async enqueueStoredJob(storedJob: Readonly<StoredJob>) {
|
private async enqueueStoredJob(storedJob: Readonly<StoredJob>) {
|
||||||
|
|
|
@ -1,10 +1,11 @@
|
||||||
// Copyright 2021 Signal Messenger, LLC
|
// Copyright 2021 Signal Messenger, LLC
|
||||||
// SPDX-License-Identifier: AGPL-3.0-only
|
// SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
|
||||||
import { pick, noop } from 'lodash';
|
import { noop } from 'lodash';
|
||||||
import { AsyncQueue } from '../util/AsyncQueue';
|
import { AsyncQueue } from '../util/AsyncQueue';
|
||||||
import { concat, wrapPromise } from '../util/asyncIterables';
|
import { concat, wrapPromise } from '../util/asyncIterables';
|
||||||
import { JobQueueStore, StoredJob } from './types';
|
import { JobQueueStore, StoredJob } from './types';
|
||||||
|
import { formatJobForInsert } from './formatJobForInsert';
|
||||||
import databaseInterface from '../sql/Client';
|
import databaseInterface from '../sql/Client';
|
||||||
import * as log from '../logging/log';
|
import * as log from '../logging/log';
|
||||||
|
|
||||||
|
@ -23,7 +24,12 @@ export class JobQueueDatabaseStore implements JobQueueStore {
|
||||||
|
|
||||||
constructor(private readonly db: Database) {}
|
constructor(private readonly db: Database) {}
|
||||||
|
|
||||||
async insert(job: Readonly<StoredJob>): Promise<void> {
|
async insert(
|
||||||
|
job: Readonly<StoredJob>,
|
||||||
|
{
|
||||||
|
shouldInsertIntoDatabase = true,
|
||||||
|
}: Readonly<{ shouldInsertIntoDatabase?: boolean }> = {}
|
||||||
|
): Promise<void> {
|
||||||
log.info(
|
log.info(
|
||||||
`JobQueueDatabaseStore adding job ${job.id} to queue ${JSON.stringify(
|
`JobQueueDatabaseStore adding job ${job.id} to queue ${JSON.stringify(
|
||||||
job.queueType
|
job.queueType
|
||||||
|
@ -40,9 +46,9 @@ export class JobQueueDatabaseStore implements JobQueueStore {
|
||||||
}
|
}
|
||||||
await initialFetchPromise;
|
await initialFetchPromise;
|
||||||
|
|
||||||
await this.db.insertJob(
|
if (shouldInsertIntoDatabase) {
|
||||||
pick(job, ['id', 'timestamp', 'queueType', 'data'])
|
await this.db.insertJob(formatJobForInsert(job));
|
||||||
);
|
}
|
||||||
|
|
||||||
this.getQueue(job.queueType).add(job);
|
this.getQueue(job.queueType).add(job);
|
||||||
}
|
}
|
||||||
|
|
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 type { WebAPIType } from '../textsecure/WebAPI';
|
||||||
|
|
||||||
|
import { normalMessageSendJobQueue } from './normalMessageSendJobQueue';
|
||||||
import { readSyncJobQueue } from './readSyncJobQueue';
|
import { readSyncJobQueue } from './readSyncJobQueue';
|
||||||
import { removeStorageKeyJobQueue } from './removeStorageKeyJobQueue';
|
import { removeStorageKeyJobQueue } from './removeStorageKeyJobQueue';
|
||||||
import { reportSpamJobQueue } from './reportSpamJobQueue';
|
import { reportSpamJobQueue } from './reportSpamJobQueue';
|
||||||
|
@ -19,6 +20,7 @@ export function initializeAllJobQueues({
|
||||||
}): void {
|
}): void {
|
||||||
reportSpamJobQueue.initialize({ server });
|
reportSpamJobQueue.initialize({ server });
|
||||||
|
|
||||||
|
normalMessageSendJobQueue.streamJobs();
|
||||||
readSyncJobQueue.streamJobs();
|
readSyncJobQueue.streamJobs();
|
||||||
removeStorageKeyJobQueue.streamJobs();
|
removeStorageKeyJobQueue.streamJobs();
|
||||||
reportSpamJobQueue.streamJobs();
|
reportSpamJobQueue.streamJobs();
|
||||||
|
|
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];
|
STATUS_NUMBERS[status] >= STATUS_NUMBERS[SendStatus.Delivered];
|
||||||
export const isSent = (status: SendStatus): boolean =>
|
export const isSent = (status: SendStatus): boolean =>
|
||||||
STATUS_NUMBERS[status] >= STATUS_NUMBERS[SendStatus.Sent];
|
STATUS_NUMBERS[status] >= STATUS_NUMBERS[SendStatus.Sent];
|
||||||
|
export const isFailed = (status: SendStatus): boolean =>
|
||||||
|
status === SendStatus.Failed;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* `SendState` combines `SendStatus` and a timestamp. You can use it to show things to the
|
* `SendState` combines `SendStatus` and a timestamp. You can use it to show things to the
|
||||||
|
|
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,
|
CustomColorType,
|
||||||
} from '../types/Colors';
|
} from '../types/Colors';
|
||||||
import { MessageModel } from './messages';
|
import { MessageModel } from './messages';
|
||||||
|
import { strictAssert } from '../util/assert';
|
||||||
import { isMuted } from '../util/isMuted';
|
import { isMuted } from '../util/isMuted';
|
||||||
import { isConversationSMSOnly } from '../util/isConversationSMSOnly';
|
import { isConversationSMSOnly } from '../util/isConversationSMSOnly';
|
||||||
import { isConversationUnregistered } from '../util/isConversationUnregistered';
|
import { isConversationUnregistered } from '../util/isConversationUnregistered';
|
||||||
|
@ -82,6 +83,7 @@ import {
|
||||||
isTapToView,
|
isTapToView,
|
||||||
getMessagePropStatus,
|
getMessagePropStatus,
|
||||||
} from '../state/selectors/message';
|
} from '../state/selectors/message';
|
||||||
|
import { normalMessageSendJobQueue } from '../jobs/normalMessageSendJobQueue';
|
||||||
import { Deletes } from '../messageModifiers/Deletes';
|
import { Deletes } from '../messageModifiers/Deletes';
|
||||||
import { Reactions, ReactionModel } from '../messageModifiers/Reactions';
|
import { Reactions, ReactionModel } from '../messageModifiers/Reactions';
|
||||||
import { isAnnouncementGroupReady } from '../util/isAnnouncementGroupReady';
|
import { isAnnouncementGroupReady } from '../util/isAnnouncementGroupReady';
|
||||||
|
@ -119,11 +121,6 @@ const ATTRIBUTES_THAT_DONT_INVALIDATE_PROPS_CACHE = new Set([
|
||||||
'profileLastFetchedAt',
|
'profileLastFetchedAt',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
type CustomError = Error & {
|
|
||||||
identifier?: string;
|
|
||||||
number?: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
type CachedIdenticon = {
|
type CachedIdenticon = {
|
||||||
readonly url: string;
|
readonly url: string;
|
||||||
readonly content: string;
|
readonly content: string;
|
||||||
|
@ -3111,6 +3108,10 @@ export class ConversationModel extends window.Backbone
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
getRecipientConversationIds(): Set<string> {
|
||||||
|
return new Set(map(this.getMembers(), conversation => conversation.id));
|
||||||
|
}
|
||||||
|
|
||||||
async getQuoteAttachment(
|
async getQuoteAttachment(
|
||||||
attachments?: Array<WhatIsThis>,
|
attachments?: Array<WhatIsThis>,
|
||||||
preview?: Array<WhatIsThis>,
|
preview?: Array<WhatIsThis>,
|
||||||
|
@ -3261,7 +3262,7 @@ export class ConversationModel extends window.Backbone
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
this.sendMessage(undefined, [], undefined, [], sticker);
|
this.enqueueMessageForSend(undefined, [], undefined, [], sticker);
|
||||||
window.reduxActions.stickers.useSticker(packId, stickerId);
|
window.reduxActions.stickers.useSticker(packId, stickerId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -3577,7 +3578,7 @@ export class ConversationModel extends window.Backbone
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
sendMessage(
|
async enqueueMessageForSend(
|
||||||
body: string | undefined,
|
body: string | undefined,
|
||||||
attachments: Array<AttachmentType>,
|
attachments: Array<AttachmentType>,
|
||||||
quote?: QuotedMessageType,
|
quote?: QuotedMessageType,
|
||||||
|
@ -3593,7 +3594,7 @@ export class ConversationModel extends window.Backbone
|
||||||
sendHQImages?: boolean;
|
sendHQImages?: boolean;
|
||||||
timestamp?: number;
|
timestamp?: number;
|
||||||
} = {}
|
} = {}
|
||||||
): void {
|
): Promise<void> {
|
||||||
if (this.isGroupV1AndDisabled()) {
|
if (this.isGroupV1AndDisabled()) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
@ -3614,223 +3615,134 @@ export class ConversationModel extends window.Backbone
|
||||||
const destination = this.getSendTarget()!;
|
const destination = this.getSendTarget()!;
|
||||||
const recipients = this.getRecipients();
|
const recipients = this.getRecipients();
|
||||||
|
|
||||||
if (timestamp) {
|
const now = timestamp || Date.now();
|
||||||
window.log.info(`sendMessage: Queueing send with timestamp ${timestamp}`);
|
|
||||||
}
|
|
||||||
this.queueJob('sendMessage', async () => {
|
|
||||||
const now = timestamp || Date.now();
|
|
||||||
|
|
||||||
await this.maybeApplyUniversalTimer(false);
|
await this.maybeApplyUniversalTimer(false);
|
||||||
|
|
||||||
const expireTimer = this.get('expireTimer');
|
const expireTimer = this.get('expireTimer');
|
||||||
|
|
||||||
window.log.info(
|
window.log.info(
|
||||||
'Sending message to conversation',
|
'Sending message to conversation',
|
||||||
this.idForLogging(),
|
this.idForLogging(),
|
||||||
'with timestamp',
|
'with timestamp',
|
||||||
now
|
now
|
||||||
);
|
);
|
||||||
|
|
||||||
const recipientMaybeConversations = map(recipients, identifier =>
|
const recipientMaybeConversations = map(recipients, identifier =>
|
||||||
window.ConversationController.get(identifier)
|
window.ConversationController.get(identifier)
|
||||||
);
|
);
|
||||||
const recipientConversations = filter(
|
const recipientConversations = filter(
|
||||||
recipientMaybeConversations,
|
recipientMaybeConversations,
|
||||||
isNotNil
|
isNotNil
|
||||||
);
|
);
|
||||||
const recipientConversationIds = concat(
|
const recipientConversationIds = concat(
|
||||||
map(recipientConversations, c => c.id),
|
map(recipientConversations, c => c.id),
|
||||||
[window.ConversationController.getOurConversationIdOrThrow()]
|
[window.ConversationController.getOurConversationIdOrThrow()]
|
||||||
);
|
);
|
||||||
|
|
||||||
// Here we move attachments to disk
|
// Here we move attachments to disk
|
||||||
const messageWithSchema = await upgradeMessageSchema({
|
const messageWithSchema = await upgradeMessageSchema({
|
||||||
timestamp: now,
|
timestamp: now,
|
||||||
type: 'outgoing',
|
type: 'outgoing',
|
||||||
body,
|
body,
|
||||||
conversationId: this.id,
|
conversationId: this.id,
|
||||||
quote,
|
quote,
|
||||||
preview,
|
preview,
|
||||||
attachments,
|
attachments,
|
||||||
sent_at: now,
|
sent_at: now,
|
||||||
received_at: window.Signal.Util.incrementMessageCounter(),
|
received_at: window.Signal.Util.incrementMessageCounter(),
|
||||||
received_at_ms: now,
|
received_at_ms: now,
|
||||||
expireTimer,
|
expireTimer,
|
||||||
recipients,
|
recipients,
|
||||||
sticker,
|
sticker,
|
||||||
bodyRanges: mentions,
|
bodyRanges: mentions,
|
||||||
sendHQImages,
|
sendHQImages,
|
||||||
sendStateByConversationId: zipObject(
|
sendStateByConversationId: zipObject(
|
||||||
recipientConversationIds,
|
recipientConversationIds,
|
||||||
repeat({
|
repeat({
|
||||||
status: SendStatus.Pending,
|
status: SendStatus.Pending,
|
||||||
updatedAt: now,
|
updatedAt: now,
|
||||||
})
|
|
||||||
),
|
|
||||||
});
|
|
||||||
|
|
||||||
if (isDirectConversation(this.attributes)) {
|
|
||||||
messageWithSchema.destination = destination;
|
|
||||||
}
|
|
||||||
const attributes: MessageAttributesType = {
|
|
||||||
...messageWithSchema,
|
|
||||||
id: window.getGuid(),
|
|
||||||
};
|
|
||||||
|
|
||||||
const model = new window.Whisper.Message(attributes);
|
|
||||||
const message = window.MessageController.register(model.id, model);
|
|
||||||
|
|
||||||
const dbStart = Date.now();
|
|
||||||
|
|
||||||
await window.Signal.Data.saveMessage(message.attributes, {
|
|
||||||
forceSave: true,
|
|
||||||
});
|
|
||||||
|
|
||||||
const dbDuration = Date.now() - dbStart;
|
|
||||||
if (dbDuration > SEND_REPORTING_THRESHOLD_MS) {
|
|
||||||
window.log.info(
|
|
||||||
`ConversationModel(${this.idForLogging()}.sendMessage(${now}): ` +
|
|
||||||
`db save took ${dbDuration}ms`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const renderStart = Date.now();
|
|
||||||
|
|
||||||
this.addSingleMessage(model);
|
|
||||||
if (sticker) {
|
|
||||||
await addStickerPackReference(model.id, sticker.packId);
|
|
||||||
}
|
|
||||||
const messageId = message.id;
|
|
||||||
|
|
||||||
const draftProperties = dontClearDraft
|
|
||||||
? {}
|
|
||||||
: {
|
|
||||||
draft: null,
|
|
||||||
draftTimestamp: null,
|
|
||||||
lastMessage: model.getNotificationText(),
|
|
||||||
lastMessageStatus: 'sending' as const,
|
|
||||||
};
|
|
||||||
|
|
||||||
this.set({
|
|
||||||
...draftProperties,
|
|
||||||
active_at: now,
|
|
||||||
timestamp: now,
|
|
||||||
isArchived: false,
|
|
||||||
});
|
|
||||||
|
|
||||||
this.incrementSentMessageCount({ save: false });
|
|
||||||
|
|
||||||
const renderDuration = Date.now() - renderStart;
|
|
||||||
|
|
||||||
if (renderDuration > SEND_REPORTING_THRESHOLD_MS) {
|
|
||||||
window.log.info(
|
|
||||||
`ConversationModel(${this.idForLogging()}.sendMessage(${now}): ` +
|
|
||||||
`render save took ${renderDuration}ms`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
window.Signal.Data.updateConversation(this.attributes);
|
|
||||||
|
|
||||||
// We're offline!
|
|
||||||
if (!window.textsecure.messaging) {
|
|
||||||
const errors = map(recipientConversationIds, conversationId => {
|
|
||||||
const error = new Error('Network is not available') as CustomError;
|
|
||||||
error.name = 'SendMessageNetworkError';
|
|
||||||
error.identifier = conversationId;
|
|
||||||
return error;
|
|
||||||
});
|
|
||||||
await message.saveErrors([...errors]);
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const attachmentsWithData = await Promise.all(
|
|
||||||
messageWithSchema.attachments?.map(loadAttachmentData) ?? []
|
|
||||||
);
|
|
||||||
|
|
||||||
const {
|
|
||||||
body: messageBody,
|
|
||||||
attachments: finalAttachments,
|
|
||||||
} = window.Whisper.Message.getLongMessageAttachment({
|
|
||||||
body,
|
|
||||||
attachments: attachmentsWithData,
|
|
||||||
now,
|
|
||||||
});
|
|
||||||
|
|
||||||
let profileKey: ArrayBuffer | undefined;
|
|
||||||
if (this.get('profileSharing')) {
|
|
||||||
profileKey = await ourProfileKeyService.get();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Special-case the self-send case - we send only a sync message
|
|
||||||
if (isMe(this.attributes)) {
|
|
||||||
const dataMessage = await window.textsecure.messaging.getDataMessage({
|
|
||||||
attachments: finalAttachments,
|
|
||||||
body: messageBody,
|
|
||||||
// deletedForEveryoneTimestamp
|
|
||||||
expireTimer,
|
|
||||||
preview,
|
|
||||||
profileKey,
|
|
||||||
quote,
|
|
||||||
// reaction
|
|
||||||
recipients: [destination],
|
|
||||||
sticker,
|
|
||||||
timestamp: now,
|
|
||||||
});
|
|
||||||
return message.sendSyncMessageOnly(dataMessage);
|
|
||||||
}
|
|
||||||
|
|
||||||
const conversationType = this.get('type');
|
|
||||||
const options = await getSendOptions(this.attributes);
|
|
||||||
const { ContentHint } = Proto.UnidentifiedSenderMessage.Message;
|
|
||||||
|
|
||||||
let promise;
|
|
||||||
if (conversationType === Message.GROUP) {
|
|
||||||
promise = window.Signal.Util.sendToGroup({
|
|
||||||
groupSendOptions: {
|
|
||||||
attachments: finalAttachments,
|
|
||||||
expireTimer,
|
|
||||||
groupV1: this.getGroupV1Info(),
|
|
||||||
groupV2: this.getGroupV2Info(),
|
|
||||||
messageText: messageBody,
|
|
||||||
preview,
|
|
||||||
profileKey,
|
|
||||||
quote,
|
|
||||||
sticker,
|
|
||||||
timestamp: now,
|
|
||||||
mentions,
|
|
||||||
},
|
|
||||||
conversation: this,
|
|
||||||
contentHint: ContentHint.RESENDABLE,
|
|
||||||
messageId,
|
|
||||||
sendOptions: options,
|
|
||||||
sendType: 'message',
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
promise = window.textsecure.messaging.sendMessageToIdentifier({
|
|
||||||
identifier: destination,
|
|
||||||
messageText: messageBody,
|
|
||||||
attachments: finalAttachments,
|
|
||||||
quote,
|
|
||||||
preview,
|
|
||||||
sticker,
|
|
||||||
reaction: null,
|
|
||||||
deletedForEveryoneTimestamp: undefined,
|
|
||||||
timestamp: now,
|
|
||||||
expireTimer,
|
|
||||||
contentHint: ContentHint.RESENDABLE,
|
|
||||||
groupId: undefined,
|
|
||||||
profileKey,
|
|
||||||
options,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return message.send(
|
|
||||||
handleMessageSend(promise, {
|
|
||||||
messageIds: [messageId],
|
|
||||||
sendType: 'message',
|
|
||||||
})
|
})
|
||||||
);
|
),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (isDirectConversation(this.attributes)) {
|
||||||
|
messageWithSchema.destination = destination;
|
||||||
|
}
|
||||||
|
const attributes: MessageAttributesType = {
|
||||||
|
...messageWithSchema,
|
||||||
|
id: window.getGuid(),
|
||||||
|
};
|
||||||
|
|
||||||
|
const model = new window.Whisper.Message(attributes);
|
||||||
|
const message = window.MessageController.register(model.id, model);
|
||||||
|
message.cachedOutgoingPreviewData = preview;
|
||||||
|
message.cachedOutgoingQuoteData = quote;
|
||||||
|
message.cachedOutgoingStickerData = sticker;
|
||||||
|
|
||||||
|
const dbStart = Date.now();
|
||||||
|
|
||||||
|
strictAssert(
|
||||||
|
typeof message.attributes.timestamp === 'number',
|
||||||
|
'Expected a timestamp'
|
||||||
|
);
|
||||||
|
|
||||||
|
await normalMessageSendJobQueue.add(
|
||||||
|
{ messageId: message.id, conversationId: this.id },
|
||||||
|
async jobToInsert => {
|
||||||
|
window.log.info(
|
||||||
|
`enqueueMessageForSend: saving message ${message.id} and job ${jobToInsert.id}`
|
||||||
|
);
|
||||||
|
await window.Signal.Data.saveMessage(message.attributes, {
|
||||||
|
jobToInsert,
|
||||||
|
forceSave: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
const dbDuration = Date.now() - dbStart;
|
||||||
|
if (dbDuration > SEND_REPORTING_THRESHOLD_MS) {
|
||||||
|
window.log.info(
|
||||||
|
`ConversationModel(${this.idForLogging()}.sendMessage(${now}): ` +
|
||||||
|
`db save took ${dbDuration}ms`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const renderStart = Date.now();
|
||||||
|
|
||||||
|
this.addSingleMessage(model);
|
||||||
|
if (sticker) {
|
||||||
|
await addStickerPackReference(model.id, sticker.packId);
|
||||||
|
}
|
||||||
|
|
||||||
|
const draftProperties = dontClearDraft
|
||||||
|
? {}
|
||||||
|
: {
|
||||||
|
draft: null,
|
||||||
|
draftTimestamp: null,
|
||||||
|
lastMessage: model.getNotificationText(),
|
||||||
|
lastMessageStatus: 'sending' as const,
|
||||||
|
};
|
||||||
|
|
||||||
|
this.set({
|
||||||
|
...draftProperties,
|
||||||
|
active_at: now,
|
||||||
|
timestamp: now,
|
||||||
|
isArchived: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
this.incrementSentMessageCount({ save: false });
|
||||||
|
|
||||||
|
const renderDuration = Date.now() - renderStart;
|
||||||
|
|
||||||
|
if (renderDuration > SEND_REPORTING_THRESHOLD_MS) {
|
||||||
|
window.log.info(
|
||||||
|
`ConversationModel(${this.idForLogging()}.sendMessage(${now}): ` +
|
||||||
|
`render save took ${renderDuration}ms`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
window.Signal.Data.updateConversation(this.attributes);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Is this someone who is a contact, or are we sharing our profile with them?
|
// Is this someone who is a contact, or are we sharing our profile with them?
|
||||||
|
|
|
@ -1,7 +1,7 @@
|
||||||
// Copyright 2020-2021 Signal Messenger, LLC
|
// Copyright 2020-2021 Signal Messenger, LLC
|
||||||
// SPDX-License-Identifier: AGPL-3.0-only
|
// SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
|
||||||
import { isEmpty, isEqual, noop, omit, union } from 'lodash';
|
import { isEmpty, isEqual, mapValues, noop, omit, union } from 'lodash';
|
||||||
import {
|
import {
|
||||||
CustomError,
|
CustomError,
|
||||||
GroupV1Update,
|
GroupV1Update,
|
||||||
|
@ -43,7 +43,6 @@ import {
|
||||||
import * as Stickers from '../types/Stickers';
|
import * as Stickers from '../types/Stickers';
|
||||||
import { AttachmentType, isImage, isVideo } from '../types/Attachment';
|
import { AttachmentType, isImage, isVideo } from '../types/Attachment';
|
||||||
import { IMAGE_WEBP, stringToMIMEType } from '../types/MIME';
|
import { IMAGE_WEBP, stringToMIMEType } from '../types/MIME';
|
||||||
import { ourProfileKeyService } from '../services/ourProfileKey';
|
|
||||||
import { ReadStatus } from '../messages/MessageReadStatus';
|
import { ReadStatus } from '../messages/MessageReadStatus';
|
||||||
import {
|
import {
|
||||||
SendActionType,
|
SendActionType,
|
||||||
|
@ -112,6 +111,8 @@ import { ViewOnceOpenSyncs } from '../messageModifiers/ViewOnceOpenSyncs';
|
||||||
import * as AttachmentDownloads from '../messageModifiers/AttachmentDownloads';
|
import * as AttachmentDownloads from '../messageModifiers/AttachmentDownloads';
|
||||||
import * as LinkPreview from '../types/LinkPreview';
|
import * as LinkPreview from '../types/LinkPreview';
|
||||||
import { SignalService as Proto } from '../protobuf';
|
import { SignalService as Proto } from '../protobuf';
|
||||||
|
import { normalMessageSendJobQueue } from '../jobs/normalMessageSendJobQueue';
|
||||||
|
import type { PreviewType as OutgoingPreviewType } from '../textsecure/SendMessage';
|
||||||
|
|
||||||
/* eslint-disable camelcase */
|
/* eslint-disable camelcase */
|
||||||
/* eslint-disable more/no-then */
|
/* eslint-disable more/no-then */
|
||||||
|
@ -134,10 +135,6 @@ const {
|
||||||
} = window.Signal.Types;
|
} = window.Signal.Types;
|
||||||
const {
|
const {
|
||||||
deleteExternalMessageFiles,
|
deleteExternalMessageFiles,
|
||||||
loadAttachmentData,
|
|
||||||
loadQuoteData,
|
|
||||||
loadPreviewData,
|
|
||||||
loadStickerData,
|
|
||||||
upgradeMessageSchema,
|
upgradeMessageSchema,
|
||||||
} = window.Signal.Migrations;
|
} = window.Signal.Migrations;
|
||||||
const { getTextWithMentions, GoogleChrome } = window.Signal.Util;
|
const { getTextWithMentions, GoogleChrome } = window.Signal.Util;
|
||||||
|
@ -190,6 +187,12 @@ export class MessageModel extends window.Backbone.Model<MessageAttributesType> {
|
||||||
|
|
||||||
syncPromise?: Promise<CallbackResultType | void>;
|
syncPromise?: Promise<CallbackResultType | void>;
|
||||||
|
|
||||||
|
cachedOutgoingPreviewData?: Array<OutgoingPreviewType>;
|
||||||
|
|
||||||
|
cachedOutgoingQuoteData?: WhatIsThis;
|
||||||
|
|
||||||
|
cachedOutgoingStickerData?: WhatIsThis;
|
||||||
|
|
||||||
initialize(attributes: unknown): void {
|
initialize(attributes: unknown): void {
|
||||||
if (_.isObject(attributes)) {
|
if (_.isObject(attributes)) {
|
||||||
this.set(
|
this.set(
|
||||||
|
@ -1200,42 +1203,27 @@ export class MessageModel extends window.Backbone.Model<MessageAttributesType> {
|
||||||
return window.ConversationController.getOrCreate(source, 'private');
|
return window.ConversationController.getOrCreate(source, 'private');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Send infrastructure
|
async retrySend(): Promise<void> {
|
||||||
// One caller today: event handler for the 'Retry Send' entry in triple-dot menu
|
|
||||||
async retrySend(): Promise<string | null | void | Array<void>> {
|
|
||||||
if (!window.textsecure.messaging) {
|
|
||||||
window.log.error('retrySend: Cannot retry since we are offline!');
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const retryOptions = this.get('retryOptions');
|
const retryOptions = this.get('retryOptions');
|
||||||
|
|
||||||
this.set({ errors: undefined, retryOptions: undefined });
|
|
||||||
|
|
||||||
if (retryOptions) {
|
if (retryOptions) {
|
||||||
|
if (!window.textsecure.messaging) {
|
||||||
|
window.log.error('retrySend: Cannot retry since we are offline!');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.unset('errors');
|
||||||
|
this.unset('retryOptions');
|
||||||
return this.sendUtilityMessageWithRetry(retryOptions);
|
return this.sendUtilityMessageWithRetry(retryOptions);
|
||||||
}
|
}
|
||||||
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||||
const conversation = this.getConversation()!;
|
const conversation = this.getConversation()!;
|
||||||
const currentRecipients = new Set<string>(
|
|
||||||
conversation
|
|
||||||
.getRecipients()
|
|
||||||
.map(identifier =>
|
|
||||||
window.ConversationController.getConversationId(identifier)
|
|
||||||
)
|
|
||||||
.filter(isNotNil)
|
|
||||||
);
|
|
||||||
|
|
||||||
const profileKey = conversation.get('profileSharing')
|
const currentConversationRecipients = conversation.getRecipientConversationIds();
|
||||||
? await ourProfileKeyService.get()
|
|
||||||
: undefined;
|
|
||||||
|
|
||||||
// Determine retry recipients and get their most up-to-date addressing information
|
// Determine retry recipients and get their most up-to-date addressing information
|
||||||
const oldSendStateByConversationId =
|
const oldSendStateByConversationId =
|
||||||
this.get('sendStateByConversationId') || {};
|
this.get('sendStateByConversationId') || {};
|
||||||
|
|
||||||
const recipients: Array<string> = [];
|
|
||||||
const newSendStateByConversationId = { ...oldSendStateByConversationId };
|
const newSendStateByConversationId = { ...oldSendStateByConversationId };
|
||||||
for (const [conversationId, sendState] of Object.entries(
|
for (const [conversationId, sendState] of Object.entries(
|
||||||
oldSendStateByConversationId
|
oldSendStateByConversationId
|
||||||
|
@ -1244,15 +1232,12 @@ export class MessageModel extends window.Backbone.Model<MessageAttributesType> {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
const isStillInConversation = currentRecipients.has(conversationId);
|
const recipient = window.ConversationController.get(conversationId);
|
||||||
if (!isStillInConversation) {
|
if (
|
||||||
continue;
|
!recipient ||
|
||||||
}
|
(!currentConversationRecipients.has(conversationId) &&
|
||||||
|
!isMe(recipient.attributes))
|
||||||
const recipient = window.ConversationController.get(
|
) {
|
||||||
conversationId
|
|
||||||
)?.getSendTarget();
|
|
||||||
if (!recipient) {
|
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -1263,133 +1248,15 @@ export class MessageModel extends window.Backbone.Model<MessageAttributesType> {
|
||||||
updatedAt: Date.now(),
|
updatedAt: Date.now(),
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
recipients.push(recipient);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
this.set('sendStateByConversationId', newSendStateByConversationId);
|
this.set('sendStateByConversationId', newSendStateByConversationId);
|
||||||
|
|
||||||
await window.Signal.Data.saveMessage(this.attributes);
|
await normalMessageSendJobQueue.add(
|
||||||
|
{ messageId: this.id, conversationId: conversation.id },
|
||||||
if (!recipients.length) {
|
async jobToInsert => {
|
||||||
window.log.warn('retrySend: Nobody to send to!');
|
await window.Signal.Data.saveMessage(this.attributes, { jobToInsert });
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
const attachmentsWithData = await Promise.all(
|
|
||||||
(this.get('attachments') || []).map(loadAttachmentData)
|
|
||||||
);
|
|
||||||
const {
|
|
||||||
body,
|
|
||||||
attachments,
|
|
||||||
} = window.Whisper.Message.getLongMessageAttachment({
|
|
||||||
body: this.get('body'),
|
|
||||||
attachments: attachmentsWithData,
|
|
||||||
now: this.get('sent_at'),
|
|
||||||
});
|
|
||||||
|
|
||||||
const quoteWithData = await loadQuoteData(this.get('quote'));
|
|
||||||
const previewWithData = await loadPreviewData(this.get('preview'));
|
|
||||||
const stickerWithData = await loadStickerData(this.get('sticker'));
|
|
||||||
const ourNumber = window.textsecure.storage.user.getNumber();
|
|
||||||
|
|
||||||
// Special-case the self-send case - we send only a sync message
|
|
||||||
if (
|
|
||||||
recipients.length === 1 &&
|
|
||||||
(recipients[0] === ourNumber || recipients[0] === this.OUR_UUID)
|
|
||||||
) {
|
|
||||||
const dataMessage = await window.textsecure.messaging.getDataMessage({
|
|
||||||
attachments,
|
|
||||||
body,
|
|
||||||
deletedForEveryoneTimestamp: this.get('deletedForEveryoneTimestamp'),
|
|
||||||
expireTimer: this.get('expireTimer'),
|
|
||||||
// flags
|
|
||||||
mentions: this.get('bodyRanges'),
|
|
||||||
preview: previewWithData,
|
|
||||||
profileKey,
|
|
||||||
quote: quoteWithData,
|
|
||||||
reaction: null,
|
|
||||||
recipients,
|
|
||||||
sticker: stickerWithData,
|
|
||||||
timestamp: this.get('sent_at'),
|
|
||||||
});
|
|
||||||
|
|
||||||
return this.sendSyncMessageOnly(dataMessage);
|
|
||||||
}
|
|
||||||
|
|
||||||
let promise;
|
|
||||||
const options = await getSendOptions(conversation.attributes);
|
|
||||||
|
|
||||||
const { ContentHint } = Proto.UnidentifiedSenderMessage.Message;
|
|
||||||
|
|
||||||
if (isDirectConversation(conversation.attributes)) {
|
|
||||||
const [identifier] = recipients;
|
|
||||||
|
|
||||||
promise = window.textsecure.messaging.sendMessageToIdentifier({
|
|
||||||
identifier,
|
|
||||||
messageText: body,
|
|
||||||
attachments,
|
|
||||||
quote: quoteWithData,
|
|
||||||
preview: previewWithData,
|
|
||||||
sticker: stickerWithData,
|
|
||||||
reaction: null,
|
|
||||||
deletedForEveryoneTimestamp: this.get('deletedForEveryoneTimestamp'),
|
|
||||||
timestamp: this.get('sent_at'),
|
|
||||||
expireTimer: this.get('expireTimer'),
|
|
||||||
contentHint: ContentHint.RESENDABLE,
|
|
||||||
groupId: undefined,
|
|
||||||
profileKey,
|
|
||||||
options,
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
const initialGroupV2 = conversation.getGroupV2Info();
|
|
||||||
const groupId = conversation.get('groupId');
|
|
||||||
if (!groupId) {
|
|
||||||
throw new Error("retrySend: Conversation didn't have groupId");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const groupV2 = initialGroupV2
|
|
||||||
? {
|
|
||||||
...initialGroupV2,
|
|
||||||
members: recipients,
|
|
||||||
}
|
|
||||||
: undefined;
|
|
||||||
const groupV1 = groupV2
|
|
||||||
? undefined
|
|
||||||
: {
|
|
||||||
id: groupId,
|
|
||||||
members: recipients,
|
|
||||||
};
|
|
||||||
|
|
||||||
promise = window.Signal.Util.sendToGroup({
|
|
||||||
groupSendOptions: {
|
|
||||||
messageText: body,
|
|
||||||
timestamp: this.get('sent_at'),
|
|
||||||
attachments,
|
|
||||||
quote: quoteWithData,
|
|
||||||
preview: previewWithData,
|
|
||||||
sticker: stickerWithData,
|
|
||||||
expireTimer: this.get('expireTimer'),
|
|
||||||
mentions: this.get('bodyRanges'),
|
|
||||||
profileKey,
|
|
||||||
groupV2,
|
|
||||||
groupV1,
|
|
||||||
},
|
|
||||||
conversation,
|
|
||||||
contentHint: ContentHint.RESENDABLE,
|
|
||||||
// Important to ensure that we don't consider this recipient list to be the
|
|
||||||
// entire member list.
|
|
||||||
isPartialSend: true,
|
|
||||||
messageId: this.id,
|
|
||||||
sendOptions: options,
|
|
||||||
sendType: 'messageRetry',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return this.send(
|
|
||||||
handleMessageSend(promise, {
|
|
||||||
messageIds: [this.id],
|
|
||||||
sendType: 'messageRetry',
|
|
||||||
})
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -1414,118 +1281,20 @@ export class MessageModel extends window.Backbone.Model<MessageAttributesType> {
|
||||||
return isEmpty(withoutMe) || someSendStatus(withoutMe, isSent);
|
return isEmpty(withoutMe) || someSendStatus(withoutMe, isSent);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Called when the user ran into an error with a specific user, wants to send to them
|
/**
|
||||||
// One caller today: ConversationView.forceSend()
|
* Change any Pending send state to Failed. Note that this will not mark successful
|
||||||
async resend(identifier: string): Promise<void | null | Array<void>> {
|
* sends failed.
|
||||||
const error = this.removeOutgoingErrors(identifier);
|
*/
|
||||||
if (!error) {
|
public markFailed(): void {
|
||||||
window.log.warn(
|
const now = Date.now();
|
||||||
'resend: requested number was not present in errors. continuing.'
|
this.set(
|
||||||
);
|
'sendStateByConversationId',
|
||||||
}
|
mapValues(this.get('sendStateByConversationId') || {}, sendState =>
|
||||||
|
sendStateReducer(sendState, {
|
||||||
if (this.isErased()) {
|
type: SendActionType.Failed,
|
||||||
window.log.warn('resend: message is erased; refusing to resend');
|
updatedAt: now,
|
||||||
return null;
|
})
|
||||||
}
|
)
|
||||||
|
|
||||||
const profileKey = undefined;
|
|
||||||
const attachmentsWithData = await Promise.all(
|
|
||||||
(this.get('attachments') || []).map(loadAttachmentData)
|
|
||||||
);
|
|
||||||
const {
|
|
||||||
body,
|
|
||||||
attachments,
|
|
||||||
} = window.Whisper.Message.getLongMessageAttachment({
|
|
||||||
body: this.get('body'),
|
|
||||||
attachments: attachmentsWithData,
|
|
||||||
now: this.get('sent_at'),
|
|
||||||
});
|
|
||||||
|
|
||||||
const quoteWithData = await loadQuoteData(this.get('quote'));
|
|
||||||
const previewWithData = await loadPreviewData(this.get('preview'));
|
|
||||||
const stickerWithData = await loadStickerData(this.get('sticker'));
|
|
||||||
const ourNumber = window.textsecure.storage.user.getNumber();
|
|
||||||
|
|
||||||
// Special-case the self-send case - we send only a sync message
|
|
||||||
if (identifier === ourNumber || identifier === this.OUR_UUID) {
|
|
||||||
const dataMessage = await window.textsecure.messaging.getDataMessage({
|
|
||||||
attachments,
|
|
||||||
body,
|
|
||||||
deletedForEveryoneTimestamp: this.get('deletedForEveryoneTimestamp'),
|
|
||||||
expireTimer: this.get('expireTimer'),
|
|
||||||
mentions: this.get('bodyRanges'),
|
|
||||||
preview: previewWithData,
|
|
||||||
profileKey,
|
|
||||||
quote: quoteWithData,
|
|
||||||
reaction: null,
|
|
||||||
recipients: [identifier],
|
|
||||||
sticker: stickerWithData,
|
|
||||||
timestamp: this.get('sent_at'),
|
|
||||||
});
|
|
||||||
|
|
||||||
return this.sendSyncMessageOnly(dataMessage);
|
|
||||||
}
|
|
||||||
|
|
||||||
const { ContentHint } = Proto.UnidentifiedSenderMessage.Message;
|
|
||||||
const parentConversation = this.getConversation();
|
|
||||||
const groupId = parentConversation?.get('groupId');
|
|
||||||
|
|
||||||
const recipientConversation = window.ConversationController.get(identifier);
|
|
||||||
const sendOptions = recipientConversation
|
|
||||||
? await getSendOptions(recipientConversation.attributes)
|
|
||||||
: undefined;
|
|
||||||
const group =
|
|
||||||
groupId && isGroupV1(parentConversation?.attributes)
|
|
||||||
? {
|
|
||||||
id: groupId,
|
|
||||||
type: Proto.GroupContext.Type.DELIVER,
|
|
||||||
}
|
|
||||||
: undefined;
|
|
||||||
|
|
||||||
const timestamp = this.get('sent_at');
|
|
||||||
const contentMessage = await window.textsecure.messaging.getContentMessage({
|
|
||||||
attachments,
|
|
||||||
body,
|
|
||||||
expireTimer: this.get('expireTimer'),
|
|
||||||
group,
|
|
||||||
groupV2: parentConversation?.getGroupV2Info(),
|
|
||||||
preview: previewWithData,
|
|
||||||
quote: quoteWithData,
|
|
||||||
mentions: this.get('bodyRanges'),
|
|
||||||
recipients: [identifier],
|
|
||||||
sticker: stickerWithData,
|
|
||||||
timestamp,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (parentConversation) {
|
|
||||||
const senderKeyInfo = parentConversation.get('senderKeyInfo');
|
|
||||||
if (senderKeyInfo && senderKeyInfo.distributionId) {
|
|
||||||
const senderKeyDistributionMessage = await window.textsecure.messaging.getSenderKeyDistributionMessage(
|
|
||||||
senderKeyInfo.distributionId
|
|
||||||
);
|
|
||||||
|
|
||||||
contentMessage.senderKeyDistributionMessage = senderKeyDistributionMessage.serialize();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const promise = window.textsecure.messaging.sendMessageProtoAndWait({
|
|
||||||
timestamp,
|
|
||||||
recipients: [identifier],
|
|
||||||
proto: contentMessage,
|
|
||||||
contentHint: ContentHint.RESENDABLE,
|
|
||||||
groupId:
|
|
||||||
groupId && isGroupV2(parentConversation?.attributes)
|
|
||||||
? groupId
|
|
||||||
: undefined,
|
|
||||||
options: sendOptions,
|
|
||||||
});
|
|
||||||
|
|
||||||
return this.send(
|
|
||||||
handleMessageSend(promise, {
|
|
||||||
messageIds: [this.id],
|
|
||||||
sendType: 'messageRetry',
|
|
||||||
})
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -1552,7 +1321,8 @@ export class MessageModel extends window.Backbone.Model<MessageAttributesType> {
|
||||||
}
|
}
|
||||||
|
|
||||||
async send(
|
async send(
|
||||||
promise: Promise<CallbackResultType | void | null>
|
promise: Promise<CallbackResultType | void | null>,
|
||||||
|
saveErrors?: (errors: Array<Error>) => void
|
||||||
): Promise<void | Array<void>> {
|
): Promise<void | Array<void>> {
|
||||||
const updateLeftPane =
|
const updateLeftPane =
|
||||||
this.getConversation()?.debouncedUpdateLastMessage || noop;
|
this.getConversation()?.debouncedUpdateLastMessage || noop;
|
||||||
|
@ -1655,7 +1425,7 @@ export class MessageModel extends window.Backbone.Model<MessageAttributesType> {
|
||||||
window.ConversationController.get(error.identifier) ||
|
window.ConversationController.get(error.identifier) ||
|
||||||
window.ConversationController.get(error.number);
|
window.ConversationController.get(error.number);
|
||||||
|
|
||||||
if (conversation) {
|
if (conversation && !saveErrors) {
|
||||||
const previousSendState = getOwn(
|
const previousSendState = getOwn(
|
||||||
sendStateByConversationId,
|
sendStateByConversationId,
|
||||||
conversation.id
|
conversation.id
|
||||||
|
@ -1719,8 +1489,12 @@ export class MessageModel extends window.Backbone.Model<MessageAttributesType> {
|
||||||
attributesToUpdate.errors = [];
|
attributesToUpdate.errors = [];
|
||||||
|
|
||||||
this.set(attributesToUpdate);
|
this.set(attributesToUpdate);
|
||||||
// We skip save because we'll save in the next step.
|
if (saveErrors) {
|
||||||
this.saveErrors(errorsToSave, { skipSave: true });
|
saveErrors(errorsToSave);
|
||||||
|
} else {
|
||||||
|
// We skip save because we'll save in the next step.
|
||||||
|
this.saveErrors(errorsToSave, { skipSave: true });
|
||||||
|
}
|
||||||
|
|
||||||
if (!this.doNotSave) {
|
if (!this.doNotSave) {
|
||||||
await window.Signal.Data.saveMessage(this.attributes);
|
await window.Signal.Data.saveMessage(this.attributes);
|
||||||
|
@ -1734,6 +1508,14 @@ export class MessageModel extends window.Backbone.Model<MessageAttributesType> {
|
||||||
|
|
||||||
await Promise.all(promises);
|
await Promise.all(promises);
|
||||||
|
|
||||||
|
const isTotalSuccess: boolean =
|
||||||
|
result.success && !this.get('errors')?.length;
|
||||||
|
if (isTotalSuccess) {
|
||||||
|
delete this.cachedOutgoingPreviewData;
|
||||||
|
delete this.cachedOutgoingQuoteData;
|
||||||
|
delete this.cachedOutgoingStickerData;
|
||||||
|
}
|
||||||
|
|
||||||
updateLeftPane();
|
updateLeftPane();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -1779,7 +1561,10 @@ export class MessageModel extends window.Backbone.Model<MessageAttributesType> {
|
||||||
throw new Error(`Unsupported retriable type: ${options.type}`);
|
throw new Error(`Unsupported retriable type: ${options.type}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
async sendSyncMessageOnly(dataMessage: ArrayBuffer): Promise<void> {
|
async sendSyncMessageOnly(
|
||||||
|
dataMessage: ArrayBuffer,
|
||||||
|
saveErrors?: (errors: Array<Error>) => void
|
||||||
|
): Promise<void> {
|
||||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||||
const conv = this.getConversation()!;
|
const conv = this.getConversation()!;
|
||||||
this.set({ dataMessage });
|
this.set({ dataMessage });
|
||||||
|
@ -1800,9 +1585,16 @@ export class MessageModel extends window.Backbone.Model<MessageAttributesType> {
|
||||||
: undefined,
|
: undefined,
|
||||||
});
|
});
|
||||||
} catch (result) {
|
} catch (result) {
|
||||||
const errors = (result && result.errors) || [new Error('Unknown error')];
|
const resultErrors = result?.errors;
|
||||||
// We don't save because we're about to save below.
|
const errors = Array.isArray(resultErrors)
|
||||||
this.saveErrors(errors, { skipSave: true });
|
? resultErrors
|
||||||
|
: [new Error('Unknown error')];
|
||||||
|
if (saveErrors) {
|
||||||
|
saveErrors(errors);
|
||||||
|
} else {
|
||||||
|
// We don't save because we're about to save below.
|
||||||
|
this.saveErrors(errors, { skipSave: true });
|
||||||
|
}
|
||||||
} finally {
|
} finally {
|
||||||
await window.Signal.Data.saveMessage(this.attributes);
|
await window.Signal.Data.saveMessage(this.attributes);
|
||||||
|
|
||||||
|
|
|
@ -40,6 +40,7 @@ import {
|
||||||
MessageModelCollectionType,
|
MessageModelCollectionType,
|
||||||
} from '../model-types.d';
|
} from '../model-types.d';
|
||||||
import { StoredJob } from '../jobs/types';
|
import { StoredJob } from '../jobs/types';
|
||||||
|
import { formatJobForInsert } from '../jobs/formatJobForInsert';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
AttachmentDownloadJobType,
|
AttachmentDownloadJobType,
|
||||||
|
@ -206,6 +207,7 @@ const dataInterface: ClientInterface = {
|
||||||
|
|
||||||
getMessageBySender,
|
getMessageBySender,
|
||||||
getMessageById,
|
getMessageById,
|
||||||
|
getMessagesById,
|
||||||
getAllMessageIds,
|
getAllMessageIds,
|
||||||
getMessagesBySentAt,
|
getMessagesBySentAt,
|
||||||
getExpiredMessages,
|
getExpiredMessages,
|
||||||
|
@ -1070,9 +1072,12 @@ async function getMessageCount(conversationId?: string) {
|
||||||
|
|
||||||
async function saveMessage(
|
async function saveMessage(
|
||||||
data: MessageType,
|
data: MessageType,
|
||||||
options?: { forceSave?: boolean }
|
options: { jobToInsert?: Readonly<StoredJob>; forceSave?: boolean } = {}
|
||||||
) {
|
) {
|
||||||
const id = await channels.saveMessage(_cleanMessageData(data), options);
|
const id = await channels.saveMessage(_cleanMessageData(data), {
|
||||||
|
...options,
|
||||||
|
jobToInsert: options.jobToInsert && formatJobForInsert(options.jobToInsert),
|
||||||
|
});
|
||||||
|
|
||||||
window.Whisper.ExpiringMessagesListener.update();
|
window.Whisper.ExpiringMessagesListener.update();
|
||||||
window.Whisper.TapToViewMessagesListener.update();
|
window.Whisper.TapToViewMessagesListener.update();
|
||||||
|
@ -1124,6 +1129,13 @@ async function getMessageById(
|
||||||
return new Message(message);
|
return new Message(message);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function getMessagesById(messageIds: Array<string>) {
|
||||||
|
if (!messageIds.length) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
return channels.getMessagesById(messageIds);
|
||||||
|
}
|
||||||
|
|
||||||
// For testing only
|
// For testing only
|
||||||
async function _getAllMessages({
|
async function _getAllMessages({
|
||||||
MessageCollection,
|
MessageCollection,
|
||||||
|
|
|
@ -307,9 +307,13 @@ export type DataInterface = {
|
||||||
options?: { limit?: number }
|
options?: { limit?: number }
|
||||||
) => Promise<Array<ConversationType>>;
|
) => Promise<Array<ConversationType>>;
|
||||||
|
|
||||||
|
getMessagesById: (messageIds: Array<string>) => Promise<Array<MessageType>>;
|
||||||
saveMessage: (
|
saveMessage: (
|
||||||
data: MessageType,
|
data: MessageType,
|
||||||
options?: { forceSave?: boolean }
|
options?: {
|
||||||
|
jobToInsert?: StoredJob;
|
||||||
|
forceSave?: boolean;
|
||||||
|
}
|
||||||
) => Promise<string>;
|
) => Promise<string>;
|
||||||
saveMessages: (
|
saveMessages: (
|
||||||
arrayOfMessages: Array<MessageType>,
|
arrayOfMessages: Array<MessageType>,
|
||||||
|
|
|
@ -196,6 +196,7 @@ const dataInterface: ServerInterface = {
|
||||||
removeReactionFromConversation,
|
removeReactionFromConversation,
|
||||||
getMessageBySender,
|
getMessageBySender,
|
||||||
getMessageById,
|
getMessageById,
|
||||||
|
getMessagesById,
|
||||||
_getAllMessages,
|
_getAllMessages,
|
||||||
getAllMessageIds,
|
getAllMessageIds,
|
||||||
getMessagesBySentAt,
|
getMessagesBySentAt,
|
||||||
|
@ -2363,22 +2364,37 @@ function getInstance(): Database {
|
||||||
return globalInstance;
|
return globalInstance;
|
||||||
}
|
}
|
||||||
|
|
||||||
function batchMultiVarQuery<T>(
|
function batchMultiVarQuery<ValueT>(
|
||||||
values: Array<T>,
|
values: Array<ValueT>,
|
||||||
query: (batch: Array<T>) => void
|
query: (batch: Array<ValueT>) => void
|
||||||
): void {
|
): [];
|
||||||
|
function batchMultiVarQuery<ValueT, ResultT>(
|
||||||
|
values: Array<ValueT>,
|
||||||
|
query: (batch: Array<ValueT>) => Array<ResultT>
|
||||||
|
): Array<ResultT>;
|
||||||
|
function batchMultiVarQuery<ValueT, ResultT>(
|
||||||
|
values: Array<ValueT>,
|
||||||
|
query:
|
||||||
|
| ((batch: Array<ValueT>) => void)
|
||||||
|
| ((batch: Array<ValueT>) => Array<ResultT>)
|
||||||
|
): Array<ResultT> {
|
||||||
const db = getInstance();
|
const db = getInstance();
|
||||||
if (values.length > MAX_VARIABLE_COUNT) {
|
if (values.length > MAX_VARIABLE_COUNT) {
|
||||||
|
const result: Array<ResultT> = [];
|
||||||
db.transaction(() => {
|
db.transaction(() => {
|
||||||
for (let i = 0; i < values.length; i += MAX_VARIABLE_COUNT) {
|
for (let i = 0; i < values.length; i += MAX_VARIABLE_COUNT) {
|
||||||
const batch = values.slice(i, i + MAX_VARIABLE_COUNT);
|
const batch = values.slice(i, i + MAX_VARIABLE_COUNT);
|
||||||
query(batch);
|
const batchResult = query(batch);
|
||||||
|
if (Array.isArray(batchResult)) {
|
||||||
|
result.push(...batchResult);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
})();
|
})();
|
||||||
return;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
query(values);
|
const result = query(values);
|
||||||
|
return Array.isArray(result) ? result : [];
|
||||||
}
|
}
|
||||||
|
|
||||||
const IDENTITY_KEYS_TABLE = 'identityKeys';
|
const IDENTITY_KEYS_TABLE = 'identityKeys';
|
||||||
|
@ -3577,11 +3593,15 @@ function hasUserInitiatedMessages(conversationId: string): boolean {
|
||||||
|
|
||||||
function saveMessageSync(
|
function saveMessageSync(
|
||||||
data: MessageType,
|
data: MessageType,
|
||||||
options?: { forceSave?: boolean; alreadyInTransaction?: boolean }
|
options?: {
|
||||||
|
jobToInsert?: StoredJob;
|
||||||
|
forceSave?: boolean;
|
||||||
|
alreadyInTransaction?: boolean;
|
||||||
|
}
|
||||||
): string {
|
): string {
|
||||||
const db = getInstance();
|
const db = getInstance();
|
||||||
|
|
||||||
const { forceSave, alreadyInTransaction } = options || {};
|
const { jobToInsert, forceSave, alreadyInTransaction } = options || {};
|
||||||
|
|
||||||
if (!alreadyInTransaction) {
|
if (!alreadyInTransaction) {
|
||||||
return db.transaction(() => {
|
return db.transaction(() => {
|
||||||
|
@ -3670,6 +3690,10 @@ function saveMessageSync(
|
||||||
`
|
`
|
||||||
).run(payload);
|
).run(payload);
|
||||||
|
|
||||||
|
if (jobToInsert) {
|
||||||
|
insertJobSync(db, jobToInsert);
|
||||||
|
}
|
||||||
|
|
||||||
return id;
|
return id;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -3733,12 +3757,20 @@ function saveMessageSync(
|
||||||
json: objectToJSON(toCreate),
|
json: objectToJSON(toCreate),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (jobToInsert) {
|
||||||
|
insertJobSync(db, jobToInsert);
|
||||||
|
}
|
||||||
|
|
||||||
return toCreate.id;
|
return toCreate.id;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function saveMessage(
|
async function saveMessage(
|
||||||
data: MessageType,
|
data: MessageType,
|
||||||
options?: { forceSave?: boolean; alreadyInTransaction?: boolean }
|
options?: {
|
||||||
|
jobToInsert?: StoredJob;
|
||||||
|
forceSave?: boolean;
|
||||||
|
alreadyInTransaction?: boolean;
|
||||||
|
}
|
||||||
): Promise<string> {
|
): Promise<string> {
|
||||||
return saveMessageSync(data, options);
|
return saveMessageSync(data, options);
|
||||||
}
|
}
|
||||||
|
@ -3795,6 +3827,25 @@ async function getMessageById(id: string): Promise<MessageType | undefined> {
|
||||||
return jsonToObject(row.json);
|
return jsonToObject(row.json);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function getMessagesById(
|
||||||
|
messageIds: Array<string>
|
||||||
|
): Promise<Array<MessageType>> {
|
||||||
|
const db = getInstance();
|
||||||
|
|
||||||
|
return batchMultiVarQuery(
|
||||||
|
messageIds,
|
||||||
|
(batch: Array<string>): Array<MessageType> => {
|
||||||
|
const query = db.prepare<ArrayQuery>(
|
||||||
|
`SELECT json FROM messages WHERE id IN (${Array(batch.length)
|
||||||
|
.fill('?')
|
||||||
|
.join(',')});`
|
||||||
|
);
|
||||||
|
const rows: JSONRows = query.all(batch);
|
||||||
|
return rows.map(row => jsonToObject(row.json));
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
async function _getAllMessages(): Promise<Array<MessageType>> {
|
async function _getAllMessages(): Promise<Array<MessageType>> {
|
||||||
const db = getInstance();
|
const db = getInstance();
|
||||||
const rows: JSONRows = db
|
const rows: JSONRows = db
|
||||||
|
@ -5902,9 +5953,7 @@ async function getJobsInQueue(queueType: string): Promise<Array<StoredJob>> {
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
async function insertJob(job: Readonly<StoredJob>): Promise<void> {
|
function insertJobSync(db: Database, job: Readonly<StoredJob>): void {
|
||||||
const db = getInstance();
|
|
||||||
|
|
||||||
db.prepare<Query>(
|
db.prepare<Query>(
|
||||||
`
|
`
|
||||||
INSERT INTO jobs
|
INSERT INTO jobs
|
||||||
|
@ -5920,6 +5969,11 @@ async function insertJob(job: Readonly<StoredJob>): Promise<void> {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function insertJob(job: Readonly<StoredJob>): Promise<void> {
|
||||||
|
const db = getInstance();
|
||||||
|
return insertJobSync(db, job);
|
||||||
|
}
|
||||||
|
|
||||||
async function deleteJob(id: string): Promise<void> {
|
async function deleteJob(id: string): Promise<void> {
|
||||||
const db = getInstance();
|
const db = getInstance();
|
||||||
|
|
||||||
|
|
|
@ -45,12 +45,16 @@ import {
|
||||||
getGroupSizeRecommendedLimit,
|
getGroupSizeRecommendedLimit,
|
||||||
getGroupSizeHardLimit,
|
getGroupSizeHardLimit,
|
||||||
} from '../../groups/limits';
|
} from '../../groups/limits';
|
||||||
|
import { getMessagesById } from '../../messages/getMessagesById';
|
||||||
import { isMessageUnread } from '../../util/isMessageUnread';
|
import { isMessageUnread } from '../../util/isMessageUnread';
|
||||||
import { toggleSelectedContactForGroupAddition } from '../../groups/toggleSelectedContactForGroupAddition';
|
import { toggleSelectedContactForGroupAddition } from '../../groups/toggleSelectedContactForGroupAddition';
|
||||||
import { GroupNameCollisionsWithIdsByTitle } from '../../util/groupMemberNameCollisions';
|
import { GroupNameCollisionsWithIdsByTitle } from '../../util/groupMemberNameCollisions';
|
||||||
import { ContactSpoofingType } from '../../util/contactSpoofing';
|
import { ContactSpoofingType } from '../../util/contactSpoofing';
|
||||||
import { writeProfile } from '../../services/writeProfile';
|
import { writeProfile } from '../../services/writeProfile';
|
||||||
import { getMe } from '../selectors/conversations';
|
import {
|
||||||
|
getMe,
|
||||||
|
getMessageIdsPendingBecauseOfVerification,
|
||||||
|
} from '../selectors/conversations';
|
||||||
import { AvatarDataType, getDefaultAvatars } from '../../types/Avatar';
|
import { AvatarDataType, getDefaultAvatars } from '../../types/Avatar';
|
||||||
import { getAvatarData } from '../../util/getAvatarData';
|
import { getAvatarData } from '../../util/getAvatarData';
|
||||||
import { isSameAvatarData } from '../../util/isSameAvatarData';
|
import { isSameAvatarData } from '../../util/isSameAvatarData';
|
||||||
|
@ -302,6 +306,15 @@ export type ConversationsStateType = {
|
||||||
composer?: ComposerStateType;
|
composer?: ComposerStateType;
|
||||||
contactSpoofingReview?: ContactSpoofingReviewStateType;
|
contactSpoofingReview?: ContactSpoofingReviewStateType;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Each key is a conversation ID. Each value is an array of message IDs stopped by that
|
||||||
|
* conversation being unverified.
|
||||||
|
*/
|
||||||
|
outboundMessagesPendingConversationVerification: Record<
|
||||||
|
string,
|
||||||
|
Array<string>
|
||||||
|
>;
|
||||||
|
|
||||||
// Note: it's very important that both of these locations are always kept up to date
|
// Note: it's very important that both of these locations are always kept up to date
|
||||||
messagesLookup: MessageLookupType;
|
messagesLookup: MessageLookupType;
|
||||||
messagesByConversation: MessagesByConversationType;
|
messagesByConversation: MessagesByConversationType;
|
||||||
|
@ -336,14 +349,21 @@ export const getConversationCallMode = (
|
||||||
|
|
||||||
export const COLORS_CHANGED = 'conversations/COLORS_CHANGED';
|
export const COLORS_CHANGED = 'conversations/COLORS_CHANGED';
|
||||||
export const COLOR_SELECTED = 'conversations/COLOR_SELECTED';
|
export const COLOR_SELECTED = 'conversations/COLOR_SELECTED';
|
||||||
|
const CANCEL_MESSAGES_PENDING_CONVERSATION_VERIFICATION =
|
||||||
|
'conversations/CANCEL_MESSAGES_PENDING_CONVERSATION_VERIFICATION';
|
||||||
const COMPOSE_TOGGLE_EDITING_AVATAR =
|
const COMPOSE_TOGGLE_EDITING_AVATAR =
|
||||||
'conversations/compose/COMPOSE_TOGGLE_EDITING_AVATAR';
|
'conversations/compose/COMPOSE_TOGGLE_EDITING_AVATAR';
|
||||||
const COMPOSE_ADD_AVATAR = 'conversations/compose/ADD_AVATAR';
|
const COMPOSE_ADD_AVATAR = 'conversations/compose/ADD_AVATAR';
|
||||||
const COMPOSE_REMOVE_AVATAR = 'conversations/compose/REMOVE_AVATAR';
|
const COMPOSE_REMOVE_AVATAR = 'conversations/compose/REMOVE_AVATAR';
|
||||||
const COMPOSE_REPLACE_AVATAR = 'conversations/compose/REPLACE_AVATAR';
|
const COMPOSE_REPLACE_AVATAR = 'conversations/compose/REPLACE_AVATAR';
|
||||||
const CUSTOM_COLOR_REMOVED = 'conversations/CUSTOM_COLOR_REMOVED';
|
const CUSTOM_COLOR_REMOVED = 'conversations/CUSTOM_COLOR_REMOVED';
|
||||||
|
const MESSAGE_STOPPED_BY_MISSING_VERIFICATION =
|
||||||
|
'conversations/MESSAGE_STOPPED_BY_MISSING_VERIFICATION';
|
||||||
const REPLACE_AVATARS = 'conversations/REPLACE_AVATARS';
|
const REPLACE_AVATARS = 'conversations/REPLACE_AVATARS';
|
||||||
|
|
||||||
|
type CancelMessagesPendingConversationVerificationActionType = {
|
||||||
|
type: typeof CANCEL_MESSAGES_PENDING_CONVERSATION_VERIFICATION;
|
||||||
|
};
|
||||||
type CantAddContactToGroupActionType = {
|
type CantAddContactToGroupActionType = {
|
||||||
type: 'CANT_ADD_CONTACT_TO_GROUP';
|
type: 'CANT_ADD_CONTACT_TO_GROUP';
|
||||||
payload: {
|
payload: {
|
||||||
|
@ -465,6 +485,13 @@ export type MessageSelectedActionType = {
|
||||||
conversationId: string;
|
conversationId: string;
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
type MessageStoppedByMissingVerificationActionType = {
|
||||||
|
type: typeof MESSAGE_STOPPED_BY_MISSING_VERIFICATION;
|
||||||
|
payload: {
|
||||||
|
messageId: string;
|
||||||
|
untrustedConversationIds: ReadonlyArray<string>;
|
||||||
|
};
|
||||||
|
};
|
||||||
export type MessageChangedActionType = {
|
export type MessageChangedActionType = {
|
||||||
type: 'MESSAGE_CHANGED';
|
type: 'MESSAGE_CHANGED';
|
||||||
payload: {
|
payload: {
|
||||||
|
@ -656,6 +683,7 @@ type ReplaceAvatarsActionType = {
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
export type ConversationActionType =
|
export type ConversationActionType =
|
||||||
|
| CancelMessagesPendingConversationVerificationActionType
|
||||||
| CantAddContactToGroupActionType
|
| CantAddContactToGroupActionType
|
||||||
| ClearChangedMessagesActionType
|
| ClearChangedMessagesActionType
|
||||||
| ClearGroupCreationErrorActionType
|
| ClearGroupCreationErrorActionType
|
||||||
|
@ -679,6 +707,7 @@ export type ConversationActionType =
|
||||||
| CreateGroupPendingActionType
|
| CreateGroupPendingActionType
|
||||||
| CreateGroupRejectedActionType
|
| CreateGroupRejectedActionType
|
||||||
| CustomColorRemovedActionType
|
| CustomColorRemovedActionType
|
||||||
|
| MessageStoppedByMissingVerificationActionType
|
||||||
| MessageChangedActionType
|
| MessageChangedActionType
|
||||||
| MessageDeletedActionType
|
| MessageDeletedActionType
|
||||||
| MessageSelectedActionType
|
| MessageSelectedActionType
|
||||||
|
@ -716,6 +745,7 @@ export type ConversationActionType =
|
||||||
// Action Creators
|
// Action Creators
|
||||||
|
|
||||||
export const actions = {
|
export const actions = {
|
||||||
|
cancelMessagesPendingConversationVerification,
|
||||||
cantAddContactToGroup,
|
cantAddContactToGroup,
|
||||||
clearChangedMessages,
|
clearChangedMessages,
|
||||||
clearGroupCreationError,
|
clearGroupCreationError,
|
||||||
|
@ -737,6 +767,7 @@ export const actions = {
|
||||||
createGroup,
|
createGroup,
|
||||||
deleteAvatarFromDisk,
|
deleteAvatarFromDisk,
|
||||||
doubleCheckMissingQuoteReference,
|
doubleCheckMissingQuoteReference,
|
||||||
|
messageStoppedByMissingVerification,
|
||||||
messageChanged,
|
messageChanged,
|
||||||
messageDeleted,
|
messageDeleted,
|
||||||
messageSizeChanged,
|
messageSizeChanged,
|
||||||
|
@ -775,6 +806,7 @@ export const actions = {
|
||||||
startSettingGroupMetadata,
|
startSettingGroupMetadata,
|
||||||
toggleConversationInChooseMembers,
|
toggleConversationInChooseMembers,
|
||||||
toggleComposeEditingAvatar,
|
toggleComposeEditingAvatar,
|
||||||
|
verifyConversationsStoppingMessageSend,
|
||||||
};
|
};
|
||||||
|
|
||||||
function filterAvatarData(
|
function filterAvatarData(
|
||||||
|
@ -1074,6 +1106,26 @@ function toggleComposeEditingAvatar(): ToggleComposeEditingAvatarActionType {
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function verifyConversationsStoppingMessageSend(): ThunkAction<
|
||||||
|
void,
|
||||||
|
RootStateType,
|
||||||
|
unknown,
|
||||||
|
never
|
||||||
|
> {
|
||||||
|
return async (_dispatch, getState) => {
|
||||||
|
const conversationIds = Object.keys(
|
||||||
|
getState().conversations.outboundMessagesPendingConversationVerification
|
||||||
|
);
|
||||||
|
|
||||||
|
await Promise.all(
|
||||||
|
conversationIds.map(async conversationId => {
|
||||||
|
const conversation = window.ConversationController.get(conversationId);
|
||||||
|
await conversation?.setVerifiedDefault();
|
||||||
|
})
|
||||||
|
);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
function composeSaveAvatarToDisk(
|
function composeSaveAvatarToDisk(
|
||||||
avatarData: AvatarDataType
|
avatarData: AvatarDataType
|
||||||
): ThunkAction<void, RootStateType, unknown, ComposeSaveAvatarActionType> {
|
): ThunkAction<void, RootStateType, unknown, ComposeSaveAvatarActionType> {
|
||||||
|
@ -1128,6 +1180,31 @@ function composeReplaceAvatar(
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function cancelMessagesPendingConversationVerification(): ThunkAction<
|
||||||
|
void,
|
||||||
|
RootStateType,
|
||||||
|
unknown,
|
||||||
|
CancelMessagesPendingConversationVerificationActionType
|
||||||
|
> {
|
||||||
|
return async (dispatch, getState) => {
|
||||||
|
const messageIdsPending = getMessageIdsPendingBecauseOfVerification(
|
||||||
|
getState()
|
||||||
|
);
|
||||||
|
const messagesStopped = await getMessagesById([...messageIdsPending]);
|
||||||
|
messagesStopped.forEach(message => {
|
||||||
|
message.markFailed();
|
||||||
|
});
|
||||||
|
|
||||||
|
dispatch({
|
||||||
|
type: CANCEL_MESSAGES_PENDING_CONVERSATION_VERIFICATION,
|
||||||
|
});
|
||||||
|
|
||||||
|
await window.Signal.Data.saveMessages(
|
||||||
|
messagesStopped.map(message => message.attributes)
|
||||||
|
);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
function cantAddContactToGroup(
|
function cantAddContactToGroup(
|
||||||
conversationId: string
|
conversationId: string
|
||||||
): CantAddContactToGroupActionType {
|
): CantAddContactToGroupActionType {
|
||||||
|
@ -1162,9 +1239,22 @@ function conversationChanged(
|
||||||
id: string,
|
id: string,
|
||||||
data: ConversationType
|
data: ConversationType
|
||||||
): ThunkAction<void, RootStateType, unknown, ConversationChangedActionType> {
|
): ThunkAction<void, RootStateType, unknown, ConversationChangedActionType> {
|
||||||
return dispatch => {
|
return async (dispatch, getState) => {
|
||||||
calling.groupMembersChanged(id);
|
calling.groupMembersChanged(id);
|
||||||
|
|
||||||
|
if (!data.isUntrusted) {
|
||||||
|
const messageIdsPending =
|
||||||
|
getOwn(
|
||||||
|
getState().conversations
|
||||||
|
.outboundMessagesPendingConversationVerification,
|
||||||
|
id
|
||||||
|
) ?? [];
|
||||||
|
const messagesPending = await getMessagesById(messageIdsPending);
|
||||||
|
messagesPending.forEach(message => {
|
||||||
|
message.retrySend();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
dispatch({
|
dispatch({
|
||||||
type: 'CONVERSATION_CHANGED',
|
type: 'CONVERSATION_CHANGED',
|
||||||
payload: {
|
payload: {
|
||||||
|
@ -1264,6 +1354,19 @@ function selectMessage(
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function messageStoppedByMissingVerification(
|
||||||
|
messageId: string,
|
||||||
|
untrustedConversationIds: ReadonlyArray<string>
|
||||||
|
): MessageStoppedByMissingVerificationActionType {
|
||||||
|
return {
|
||||||
|
type: MESSAGE_STOPPED_BY_MISSING_VERIFICATION,
|
||||||
|
payload: {
|
||||||
|
messageId,
|
||||||
|
untrustedConversationIds,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
function messageChanged(
|
function messageChanged(
|
||||||
id: string,
|
id: string,
|
||||||
conversationId: string,
|
conversationId: string,
|
||||||
|
@ -1651,6 +1754,7 @@ export function getEmptyState(): ConversationsStateType {
|
||||||
conversationsByE164: {},
|
conversationsByE164: {},
|
||||||
conversationsByUuid: {},
|
conversationsByUuid: {},
|
||||||
conversationsByGroupId: {},
|
conversationsByGroupId: {},
|
||||||
|
outboundMessagesPendingConversationVerification: {},
|
||||||
messagesByConversation: {},
|
messagesByConversation: {},
|
||||||
messagesLookup: {},
|
messagesLookup: {},
|
||||||
selectedMessageCounter: 0,
|
selectedMessageCounter: 0,
|
||||||
|
@ -1799,6 +1903,13 @@ export function reducer(
|
||||||
state: Readonly<ConversationsStateType> = getEmptyState(),
|
state: Readonly<ConversationsStateType> = getEmptyState(),
|
||||||
action: Readonly<ConversationActionType>
|
action: Readonly<ConversationActionType>
|
||||||
): ConversationsStateType {
|
): ConversationsStateType {
|
||||||
|
if (action.type === CANCEL_MESSAGES_PENDING_CONVERSATION_VERIFICATION) {
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
outboundMessagesPendingConversationVerification: {},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
if (action.type === 'CANT_ADD_CONTACT_TO_GROUP') {
|
if (action.type === 'CANT_ADD_CONTACT_TO_GROUP') {
|
||||||
const { composer } = state;
|
const { composer } = state;
|
||||||
if (composer?.step !== ComposerStep.ChooseGroupMembers) {
|
if (composer?.step !== ComposerStep.ChooseGroupMembers) {
|
||||||
|
@ -1887,6 +1998,9 @@ export function reducer(
|
||||||
[id]: data,
|
[id]: data,
|
||||||
},
|
},
|
||||||
...updateConversationLookups(data, undefined, state),
|
...updateConversationLookups(data, undefined, state),
|
||||||
|
outboundMessagesPendingConversationVerification: data.isUntrusted
|
||||||
|
? state.outboundMessagesPendingConversationVerification
|
||||||
|
: omit(state.outboundMessagesPendingConversationVerification, id),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
if (action.type === 'CONVERSATION_CHANGED') {
|
if (action.type === 'CONVERSATION_CHANGED') {
|
||||||
|
@ -1933,6 +2047,9 @@ export function reducer(
|
||||||
[id]: data,
|
[id]: data,
|
||||||
},
|
},
|
||||||
...updateConversationLookups(data, existing, state),
|
...updateConversationLookups(data, existing, state),
|
||||||
|
outboundMessagesPendingConversationVerification: data.isUntrusted
|
||||||
|
? state.outboundMessagesPendingConversationVerification
|
||||||
|
: omit(state.outboundMessagesPendingConversationVerification, id),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
if (action.type === 'CONVERSATION_REMOVED') {
|
if (action.type === 'CONVERSATION_REMOVED') {
|
||||||
|
@ -2037,6 +2154,31 @@ export function reducer(
|
||||||
selectedMessageCounter: state.selectedMessageCounter + 1,
|
selectedMessageCounter: state.selectedMessageCounter + 1,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
if (action.type === MESSAGE_STOPPED_BY_MISSING_VERIFICATION) {
|
||||||
|
const { messageId, untrustedConversationIds } = action.payload;
|
||||||
|
|
||||||
|
const newOutboundMessagesPendingConversationVerification = {
|
||||||
|
...state.outboundMessagesPendingConversationVerification,
|
||||||
|
};
|
||||||
|
untrustedConversationIds.forEach(conversationId => {
|
||||||
|
const existingPendingMessageIds =
|
||||||
|
getOwn(
|
||||||
|
newOutboundMessagesPendingConversationVerification,
|
||||||
|
conversationId
|
||||||
|
) ?? [];
|
||||||
|
if (!existingPendingMessageIds.includes(messageId)) {
|
||||||
|
newOutboundMessagesPendingConversationVerification[conversationId] = [
|
||||||
|
...existingPendingMessageIds,
|
||||||
|
messageId,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
outboundMessagesPendingConversationVerification: newOutboundMessagesPendingConversationVerification,
|
||||||
|
};
|
||||||
|
}
|
||||||
if (action.type === 'MESSAGE_CHANGED') {
|
if (action.type === 'MESSAGE_CHANGED') {
|
||||||
const { id, conversationId, data } = action.payload;
|
const { id, conversationId, data } = action.payload;
|
||||||
const existingConversation = state.messagesByConversation[conversationId];
|
const existingConversation = state.messagesByConversation[conversationId];
|
||||||
|
|
|
@ -18,6 +18,7 @@ import {
|
||||||
PreJoinConversationType,
|
PreJoinConversationType,
|
||||||
} from '../ducks/conversations';
|
} from '../ducks/conversations';
|
||||||
import { getOwn } from '../../util/getOwn';
|
import { getOwn } from '../../util/getOwn';
|
||||||
|
import { isNotNil } from '../../util/isNotNil';
|
||||||
import { deconstructLookup } from '../../util/deconstructLookup';
|
import { deconstructLookup } from '../../util/deconstructLookup';
|
||||||
import { PropsDataType as TimelinePropsType } from '../../components/conversation/Timeline';
|
import { PropsDataType as TimelinePropsType } from '../../components/conversation/Timeline';
|
||||||
import { TimelineItemType } from '../../components/conversation/TimelineItem';
|
import { TimelineItemType } from '../../components/conversation/TimelineItem';
|
||||||
|
@ -27,6 +28,7 @@ import { filterAndSortConversationsByTitle } from '../../util/filterAndSortConve
|
||||||
import { ContactNameColors, ContactNameColorType } from '../../types/Colors';
|
import { ContactNameColors, ContactNameColorType } from '../../types/Colors';
|
||||||
import { AvatarDataType } from '../../types/Avatar';
|
import { AvatarDataType } from '../../types/Avatar';
|
||||||
import { isInSystemContacts } from '../../util/isInSystemContacts';
|
import { isInSystemContacts } from '../../util/isInSystemContacts';
|
||||||
|
import { sortByTitle } from '../../util/sortByTitle';
|
||||||
import { isGroupV2 } from '../../util/whatTypeOfConversation';
|
import { isGroupV2 } from '../../util/whatTypeOfConversation';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
|
@ -956,3 +958,51 @@ export const getGroupAdminsSelector = createSelector(
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const getOutboundMessagesPendingConversationVerification = createSelector(
|
||||||
|
getConversations,
|
||||||
|
(
|
||||||
|
conversations: Readonly<ConversationsStateType>
|
||||||
|
): Record<string, Array<string>> =>
|
||||||
|
conversations.outboundMessagesPendingConversationVerification
|
||||||
|
);
|
||||||
|
|
||||||
|
const getConversationIdsStoppingMessageSendBecauseOfVerification = createSelector(
|
||||||
|
getOutboundMessagesPendingConversationVerification,
|
||||||
|
(outboundMessagesPendingConversationVerification): Array<string> =>
|
||||||
|
Object.keys(outboundMessagesPendingConversationVerification)
|
||||||
|
);
|
||||||
|
|
||||||
|
export const getConversationsStoppingMessageSendBecauseOfVerification = createSelector(
|
||||||
|
getConversationByIdSelector,
|
||||||
|
getConversationIdsStoppingMessageSendBecauseOfVerification,
|
||||||
|
(
|
||||||
|
conversationSelector: (id: string) => undefined | ConversationType,
|
||||||
|
conversationIds: ReadonlyArray<string>
|
||||||
|
): Array<ConversationType> => {
|
||||||
|
const conversations = conversationIds
|
||||||
|
.map(conversationId => conversationSelector(conversationId))
|
||||||
|
.filter(isNotNil);
|
||||||
|
return sortByTitle(conversations);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
export const getMessageIdsPendingBecauseOfVerification = createSelector(
|
||||||
|
getOutboundMessagesPendingConversationVerification,
|
||||||
|
(outboundMessagesPendingConversationVerification): Set<string> => {
|
||||||
|
const result = new Set<string>();
|
||||||
|
Object.values(outboundMessagesPendingConversationVerification).forEach(
|
||||||
|
messageGroup => {
|
||||||
|
messageGroup.forEach(messageId => {
|
||||||
|
result.add(messageId);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
export const getNumberOfMessagesPendingBecauseOfVerification = createSelector(
|
||||||
|
getMessageIdsPendingBecauseOfVerification,
|
||||||
|
(messageIds: Readonly<Set<string>>): number => messageIds.size
|
||||||
|
);
|
||||||
|
|
|
@ -67,6 +67,7 @@ import {
|
||||||
import {
|
import {
|
||||||
SendStatus,
|
SendStatus,
|
||||||
isDelivered,
|
isDelivered,
|
||||||
|
isFailed,
|
||||||
isMessageJustForMe,
|
isMessageJustForMe,
|
||||||
isRead,
|
isRead,
|
||||||
isSent,
|
isSent,
|
||||||
|
@ -1234,7 +1235,10 @@ export function getMessagePropStatus(
|
||||||
sendStateByConversationId[ourConversationId]?.status ??
|
sendStateByConversationId[ourConversationId]?.status ??
|
||||||
SendStatus.Pending;
|
SendStatus.Pending;
|
||||||
const sent = isSent(status);
|
const sent = isSent(status);
|
||||||
if (hasErrors(message)) {
|
if (
|
||||||
|
hasErrors(message) ||
|
||||||
|
someSendStatus(sendStateByConversationId, isFailed)
|
||||||
|
) {
|
||||||
return sent ? 'partial-sent' : 'error';
|
return sent ? 'partial-sent' : 'error';
|
||||||
}
|
}
|
||||||
return sent ? 'viewed' : 'sending';
|
return sent ? 'viewed' : 'sending';
|
||||||
|
@ -1248,7 +1252,10 @@ export function getMessagePropStatus(
|
||||||
SendStatus.Pending
|
SendStatus.Pending
|
||||||
);
|
);
|
||||||
|
|
||||||
if (hasErrors(message)) {
|
if (
|
||||||
|
hasErrors(message) ||
|
||||||
|
someSendStatus(sendStateByConversationId, isFailed)
|
||||||
|
) {
|
||||||
return isSent(highestSuccessfulStatus) ? 'partial-sent' : 'error';
|
return isSent(highestSuccessfulStatus) ? 'partial-sent' : 'error';
|
||||||
}
|
}
|
||||||
if (isViewed(highestSuccessfulStatus)) {
|
if (isViewed(highestSuccessfulStatus)) {
|
||||||
|
|
|
@ -4,18 +4,34 @@
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { connect } from 'react-redux';
|
import { connect } from 'react-redux';
|
||||||
|
|
||||||
import { App, PropsType } from '../../components/App';
|
import { App } from '../../components/App';
|
||||||
import { SmartCallManager } from './CallManager';
|
import { SmartCallManager } from './CallManager';
|
||||||
import { SmartGlobalModalContainer } from './GlobalModalContainer';
|
import { SmartGlobalModalContainer } from './GlobalModalContainer';
|
||||||
|
import { SmartSafetyNumberViewer } from './SafetyNumberViewer';
|
||||||
import { StateType } from '../reducer';
|
import { StateType } from '../reducer';
|
||||||
import { getTheme } from '../selectors/user';
|
import { getIntl, getTheme } from '../selectors/user';
|
||||||
|
import {
|
||||||
|
getConversationsStoppingMessageSendBecauseOfVerification,
|
||||||
|
getNumberOfMessagesPendingBecauseOfVerification,
|
||||||
|
} from '../selectors/conversations';
|
||||||
import { mapDispatchToProps } from '../actions';
|
import { mapDispatchToProps } from '../actions';
|
||||||
|
import type { SafetyNumberProps } from '../../components/SafetyNumberChangeDialog';
|
||||||
|
|
||||||
const mapStateToProps = (state: StateType): PropsType => {
|
const mapStateToProps = (state: StateType) => {
|
||||||
return {
|
return {
|
||||||
...state.app,
|
...state.app,
|
||||||
|
conversationsStoppingMessageSendBecauseOfVerification: getConversationsStoppingMessageSendBecauseOfVerification(
|
||||||
|
state
|
||||||
|
),
|
||||||
|
i18n: getIntl(state),
|
||||||
|
numberOfMessagesPendingBecauseOfVerification: getNumberOfMessagesPendingBecauseOfVerification(
|
||||||
|
state
|
||||||
|
),
|
||||||
renderCallManager: () => <SmartCallManager />,
|
renderCallManager: () => <SmartCallManager />,
|
||||||
renderGlobalModalContainer: () => <SmartGlobalModalContainer />,
|
renderGlobalModalContainer: () => <SmartGlobalModalContainer />,
|
||||||
|
renderSafetyNumber: (props: SafetyNumberProps) => (
|
||||||
|
<SmartSafetyNumberViewer {...props} />
|
||||||
|
),
|
||||||
theme: getTheme(state),
|
theme: getTheme(state),
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
|
@ -32,7 +32,6 @@ const mapStateToProps = (
|
||||||
receivedAt,
|
receivedAt,
|
||||||
sentAt,
|
sentAt,
|
||||||
|
|
||||||
sendAnyway,
|
|
||||||
showSafetyNumber,
|
showSafetyNumber,
|
||||||
|
|
||||||
displayTapToViewMessage,
|
displayTapToViewMessage,
|
||||||
|
@ -71,7 +70,6 @@ const mapStateToProps = (
|
||||||
i18n: getIntl(state),
|
i18n: getIntl(state),
|
||||||
interactionMode: getInteractionMode(state),
|
interactionMode: getInteractionMode(state),
|
||||||
|
|
||||||
sendAnyway,
|
|
||||||
showSafetyNumber,
|
showSafetyNumber,
|
||||||
|
|
||||||
displayTapToViewMessage,
|
displayTapToViewMessage,
|
||||||
|
|
|
@ -12,6 +12,7 @@ import {
|
||||||
SendStateByConversationId,
|
SendStateByConversationId,
|
||||||
SendStatus,
|
SendStatus,
|
||||||
isDelivered,
|
isDelivered,
|
||||||
|
isFailed,
|
||||||
isMessageJustForMe,
|
isMessageJustForMe,
|
||||||
isRead,
|
isRead,
|
||||||
isSent,
|
isSent,
|
||||||
|
@ -106,6 +107,20 @@ describe('message send state utilities', () => {
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('isFailed', () => {
|
||||||
|
it('returns true for failed statuses', () => {
|
||||||
|
assert.isTrue(isFailed(SendStatus.Failed));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns false for non-failed statuses', () => {
|
||||||
|
assert.isFalse(isFailed(SendStatus.Viewed));
|
||||||
|
assert.isFalse(isFailed(SendStatus.Read));
|
||||||
|
assert.isFalse(isFailed(SendStatus.Delivered));
|
||||||
|
assert.isFalse(isFailed(SendStatus.Sent));
|
||||||
|
assert.isFalse(isFailed(SendStatus.Pending));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe('someSendStatus', () => {
|
describe('someSendStatus', () => {
|
||||||
it('returns false if there are no send states', () => {
|
it('returns false if there are no send states', () => {
|
||||||
const alwaysTrue = () => true;
|
const alwaysTrue = () => true;
|
||||||
|
|
|
@ -27,11 +27,14 @@ import {
|
||||||
getConversationByIdSelector,
|
getConversationByIdSelector,
|
||||||
getConversationsByTitleSelector,
|
getConversationsByTitleSelector,
|
||||||
getConversationSelector,
|
getConversationSelector,
|
||||||
|
getConversationsStoppingMessageSendBecauseOfVerification,
|
||||||
getFilteredCandidateContactsForNewGroup,
|
getFilteredCandidateContactsForNewGroup,
|
||||||
getFilteredComposeContacts,
|
getFilteredComposeContacts,
|
||||||
getFilteredComposeGroups,
|
getFilteredComposeGroups,
|
||||||
getInvitedContactsForNewlyCreatedGroup,
|
getInvitedContactsForNewlyCreatedGroup,
|
||||||
getMaximumGroupSizeModalState,
|
getMaximumGroupSizeModalState,
|
||||||
|
getMessageIdsPendingBecauseOfVerification,
|
||||||
|
getNumberOfMessagesPendingBecauseOfVerification,
|
||||||
getPlaceholderContact,
|
getPlaceholderContact,
|
||||||
getRecommendedGroupSizeModalState,
|
getRecommendedGroupSizeModalState,
|
||||||
getSelectedConversation,
|
getSelectedConversation,
|
||||||
|
@ -266,6 +269,100 @@ describe('both/state/selectors/conversations', () => {
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('#getConversationsStoppingMessageSendBecauseOfVerification', () => {
|
||||||
|
it('returns an empty array if there are no conversations stopping send', () => {
|
||||||
|
const state = getEmptyRootState();
|
||||||
|
|
||||||
|
assert.isEmpty(
|
||||||
|
getConversationsStoppingMessageSendBecauseOfVerification(state)
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns all conversations stopping message send', () => {
|
||||||
|
const convo1 = makeConversation('abc');
|
||||||
|
const convo2 = makeConversation('def');
|
||||||
|
const state = {
|
||||||
|
...getEmptyRootState(),
|
||||||
|
conversations: {
|
||||||
|
...getEmptyState(),
|
||||||
|
conversationLookup: {
|
||||||
|
def: convo2,
|
||||||
|
abc: convo1,
|
||||||
|
},
|
||||||
|
outboundMessagesPendingConversationVerification: {
|
||||||
|
def: ['message 2', 'message 3'],
|
||||||
|
abc: ['message 1', 'message 2'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
assert.deepEqual(
|
||||||
|
getConversationsStoppingMessageSendBecauseOfVerification(state),
|
||||||
|
[convo1, convo2]
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('#getMessageIdsPendingBecauseOfVerification', () => {
|
||||||
|
it('returns an empty set if there are no conversations stopping send', () => {
|
||||||
|
const state = getEmptyRootState();
|
||||||
|
|
||||||
|
assert.deepEqual(
|
||||||
|
getMessageIdsPendingBecauseOfVerification(state),
|
||||||
|
new Set()
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns a set of unique pending messages', () => {
|
||||||
|
const state = {
|
||||||
|
...getEmptyRootState(),
|
||||||
|
conversations: {
|
||||||
|
...getEmptyState(),
|
||||||
|
outboundMessagesPendingConversationVerification: {
|
||||||
|
abc: ['message 2', 'message 3'],
|
||||||
|
def: ['message 1', 'message 2'],
|
||||||
|
ghi: ['message 4'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
assert.deepEqual(
|
||||||
|
getMessageIdsPendingBecauseOfVerification(state),
|
||||||
|
new Set(['message 1', 'message 2', 'message 3', 'message 4'])
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('#getNumberOfMessagesPendingBecauseOfVerification', () => {
|
||||||
|
it('returns 0 if there are no conversations stopping send', () => {
|
||||||
|
const state = getEmptyRootState();
|
||||||
|
|
||||||
|
assert.strictEqual(
|
||||||
|
getNumberOfMessagesPendingBecauseOfVerification(state),
|
||||||
|
0
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns a count of unique pending messages', () => {
|
||||||
|
const state = {
|
||||||
|
...getEmptyRootState(),
|
||||||
|
conversations: {
|
||||||
|
...getEmptyState(),
|
||||||
|
outboundMessagesPendingConversationVerification: {
|
||||||
|
abc: ['message 2', 'message 3'],
|
||||||
|
def: ['message 1', 'message 2'],
|
||||||
|
ghi: ['message 4'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
assert.strictEqual(
|
||||||
|
getNumberOfMessagesPendingBecauseOfVerification(state),
|
||||||
|
4
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe('#getInvitedContactsForNewlyCreatedGroup', () => {
|
describe('#getInvitedContactsForNewlyCreatedGroup', () => {
|
||||||
it('returns an empty array if there are no invited contacts', () => {
|
it('returns an empty array if there are no invited contacts', () => {
|
||||||
const state = getEmptyRootState();
|
const state = getEmptyRootState();
|
||||||
|
|
|
@ -45,6 +45,7 @@ const {
|
||||||
closeRecommendedGroupSizeModal,
|
closeRecommendedGroupSizeModal,
|
||||||
createGroup,
|
createGroup,
|
||||||
messageSizeChanged,
|
messageSizeChanged,
|
||||||
|
messageStoppedByMissingVerification,
|
||||||
openConversationInternal,
|
openConversationInternal,
|
||||||
repairNewestMessage,
|
repairNewestMessage,
|
||||||
repairOldestMessage,
|
repairOldestMessage,
|
||||||
|
@ -888,6 +889,35 @@ describe('both/state/ducks/conversations', () => {
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('MESSAGE_STOPPED_BY_MISSING_VERIFICATION', () => {
|
||||||
|
it('adds messages that need conversation verification, removing duplicates', () => {
|
||||||
|
const first = reducer(
|
||||||
|
getEmptyState(),
|
||||||
|
messageStoppedByMissingVerification('message 1', ['convo 1'])
|
||||||
|
);
|
||||||
|
const second = reducer(
|
||||||
|
first,
|
||||||
|
messageStoppedByMissingVerification('message 1', ['convo 2'])
|
||||||
|
);
|
||||||
|
const third = reducer(
|
||||||
|
second,
|
||||||
|
messageStoppedByMissingVerification('message 2', [
|
||||||
|
'convo 1',
|
||||||
|
'convo 3',
|
||||||
|
])
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.deepStrictEqual(
|
||||||
|
third.outboundMessagesPendingConversationVerification,
|
||||||
|
{
|
||||||
|
'convo 1': ['message 1', 'message 2'],
|
||||||
|
'convo 2': ['message 1'],
|
||||||
|
'convo 3': ['message 2'],
|
||||||
|
}
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe('REPAIR_NEWEST_MESSAGE', () => {
|
describe('REPAIR_NEWEST_MESSAGE', () => {
|
||||||
it('updates newest', () => {
|
it('updates newest', () => {
|
||||||
const action = repairNewestMessage(conversationId);
|
const action = repairNewestMessage(conversationId);
|
||||||
|
|
|
@ -92,6 +92,32 @@ describe('JobQueueDatabaseStore', () => {
|
||||||
assert.deepEqual(events, ['insert', 'yielded job']);
|
assert.deepEqual(events, ['insert', 'yielded job']);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('can skip the database', async () => {
|
||||||
|
const store = new JobQueueDatabaseStore(fakeDatabase);
|
||||||
|
|
||||||
|
const streamPromise = (async () => {
|
||||||
|
// We don't actually care about using the variable from the async iterable.
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||||
|
for await (const _job of store.stream('test queue')) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
|
||||||
|
await store.insert(
|
||||||
|
{
|
||||||
|
id: 'abc',
|
||||||
|
timestamp: 1234,
|
||||||
|
queueType: 'test queue',
|
||||||
|
data: { hi: 5 },
|
||||||
|
},
|
||||||
|
{ shouldInsertIntoDatabase: false }
|
||||||
|
);
|
||||||
|
|
||||||
|
await streamPromise;
|
||||||
|
|
||||||
|
sinon.assert.notCalled(fakeDatabase.insertJob);
|
||||||
|
});
|
||||||
|
|
||||||
it("doesn't insert jobs until the initial fetch has completed", async () => {
|
it("doesn't insert jobs until the initial fetch has completed", async () => {
|
||||||
const events: Array<string> = [];
|
const events: Array<string> = [];
|
||||||
|
|
||||||
|
|
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;
|
online?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
type PreviewType = {
|
export type PreviewType = {
|
||||||
url: string;
|
url: string;
|
||||||
title: string;
|
title: string;
|
||||||
image?: AttachmentType;
|
image?: AttachmentType;
|
||||||
|
|
|
@ -145,7 +145,6 @@ type MessageActionsType = {
|
||||||
) => unknown;
|
) => unknown;
|
||||||
replyToMessage: (messageId: string) => unknown;
|
replyToMessage: (messageId: string) => unknown;
|
||||||
retrySend: (messageId: string) => unknown;
|
retrySend: (messageId: string) => unknown;
|
||||||
sendAnyway: (contactId: string, messageId: string) => unknown;
|
|
||||||
showContactDetail: (options: {
|
showContactDetail: (options: {
|
||||||
contact: EmbeddedContactType;
|
contact: EmbeddedContactType;
|
||||||
signalAccount?: string;
|
signalAccount?: string;
|
||||||
|
@ -1057,9 +1056,6 @@ export class ConversationView extends window.Backbone.View<ConversationModel> {
|
||||||
const downloadNewVersion = () => {
|
const downloadNewVersion = () => {
|
||||||
this.downloadNewVersion();
|
this.downloadNewVersion();
|
||||||
};
|
};
|
||||||
const sendAnyway = (contactId: string, messageId: string) => {
|
|
||||||
this.forceSend({ contactId, messageId });
|
|
||||||
};
|
|
||||||
const showSafetyNumber = (contactId: string) => {
|
const showSafetyNumber = (contactId: string) => {
|
||||||
this.showSafetyNumber(contactId);
|
this.showSafetyNumber(contactId);
|
||||||
};
|
};
|
||||||
|
@ -1092,7 +1088,6 @@ export class ConversationView extends window.Backbone.View<ConversationModel> {
|
||||||
reactToMessage,
|
reactToMessage,
|
||||||
replyToMessage,
|
replyToMessage,
|
||||||
retrySend,
|
retrySend,
|
||||||
sendAnyway,
|
|
||||||
showContactDetail,
|
showContactDetail,
|
||||||
showContactModal,
|
showContactModal,
|
||||||
showSafetyNumber,
|
showSafetyNumber,
|
||||||
|
@ -2657,7 +2652,7 @@ export class ConversationView extends window.Backbone.View<ConversationModel> {
|
||||||
}
|
}
|
||||||
: undefined;
|
: undefined;
|
||||||
|
|
||||||
conversation.sendMessage(
|
conversation.enqueueMessageForSend(
|
||||||
undefined, // body
|
undefined, // body
|
||||||
[],
|
[],
|
||||||
undefined, // quote
|
undefined, // quote
|
||||||
|
@ -2683,7 +2678,7 @@ export class ConversationView extends window.Backbone.View<ConversationModel> {
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
|
||||||
conversation.sendMessage(
|
conversation.enqueueMessageForSend(
|
||||||
messageBody || undefined,
|
messageBody || undefined,
|
||||||
attachmentsToSend,
|
attachmentsToSend,
|
||||||
undefined, // quote
|
undefined, // quote
|
||||||
|
@ -2945,49 +2940,6 @@ export class ConversationView extends window.Backbone.View<ConversationModel> {
|
||||||
view.render();
|
view.render();
|
||||||
}
|
}
|
||||||
|
|
||||||
// eslint-disable-next-line class-methods-use-this
|
|
||||||
forceSend({
|
|
||||||
contactId,
|
|
||||||
messageId,
|
|
||||||
}: Readonly<{ contactId: string; messageId: string }>): void {
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
|
||||||
const contact = window.ConversationController.get(contactId)!;
|
|
||||||
const message = window.MessageController.getById(messageId);
|
|
||||||
if (!message) {
|
|
||||||
throw new Error(`forceSend: Message ${messageId} missing!`);
|
|
||||||
}
|
|
||||||
|
|
||||||
window.showConfirmationDialog({
|
|
||||||
confirmStyle: 'negative',
|
|
||||||
message: window.i18n('identityKeyErrorOnSend', {
|
|
||||||
name1: contact.getTitle(),
|
|
||||||
name2: contact.getTitle(),
|
|
||||||
}),
|
|
||||||
okText: window.i18n('sendAnyway'),
|
|
||||||
resolve: async () => {
|
|
||||||
await contact.updateVerified();
|
|
||||||
|
|
||||||
if (contact.isUnverified()) {
|
|
||||||
await contact.setVerifiedDefault();
|
|
||||||
}
|
|
||||||
|
|
||||||
const untrusted = await contact.isUntrusted();
|
|
||||||
if (untrusted) {
|
|
||||||
contact.setApproved();
|
|
||||||
}
|
|
||||||
|
|
||||||
const sendTarget = contact.getSendTarget();
|
|
||||||
if (!sendTarget) {
|
|
||||||
throw new Error(
|
|
||||||
`forceSend: Contact ${contact.idForLogging()} had no sendTarget!`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
message.resend(sendTarget);
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
showSafetyNumber(id?: string): void {
|
showSafetyNumber(id?: string): void {
|
||||||
let conversation: undefined | ConversationModel;
|
let conversation: undefined | ConversationModel;
|
||||||
|
|
||||||
|
@ -4200,7 +4152,7 @@ export class ConversationView extends window.Backbone.View<ConversationModel> {
|
||||||
window.log.info('Send pre-checks took', sendDelta, 'milliseconds');
|
window.log.info('Send pre-checks took', sendDelta, 'milliseconds');
|
||||||
|
|
||||||
batchedUpdates(() => {
|
batchedUpdates(() => {
|
||||||
model.sendMessage(
|
model.enqueueMessageForSend(
|
||||||
message,
|
message,
|
||||||
attachments,
|
attachments,
|
||||||
this.quote,
|
this.quote,
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue