Introduce new conversationJobQueue

This commit is contained in:
Scott Nonnenberg 2022-02-16 10:36:21 -08:00 committed by GitHub
parent 37d4776472
commit 30783c887c
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
40 changed files with 3111 additions and 1742 deletions

View File

@ -415,20 +415,10 @@
"message": "The following people may have reinstalled or changed devices. Verify your safety number with them to ensure privacy.",
"description": "Shown on confirmation dialog when user attempts to send a message"
},
"safetyNumberChangeDialog__pending-messages--1": {
"message": "Send pending message",
"safetyNumberChangeDialog__pending-messages": {
"message": "Send pending messages",
"description": "Shown on confirmation dialog when user attempts to send a message in the outbox"
},
"safetyNumberChangeDialog__pending-messages--many": {
"message": "Send $count$ pending messages",
"description": "Shown on confirmation dialog when user attempts to send a message in the outbox",
"placeholders": {
"count": {
"content": "$1",
"example": "123"
}
}
},
"identityKeyErrorOnSend": {
"message": "Your safety number with $name1$ has changed. This could either mean that someone is trying to intercept your communication or that $name2$ has simply reinstalled Signal. You may wish to verify your safety number with this contact.",
"description": "Shown when user clicks on a failed recipient in the message detail view after an identity key change",

View File

@ -9,7 +9,6 @@ import * as log from './logging/log';
export type ConfigKeyType =
| 'desktop.announcementGroup'
| 'desktop.clientExpiration'
| 'desktop.disableGV1'
| 'desktop.groupCallOutboundRing'
| 'desktop.internalUser'
| 'desktop.mandatoryProfileSharing'

View File

@ -897,10 +897,12 @@ export async function startApp(): Promise<void> {
const ourUuid = window.textsecure.storage.user.getUuid()?.toString();
const ourConversationId =
window.ConversationController.getOurConversationId();
const ourDeviceId = window.textsecure.storage.user.getDeviceId();
const themeSetting = window.Events.getThemeSetting();
const theme = themeSetting === 'system' ? window.systemTheme : themeSetting;
// TODO: DESKTOP-3125
const initialState = {
badges: initialBadgesState,
conversations: {
@ -923,7 +925,7 @@ export async function startApp(): Promise<void> {
),
messagesByConversation: {},
messagesLookup: {},
outboundMessagesPendingConversationVerification: {},
verificationDataByConversation: {},
selectedConversationId: undefined,
selectedMessage: undefined,
selectedMessageCounter: 0,
@ -942,6 +944,7 @@ export async function startApp(): Promise<void> {
tempPath: window.baseTempPath,
regionCode: window.storage.get('regionCode'),
ourConversationId,
ourDeviceId,
ourNumber,
ourUuid,
platform: window.platform,

View File

@ -30,13 +30,12 @@ type PropsType = {
export const App = ({
appView,
cancelMessagesPendingConversationVerification,
conversationsStoppingMessageSendBecauseOfVerification,
cancelConversationVerification,
conversationsStoppingSend,
hasInitialLoadCompleted,
getPreferredBadge,
i18n,
isCustomizingPreferredReactions,
numberOfMessagesPendingBecauseOfVerification,
renderCallManager,
renderCustomizingPreferredReactionsModal,
renderGlobalModalContainer,
@ -45,7 +44,7 @@ export const App = ({
requestVerification,
registerSingleDevice,
theme,
verifyConversationsStoppingMessageSend,
verifyConversationsStoppingSend,
}: PropsType): JSX.Element => {
let contents;
@ -66,27 +65,18 @@ export const App = ({
} else if (appView === AppViewType.Inbox) {
contents = (
<Inbox
cancelMessagesPendingConversationVerification={
cancelMessagesPendingConversationVerification
}
conversationsStoppingMessageSendBecauseOfVerification={
conversationsStoppingMessageSendBecauseOfVerification
}
cancelConversationVerification={cancelConversationVerification}
conversationsStoppingSend={conversationsStoppingSend}
hasInitialLoadCompleted={hasInitialLoadCompleted}
getPreferredBadge={getPreferredBadge}
i18n={i18n}
isCustomizingPreferredReactions={isCustomizingPreferredReactions}
numberOfMessagesPendingBecauseOfVerification={
numberOfMessagesPendingBecauseOfVerification
}
renderCustomizingPreferredReactionsModal={
renderCustomizingPreferredReactionsModal
}
renderSafetyNumber={renderSafetyNumber}
theme={theme}
verifyConversationsStoppingMessageSend={
verifyConversationsStoppingMessageSend
}
verifyConversationsStoppingSend={verifyConversationsStoppingSend}
/>
);
}

View File

@ -20,31 +20,29 @@ type InboxViewOptionsType = Backbone.ViewOptions & {
};
export type PropsType = {
cancelMessagesPendingConversationVerification: () => void;
conversationsStoppingMessageSendBecauseOfVerification: Array<ConversationType>;
cancelConversationVerification: () => void;
conversationsStoppingSend: Array<ConversationType>;
hasInitialLoadCompleted: boolean;
getPreferredBadge: PreferredBadgeSelectorType;
i18n: LocalizerType;
isCustomizingPreferredReactions: boolean;
numberOfMessagesPendingBecauseOfVerification: number;
renderCustomizingPreferredReactionsModal: () => JSX.Element;
renderSafetyNumber: (props: SafetyNumberProps) => JSX.Element;
theme: ThemeType;
verifyConversationsStoppingMessageSend: () => void;
verifyConversationsStoppingSend: () => void;
};
export const Inbox = ({
cancelMessagesPendingConversationVerification,
conversationsStoppingMessageSendBecauseOfVerification,
cancelConversationVerification,
conversationsStoppingSend,
hasInitialLoadCompleted,
getPreferredBadge,
i18n,
isCustomizingPreferredReactions,
numberOfMessagesPendingBecauseOfVerification,
renderCustomizingPreferredReactionsModal,
renderSafetyNumber,
theme,
verifyConversationsStoppingMessageSend,
verifyConversationsStoppingSend,
}: PropsType): JSX.Element => {
const hostRef = useRef<HTMLDivElement | null>(null);
const viewRef = useRef<InboxViewType | undefined>(undefined);
@ -76,21 +74,15 @@ export const Inbox = ({
}, [hasInitialLoadCompleted, viewRef]);
let activeModal: ReactNode;
if (conversationsStoppingMessageSendBecauseOfVerification.length) {
const confirmText: string =
numberOfMessagesPendingBecauseOfVerification === 1
? i18n('safetyNumberChangeDialog__pending-messages--1')
: i18n('safetyNumberChangeDialog__pending-messages--many', [
numberOfMessagesPendingBecauseOfVerification.toString(),
]);
if (conversationsStoppingSend.length) {
activeModal = (
<SafetyNumberChangeDialog
confirmText={confirmText}
contacts={conversationsStoppingMessageSendBecauseOfVerification}
confirmText={i18n('safetyNumberChangeDialog__pending-messages')}
contacts={conversationsStoppingSend}
getPreferredBadge={getPreferredBadge}
i18n={i18n}
onCancel={cancelMessagesPendingConversationVerification}
onConfirm={verifyConversationsStoppingMessageSend}
onCancel={cancelConversationVerification}
onConfirm={verifyConversationsStoppingSend}
renderSafetyNumber={renderSafetyNumber}
theme={theme}
/>

View File

@ -1,4 +1,4 @@
// Copyright 2020-2021 Signal Messenger, LLC
// Copyright 2020-2022 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import {
@ -59,24 +59,25 @@ import type {
GroupLogResponseType,
} from './textsecure/WebAPI';
import type MessageSender from './textsecure/SendMessage';
import type { CallbackResultType } from './textsecure/Types.d';
import { CURRENT_SCHEMA_VERSION as MAX_MESSAGE_SCHEMA } from '../js/modules/types/message';
import type { ConversationModel } from './models/conversations';
import { getGroupSizeHardLimit } from './groups/limits';
import { ourProfileKeyService } from './services/ourProfileKey';
import {
isGroupV1 as getIsGroupV1,
isGroupV2 as getIsGroupV2,
isMe,
} from './util/whatTypeOfConversation';
import type { SendTypesType } from './util/handleMessageSend';
import { handleMessageSend } from './util/handleMessageSend';
import { getSendOptions } from './util/getSendOptions';
import * as Bytes from './Bytes';
import type { AvatarDataType } from './types/Avatar';
import { UUID, isValidUuid } from './types/UUID';
import type { UUIDStringType } from './types/UUID';
import { SignalService as Proto } from './protobuf';
import {
conversationJobQueue,
conversationQueueJobEnum,
} from './jobs/conversationJobQueue';
import AccessRequiredEnum = Proto.AccessControl.AccessRequired;
export { joinViaLink } from './groups/joinViaLink';
@ -1234,11 +1235,11 @@ export async function modifyGroupV2({
inviteLinkPassword?: string;
name: string;
}): Promise<void> {
const idLog = `${name}/${conversation.idForLogging()}`;
const logId = `${name}/${conversation.idForLogging()}`;
if (!getIsGroupV2(conversation.attributes)) {
throw new Error(
`modifyGroupV2/${idLog}: Called for non-GroupV2 conversation`
`modifyGroupV2/${logId}: Called for non-GroupV2 conversation`
);
}
@ -1248,21 +1249,21 @@ export async function modifyGroupV2({
const MAX_ATTEMPTS = 5;
for (let attempt = 0; attempt < MAX_ATTEMPTS; attempt += 1) {
log.info(`modifyGroupV2/${idLog}: Starting attempt ${attempt}`);
log.info(`modifyGroupV2/${logId}: Starting attempt ${attempt}`);
try {
// eslint-disable-next-line no-await-in-loop
await window.waitForEmptyEventQueue();
log.info(`modifyGroupV2/${idLog}: Queuing attempt ${attempt}`);
log.info(`modifyGroupV2/${logId}: Queuing attempt ${attempt}`);
// eslint-disable-next-line no-await-in-loop
await conversation.queueJob('modifyGroupV2', async () => {
log.info(`modifyGroupV2/${idLog}: Running attempt ${attempt}`);
log.info(`modifyGroupV2/${logId}: Running attempt ${attempt}`);
const actions = await createGroupChange();
if (!actions) {
log.warn(
`modifyGroupV2/${idLog}: No change actions. Returning early.`
`modifyGroupV2/${logId}: No change actions. Returning early.`
);
return;
}
@ -1274,7 +1275,7 @@ export async function modifyGroupV2({
if ((currentRevision || 0) + 1 !== newRevision) {
throw new Error(
`modifyGroupV2/${idLog}: Revision mismatch - ${currentRevision} to ${newRevision}.`
`modifyGroupV2/${logId}: Revision mismatch - ${currentRevision} to ${newRevision}.`
);
}
@ -1297,76 +1298,44 @@ export async function modifyGroupV2({
newRevision,
});
// Send message to notify group members (including pending members) of change
const profileKey = conversation.get('profileSharing')
? await ourProfileKeyService.get()
: undefined;
const groupV2Info = conversation.getGroupV2Info({
includePendingMembers: true,
extraConversationsForSend,
});
strictAssert(groupV2Info, 'missing groupV2Info');
const sendOptions = await getSendOptions(conversation.attributes);
const timestamp = Date.now();
const { ContentHint } = Proto.UnidentifiedSenderMessage.Message;
const promise = handleMessageSend(
window.Signal.Util.sendToGroup({
groupSendOptions: {
groupV2: conversation.getGroupV2Info({
groupChange: groupChangeBuffer,
includePendingMembers: true,
extraConversationsForSend,
}),
timestamp,
profileKey,
},
contentHint: ContentHint.RESENDABLE,
messageId: undefined,
sendOptions,
sendTarget: conversation.toSenderKeyTarget(),
sendType: 'groupChange',
}),
{ messageIds: [], sendType: 'groupChange' }
);
// We don't save this message; we just use it to ensure that a sync message is
// sent to our linked devices.
const m = new window.Whisper.Message({
await conversationJobQueue.add({
type: conversationQueueJobEnum.enum.GroupUpdate,
conversationId: conversation.id,
type: 'not-to-save',
sent_at: timestamp,
received_at: timestamp,
// TODO: DESKTOP-722
// this type does not fully implement the interface it is expected to
} as unknown as MessageAttributesType);
// This is to ensure that the functions in send() and sendSyncMessage()
// don't save anything to the database.
m.doNotSave = true;
await m.send(promise);
groupChangeBase64: Bytes.toBase64(groupChangeBuffer),
recipients: groupV2Info.members,
revision: groupV2Info.revision,
});
});
// If we've gotten here with no error, we exit!
log.info(
`modifyGroupV2/${idLog}: Update complete, with attempt ${attempt}!`
`modifyGroupV2/${logId}: Update complete, with attempt ${attempt}!`
);
break;
} catch (error) {
if (error.code === 409 && Date.now() <= timeoutTime) {
log.info(
`modifyGroupV2/${idLog}: Conflict while updating. Trying again...`
`modifyGroupV2/${logId}: Conflict while updating. Trying again...`
);
// eslint-disable-next-line no-await-in-loop
await conversation.fetchLatestGroupV2Data({ force: true });
} else if (error.code === 409) {
log.error(
`modifyGroupV2/${idLog}: Conflict while updating. Timed out; not retrying.`
`modifyGroupV2/${logId}: Conflict while updating. Timed out; not retrying.`
);
// We don't wait here because we're breaking out of the loop immediately.
conversation.fetchLatestGroupV2Data({ force: true });
throw error;
} else {
const errorString = error && error.stack ? error.stack : error;
log.error(`modifyGroupV2/${idLog}: Error updating: ${errorString}`);
log.error(`modifyGroupV2/${logId}: Error updating: ${errorString}`);
throw error;
}
}
@ -1673,33 +1642,16 @@ export async function createGroupV2({
});
const timestamp = Date.now();
const profileKey = await ourProfileKeyService.get();
const groupV2Info = conversation.getGroupV2Info({
includePendingMembers: true,
});
const { ContentHint } = Proto.UnidentifiedSenderMessage.Message;
const sendOptions = await getSendOptions(conversation.attributes);
strictAssert(groupV2Info, 'missing groupV2Info');
await wrapWithSyncMessageSend({
conversation,
logId: `sendToGroup/${logId}`,
messageIds: [],
send: async () =>
window.Signal.Util.sendToGroup({
contentHint: ContentHint.RESENDABLE,
groupSendOptions: {
groupV2: groupV2Info,
timestamp,
profileKey,
},
messageId: undefined,
sendOptions,
sendTarget: conversation.toSenderKeyTarget(),
sendType: 'groupChange',
}),
sendType: 'groupChange',
timestamp,
await conversationJobQueue.add({
type: conversationQueueJobEnum.enum.GroupUpdate,
conversationId: conversation.id,
recipients: groupV2Info.members,
revision: groupV2Info.revision,
});
const createdTheGroupMessage: MessageAttributesType = {
@ -2199,119 +2151,17 @@ export async function initiateMigrationToGroupV2(
return;
}
// We've migrated the group, now we need to let all other group members know about it
const logId = conversation.idForLogging();
const timestamp = Date.now();
const ourProfileKey = await ourProfileKeyService.get();
const { ContentHint } = Proto.UnidentifiedSenderMessage.Message;
const sendOptions = await getSendOptions(conversation.attributes);
await wrapWithSyncMessageSend({
conversation,
logId: `sendToGroup/${logId}`,
messageIds: [],
send: async () =>
// Minimal message to notify group members about migration
window.Signal.Util.sendToGroup({
contentHint: ContentHint.RESENDABLE,
groupSendOptions: {
groupV2: conversation.getGroupV2Info({
includePendingMembers: true,
}),
timestamp,
profileKey: ourProfileKey,
},
messageId: undefined,
sendOptions,
sendTarget: conversation.toSenderKeyTarget(),
sendType: 'groupChange',
}),
sendType: 'groupChange',
timestamp,
const groupV2Info = conversation.getGroupV2Info({
includePendingMembers: true,
});
}
strictAssert(groupV2Info, 'missing groupV2Info');
export async function wrapWithSyncMessageSend({
conversation,
logId,
messageIds,
send,
sendType,
timestamp,
}: {
conversation: ConversationModel;
logId: string;
messageIds: Array<string>;
send: (sender: MessageSender) => Promise<CallbackResultType>;
sendType: SendTypesType;
timestamp: number;
}): Promise<void> {
const sender = window.textsecure.messaging;
if (!sender) {
throw new Error(
`initiateMigrationToGroupV2/${logId}: textsecure.messaging is not available!`
);
}
let response: CallbackResultType | undefined;
try {
response = await handleMessageSend(send(sender), { messageIds, sendType });
} catch (error) {
if (conversation.processSendResponse(error)) {
response = error;
}
}
if (!response) {
throw new Error(
`wrapWithSyncMessageSend/${logId}: message send didn't return result!!`
);
}
// Minimal implementation of sending same message to linked devices
const { dataMessage } = response;
if (!dataMessage) {
throw new Error(
`wrapWithSyncMessageSend/${logId}: dataMessage was not returned by send!`
);
}
const ourConversationId =
window.ConversationController.getOurConversationId();
if (!ourConversationId) {
throw new Error(
`wrapWithSyncMessageSend/${logId}: Cannot get our conversationId!`
);
}
const ourConversation = window.ConversationController.get(ourConversationId);
if (!ourConversation) {
throw new Error(
`wrapWithSyncMessageSend/${logId}: Cannot get our conversation!`
);
}
if (window.ConversationController.areWePrimaryDevice()) {
log.warn(
`wrapWithSyncMessageSend/${logId}: We are primary device; not sync message`
);
return;
}
const options = await getSendOptions(ourConversation.attributes);
await handleMessageSend(
sender.sendSyncMessage({
destination: ourConversation.get('e164'),
destinationUuid: ourConversation.get('uuid'),
encodedDataMessage: dataMessage,
expirationStartTimestamp: null,
options,
timestamp,
}),
{ messageIds, sendType }
);
await conversationJobQueue.add({
type: conversationQueueJobEnum.enum.GroupUpdate,
conversationId: conversation.id,
recipients: groupV2Info.members,
revision: groupV2Info.revision,
});
}
export async function waitThenRespondToGroupV2Migration(

View File

@ -0,0 +1,347 @@
// Copyright 2021-2022 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import { z } from 'zod';
import type PQueue from 'p-queue';
import * as globalLogger from '../logging/log';
import * as durations from '../util/durations';
import { exponentialBackoffMaxAttempts } from '../util/exponentialBackoff';
import { commonShouldJobContinue } from './helpers/commonShouldJobContinue';
import { InMemoryQueues } from './helpers/InMemoryQueues';
import { jobQueueDatabaseStore } from './JobQueueDatabaseStore';
import { JobQueue } from './JobQueue';
import { sendNormalMessage } from './helpers/sendNormalMessage';
import { sendDirectExpirationTimerUpdate } from './helpers/sendDirectExpirationTimerUpdate';
import { sendGroupUpdate } from './helpers/sendGroupUpdate';
import { sendDeleteForEveryone } from './helpers/sendDeleteForEveryone';
import { sendProfileKey } from './helpers/sendProfileKey';
import { sendReaction } from './helpers/sendReaction';
import type { LoggerType } from '../types/Logging';
import { ConversationVerificationState } from '../state/ducks/conversationsEnums';
import { sleep } from '../util/sleep';
import { SECOND } from '../util/durations';
import {
OutgoingIdentityKeyError,
SendMessageProtoError,
} from '../textsecure/Errors';
import { strictAssert } from '../util/assert';
import { missingCaseError } from '../util/missingCaseError';
import { explodePromise } from '../util/explodePromise';
// Note: generally, we only want to add to this list. If you do need to change one of
// these values, you'll likely need to write a database migration.
export const conversationQueueJobEnum = z.enum([
'DeleteForEveryone',
'DirectExpirationTimerUpdate',
'GroupUpdate',
'NormalMessage',
'ProfileKey',
'Reaction',
]);
const deleteForEveryoneJobDataSchema = z.object({
type: z.literal(conversationQueueJobEnum.enum.DeleteForEveryone),
conversationId: z.string(),
messageId: z.string(),
recipients: z.array(z.string()),
revision: z.number().optional(),
targetTimestamp: z.number(),
});
export type DeleteForEveryoneJobData = z.infer<
typeof deleteForEveryoneJobDataSchema
>;
const expirationTimerUpdateJobDataSchema = z.object({
type: z.literal(conversationQueueJobEnum.enum.DirectExpirationTimerUpdate),
conversationId: z.string(),
expireTimer: z.number().or(z.undefined()),
// Note: no recipients/revision, because this job is for 1:1 conversations only!
});
export type ExpirationTimerUpdateJobData = z.infer<
typeof expirationTimerUpdateJobDataSchema
>;
const groupUpdateJobDataSchema = z.object({
type: z.literal(conversationQueueJobEnum.enum.GroupUpdate),
conversationId: z.string(),
groupChangeBase64: z.string().optional(),
recipients: z.array(z.string()),
revision: z.number(),
});
export type GroupUpdateJobData = z.infer<typeof groupUpdateJobDataSchema>;
const normalMessageSendJobDataSchema = z.object({
type: z.literal(conversationQueueJobEnum.enum.NormalMessage),
conversationId: z.string(),
messageId: z.string(),
// Note: recipients are baked into the message itself
revision: z.number().optional(),
});
export type NormalMessageSendJobData = z.infer<
typeof normalMessageSendJobDataSchema
>;
const profileKeyJobDataSchema = z.object({
type: z.literal(conversationQueueJobEnum.enum.ProfileKey),
conversationId: z.string(),
// Note: we will use whichever recipients list is up to date when this job runs
revision: z.number().optional(),
});
export type ProfileKeyJobData = z.infer<typeof profileKeyJobDataSchema>;
const reactionJobDataSchema = z.object({
type: z.literal(conversationQueueJobEnum.enum.Reaction),
conversationId: z.string(),
messageId: z.string(),
// Note: recipients are baked into the message itself
revision: z.number().optional(),
});
export type ReactionJobData = z.infer<typeof reactionJobDataSchema>;
export const conversationQueueJobDataSchema = z.union([
deleteForEveryoneJobDataSchema,
expirationTimerUpdateJobDataSchema,
groupUpdateJobDataSchema,
normalMessageSendJobDataSchema,
profileKeyJobDataSchema,
reactionJobDataSchema,
]);
export type ConversationQueueJobData = z.infer<
typeof conversationQueueJobDataSchema
>;
export type ConversationQueueJobBundle = {
isFinalAttempt: boolean;
shouldContinue: boolean;
timeRemaining: number;
timestamp: number;
log: LoggerType;
};
const MAX_RETRY_TIME = durations.DAY;
const MAX_ATTEMPTS = exponentialBackoffMaxAttempts(MAX_RETRY_TIME);
export class ConversationJobQueue extends JobQueue<ConversationQueueJobData> {
private readonly inMemoryQueues = new InMemoryQueues();
private readonly verificationWaitMap = new Map<
string,
{
resolve: (value: unknown) => unknown;
reject: (error: Error) => unknown;
promise: Promise<unknown>;
}
>();
protected parseData(data: unknown): ConversationQueueJobData {
return conversationQueueJobDataSchema.parse(data);
}
protected override getInMemoryQueue({
data,
}: Readonly<{ data: ConversationQueueJobData }>): PQueue {
return this.inMemoryQueues.get(data.conversationId);
}
private startVerificationWaiter(conversationId: string): Promise<unknown> {
const existing = this.verificationWaitMap.get(conversationId);
if (existing) {
globalLogger.info(
`startVerificationWaiter: Found existing waiter for conversation ${conversationId}. Returning it.`
);
return existing.promise;
}
globalLogger.info(
`startVerificationWaiter: Starting new waiter for conversation ${conversationId}.`
);
const { resolve, reject, promise } = explodePromise();
this.verificationWaitMap.set(conversationId, {
resolve,
reject,
promise,
});
return promise;
}
public resolveVerificationWaiter(conversationId: string): void {
const existing = this.verificationWaitMap.get(conversationId);
if (existing) {
globalLogger.info(
`resolveVerificationWaiter: Found waiter for conversation ${conversationId}. Resolving.`
);
existing.resolve('resolveVerificationWaiter: success');
this.verificationWaitMap.delete(conversationId);
} else {
globalLogger.warn(
`resolveVerificationWaiter: Missing waiter for conversation ${conversationId}.`
);
}
}
protected async run(
{
data,
timestamp,
}: Readonly<{ data: ConversationQueueJobData; timestamp: number }>,
{ attempt, log }: Readonly<{ attempt: number; log: LoggerType }>
): Promise<void> {
const { type, conversationId } = data;
const isFinalAttempt = attempt >= MAX_ATTEMPTS;
await window.ConversationController.load();
const conversation = window.ConversationController.get(conversationId);
if (!conversation) {
throw new Error(`Failed to find conversation ${conversationId}`);
}
let timeRemaining: number;
let shouldContinue: boolean;
// eslint-disable-next-line no-constant-condition
while (true) {
log.info('calculating timeRemaining and shouldContinue...');
timeRemaining = timestamp + MAX_RETRY_TIME - Date.now();
// eslint-disable-next-line no-await-in-loop
shouldContinue = await commonShouldJobContinue({
attempt,
log,
timeRemaining,
});
if (!shouldContinue) {
break;
}
const verificationData =
window.reduxStore.getState().conversations
.verificationDataByConversation[conversationId];
if (!verificationData) {
break;
}
if (
verificationData.type ===
ConversationVerificationState.PendingVerification
) {
log.info(
'verification is pending for this conversation; waiting at most 30s...'
);
// eslint-disable-next-line no-await-in-loop
await Promise.race([
this.startVerificationWaiter(conversation.id),
sleep(30 * SECOND),
]);
continue;
}
if (
verificationData.type ===
ConversationVerificationState.VerificationCancelled
) {
if (verificationData.canceledAt >= timestamp) {
log.info(
'cancelling job; user cancelled out of verification dialog.'
);
shouldContinue = false;
} else {
log.info(
'clearing cancellation tombstone; continuing ahead with job'
);
window.reduxActions.conversations.clearCancelledConversationVerification(
conversation.id
);
}
break;
}
throw missingCaseError(verificationData);
}
const jobBundle = {
isFinalAttempt,
shouldContinue,
timeRemaining,
timestamp,
log,
};
// Note: A six-letter variable makes below code autoformatting easier to read.
const jobSet = conversationQueueJobEnum.enum;
try {
switch (type) {
case jobSet.DeleteForEveryone:
await sendDeleteForEveryone(conversation, jobBundle, data);
break;
case jobSet.DirectExpirationTimerUpdate:
await sendDirectExpirationTimerUpdate(conversation, jobBundle, data);
break;
case jobSet.GroupUpdate:
await sendGroupUpdate(conversation, jobBundle, data);
break;
case jobSet.NormalMessage:
await sendNormalMessage(conversation, jobBundle, data);
break;
case jobSet.ProfileKey:
await sendProfileKey(conversation, jobBundle, data);
break;
case jobSet.Reaction:
await sendReaction(conversation, jobBundle, data);
break;
default: {
// Note: This should never happen, because the zod call in parseData wouldn't
// accept data that doesn't look like our type specification.
const problem: never = type;
log.error(
`conversationJobQueue: Got job with type ${problem}; Cancelling job.`
);
}
}
} catch (error: unknown) {
const untrustedConversationIds: Array<string> = [];
if (error instanceof OutgoingIdentityKeyError) {
const failedConversation = window.ConversationController.getOrCreate(
error.identifier,
'private'
);
strictAssert(failedConversation, 'Conversation should be created');
untrustedConversationIds.push(conversation.id);
} else if (error instanceof SendMessageProtoError) {
(error.errors || []).forEach(innerError => {
if (innerError instanceof OutgoingIdentityKeyError) {
const failedConversation =
window.ConversationController.getOrCreate(
innerError.identifier,
'private'
);
strictAssert(failedConversation, 'Conversation should be created');
untrustedConversationIds.push(conversation.id);
}
});
}
if (untrustedConversationIds.length) {
log.error(
`Send failed because ${untrustedConversationIds.length} conversation(s) were untrusted. Adding to verification list.`
);
window.reduxActions.conversations.conversationStoppedByMissingVerification(
{
conversationId: conversation.id,
untrustedConversationIds,
}
);
}
throw error;
}
}
}
export const conversationJobQueue = new ConversationJobQueue({
store: jobQueueDatabaseStore,
queueType: 'conversation',
maxAttempts: MAX_ATTEMPTS,
});

View File

@ -0,0 +1,20 @@
// Copyright 2022 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import type { ConversationAttributesType } from '../../model-types.d';
import {
SendMessageProtoError,
UnregisteredUserError,
} from '../../textsecure/Errors';
import { isGroup } from '../../util/whatTypeOfConversation';
export function areAllErrorsUnregistered(
conversation: ConversationAttributesType,
error: unknown
): boolean {
return Boolean(
isGroup(conversation) &&
error instanceof SendMessageProtoError &&
error.errors?.every(item => item instanceof UnregisteredUserError)
);
}

View File

@ -0,0 +1,14 @@
// Copyright 2022 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
export function getUntrustedConversationIds(
recipients: ReadonlyArray<string>
): Array<string> {
return recipients.filter(recipient => {
const recipientConversation = window.ConversationController.getOrCreate(
recipient,
'private'
);
return recipientConversation.isUntrusted();
});
}

View File

@ -7,19 +7,32 @@ import { sleepFor413RetryAfterTime } from './sleepFor413RetryAfterTime';
import { getHttpErrorCode } from './getHttpErrorCode';
import { strictAssert } from '../../util/assert';
import { findRetryAfterTimeFromError } from './findRetryAfterTimeFromError';
import { SendMessageProtoError } from '../../textsecure/Errors';
export function maybeExpandErrors(error: unknown): ReadonlyArray<unknown> {
if (error instanceof SendMessageProtoError) {
return error.errors || [error];
}
return [error];
}
// Note: toThrow is very important to preserve the full error for outer handlers. For
// example, the catch handler check for Safety Number Errors in conversationJobQueue.
export async function handleMultipleSendErrors({
errors,
isFinalAttempt,
log,
markFailed,
timeRemaining,
toThrow,
}: Readonly<{
errors: ReadonlyArray<unknown>;
isFinalAttempt: boolean;
log: Pick<LoggerType, 'info'>;
markFailed?: (() => void) | (() => Promise<void>);
timeRemaining: number;
toThrow: unknown;
}>): Promise<void> {
strictAssert(errors.length, 'Expected at least one error');
@ -66,5 +79,5 @@ export async function handleMultipleSendErrors({
});
}
throw errors[0];
throw toThrow;
}

View File

@ -0,0 +1,149 @@
// Copyright 2022 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import { isNumber } from 'lodash';
import { getSendOptions } from '../../util/getSendOptions';
import {
isDirectConversation,
isGroupV2,
} from '../../util/whatTypeOfConversation';
import { SignalService as Proto } from '../../protobuf';
import {
handleMultipleSendErrors,
maybeExpandErrors,
} from './handleMultipleSendErrors';
import { ourProfileKeyService } from '../../services/ourProfileKey';
import { wrapWithSyncMessageSend } from '../../util/wrapWithSyncMessageSend';
import type { ConversationModel } from '../../models/conversations';
import type {
ConversationQueueJobBundle,
DeleteForEveryoneJobData,
} from '../conversationJobQueue';
import { getUntrustedConversationIds } from './getUntrustedConversationIds';
// Note: because we don't have a recipient map, if some sends fail, we will resend this
// message to folks that got it on the first go-round. This is okay, because a delete
// for everyone has no effect when applied the second time on a message.
export async function sendDeleteForEveryone(
conversation: ConversationModel,
{
isFinalAttempt,
shouldContinue,
timestamp,
timeRemaining,
log,
}: ConversationQueueJobBundle,
data: DeleteForEveryoneJobData
): Promise<void> {
if (!shouldContinue) {
log.info('Ran out of time. Giving up on sending delete for everyone');
return;
}
const { messageId, recipients, revision, targetTimestamp } = data;
const sendType = 'deleteForEveryone';
const { ContentHint } = Proto.UnidentifiedSenderMessage.Message;
const contentHint = ContentHint.RESENDABLE;
const messageIds = [messageId];
const logId = `deleteForEveryone/${conversation.idForLogging()}`;
const untrustedConversationIds = getUntrustedConversationIds(recipients);
if (untrustedConversationIds.length) {
window.reduxActions.conversations.conversationStoppedByMissingVerification({
conversationId: conversation.id,
untrustedConversationIds,
});
throw new Error(
`Delete for everyone blocked because ${untrustedConversationIds.length} conversation(s) were untrusted. Failing this attempt.`
);
}
await conversation.queueJob(
'conversationQueue/sendDeleteForEveryone',
async () => {
log.info(
`Sending deleteForEveryone to conversation ${logId}`,
`with timestamp ${timestamp}`,
`for message ${targetTimestamp}`
);
let profileKey: Uint8Array | undefined;
if (conversation.get('profileSharing')) {
profileKey = await ourProfileKeyService.get();
}
const sendOptions = await getSendOptions(conversation.attributes);
try {
if (isDirectConversation(conversation.attributes)) {
await wrapWithSyncMessageSend({
conversation,
logId,
messageIds,
send: async sender =>
sender.sendMessageToIdentifier({
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
identifier: conversation.getSendTarget()!,
messageText: undefined,
attachments: [],
deletedForEveryoneTimestamp: targetTimestamp,
timestamp,
expireTimer: undefined,
contentHint,
groupId: undefined,
profileKey,
options: sendOptions,
}),
sendType,
timestamp,
});
} else {
if (isGroupV2(conversation.attributes) && !isNumber(revision)) {
log.error('No revision provided, but conversation is GroupV2');
}
const groupV2Info = conversation.getGroupV2Info({
members: recipients,
});
if (groupV2Info && isNumber(revision)) {
groupV2Info.revision = revision;
}
await wrapWithSyncMessageSend({
conversation,
logId,
messageIds,
send: async () =>
window.Signal.Util.sendToGroup({
contentHint,
groupSendOptions: {
groupV1: conversation.getGroupV1Info(recipients),
groupV2: groupV2Info,
deletedForEveryoneTimestamp: targetTimestamp,
timestamp,
profileKey,
},
messageId,
sendOptions,
sendTarget: conversation.toSenderKeyTarget(),
sendType: 'deleteForEveryone',
}),
sendType,
timestamp,
});
}
} catch (error: unknown) {
await handleMultipleSendErrors({
errors: maybeExpandErrors(error),
isFinalAttempt,
log,
timeRemaining,
toThrow: error,
});
}
}
);
}

View File

@ -0,0 +1,125 @@
// Copyright 2022 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import { getSendOptions } from '../../util/getSendOptions';
import { isDirectConversation, isMe } from '../../util/whatTypeOfConversation';
import { SignalService as Proto } from '../../protobuf';
import {
handleMultipleSendErrors,
maybeExpandErrors,
} from './handleMultipleSendErrors';
import { wrapWithSyncMessageSend } from '../../util/wrapWithSyncMessageSend';
import { ourProfileKeyService } from '../../services/ourProfileKey';
import type { ConversationModel } from '../../models/conversations';
import type {
ExpirationTimerUpdateJobData,
ConversationQueueJobBundle,
} from '../conversationJobQueue';
export async function sendDirectExpirationTimerUpdate(
conversation: ConversationModel,
{
isFinalAttempt,
shouldContinue,
timeRemaining,
timestamp,
log,
}: ConversationQueueJobBundle,
data: ExpirationTimerUpdateJobData
): Promise<void> {
if (!shouldContinue) {
log.info('Ran out of time. Giving up on sending expiration timer update');
return;
}
if (!isDirectConversation(conversation.attributes)) {
log.error(
`Conversation ${conversation.idForLogging()} is not a 1:1 conversation; cancelling expiration timer job.`
);
return;
}
if (conversation.isUntrusted()) {
window.reduxActions.conversations.conversationStoppedByMissingVerification({
conversationId: conversation.id,
untrustedConversationIds: [conversation.id],
});
throw new Error(
'Expiration timer send blocked because conversation is untrusted. Failing this attempt.'
);
}
log.info(
`Starting expiration timer update for ${conversation.idForLogging()} with timestamp ${timestamp}`
);
const { expireTimer } = data;
const sendOptions = await getSendOptions(conversation.attributes);
let profileKey: Uint8Array | undefined;
if (conversation.get('profileSharing')) {
profileKey = await ourProfileKeyService.get();
}
const { ContentHint } = Proto.UnidentifiedSenderMessage.Message;
const contentHint = ContentHint.RESENDABLE;
const sendType = 'expirationTimerUpdate';
const flags = Proto.DataMessage.Flags.EXPIRATION_TIMER_UPDATE;
const proto = await window.textsecure.messaging.getContentMessage({
expireTimer,
flags,
profileKey,
recipients: conversation.getRecipients(),
timestamp,
});
if (!proto.dataMessage) {
log.error(
"ContentMessage proto didn't have a data message; cancelling job."
);
return;
}
const logId = `expirationTimerUdate/${conversation.idForLogging()}`;
try {
if (isMe(conversation.attributes)) {
await window.textsecure.messaging.sendSyncMessage({
encodedDataMessage: Proto.DataMessage.encode(
proto.dataMessage
).finish(),
destination: conversation.get('e164'),
destinationUuid: conversation.get('uuid'),
expirationStartTimestamp: null,
options: sendOptions,
timestamp,
});
} else if (isDirectConversation(conversation.attributes)) {
await wrapWithSyncMessageSend({
conversation,
logId,
messageIds: [],
send: async sender =>
sender.sendIndividualProto({
contentHint,
identifier: conversation.getSendTarget(),
options: sendOptions,
proto,
timestamp,
}),
sendType,
timestamp,
});
}
} catch (error: unknown) {
await handleMultipleSendErrors({
errors: maybeExpandErrors(error),
isFinalAttempt,
log,
timeRemaining,
toThrow: error,
});
}
}

View File

@ -0,0 +1,124 @@
// Copyright 2022 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import { getSendOptionsForRecipients } from '../../util/getSendOptions';
import { isGroupV2 } from '../../util/whatTypeOfConversation';
import { SignalService as Proto } from '../../protobuf';
import {
handleMultipleSendErrors,
maybeExpandErrors,
} from './handleMultipleSendErrors';
import { wrapWithSyncMessageSend } from '../../util/wrapWithSyncMessageSend';
import * as Bytes from '../../Bytes';
import { strictAssert } from '../../util/assert';
import { ourProfileKeyService } from '../../services/ourProfileKey';
import type { ConversationModel } from '../../models/conversations';
import type { GroupV2InfoType } from '../../textsecure/SendMessage';
import type {
GroupUpdateJobData,
ConversationQueueJobBundle,
} from '../conversationJobQueue';
import { getUntrustedConversationIds } from './getUntrustedConversationIds';
// Note: because we don't have a recipient map, if some sends fail, we will resend this
// message to folks that got it on the first go-round. This is okay, because receivers
// will drop this as an empty message if they already know about its revision.
export async function sendGroupUpdate(
conversation: ConversationModel,
{
isFinalAttempt,
shouldContinue,
timeRemaining,
timestamp,
log,
}: ConversationQueueJobBundle,
data: GroupUpdateJobData
): Promise<void> {
if (!shouldContinue) {
log.info('Ran out of time. Giving up on sending group update');
return;
}
if (!isGroupV2(conversation.attributes)) {
log.error(
`Conversation ${conversation.idForLogging()} is not GroupV2, cannot send group update!`
);
return;
}
log.info(
`Starting group update for ${conversation.idForLogging()} with timestamp ${timestamp}`
);
const { groupChangeBase64, recipients, revision } = data;
const untrustedConversationIds = getUntrustedConversationIds(recipients);
if (untrustedConversationIds.length) {
window.reduxActions.conversations.conversationStoppedByMissingVerification({
conversationId: conversation.id,
untrustedConversationIds,
});
throw new Error(
`Delete for everyone blocked because ${untrustedConversationIds.length} conversation(s) were untrusted. Failing this attempt.`
);
}
const sendOptions = await getSendOptionsForRecipients(recipients);
const { ContentHint } = Proto.UnidentifiedSenderMessage.Message;
const contentHint = ContentHint.RESENDABLE;
const sendType = 'groupChange';
const logId = `sendGroupUpdate/${conversation.idForLogging()}`;
const groupChange = groupChangeBase64
? Bytes.fromBase64(groupChangeBase64)
: undefined;
let profileKey: Uint8Array | undefined;
if (conversation.get('profileSharing')) {
profileKey = await ourProfileKeyService.get();
}
const groupV2Info = conversation.getGroupV2Info();
strictAssert(groupV2Info, 'groupV2Info missing');
const groupV2: GroupV2InfoType = {
...groupV2Info,
revision,
members: recipients,
groupChange,
};
try {
await conversation.queueJob('conversationQueue/sendGroupUpdate', async () =>
wrapWithSyncMessageSend({
conversation,
logId,
messageIds: [],
send: async () =>
window.Signal.Util.sendToGroup({
groupSendOptions: {
groupV2,
timestamp,
profileKey,
},
contentHint,
messageId: undefined,
sendOptions,
sendTarget: conversation.toSenderKeyTarget(),
sendType,
}),
sendType,
timestamp,
})
);
} catch (error: unknown) {
await handleMultipleSendErrors({
errors: maybeExpandErrors(error),
isFinalAttempt,
log,
timeRemaining,
toThrow: error,
});
}
}

View File

@ -0,0 +1,446 @@
// Copyright 2022 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import { isNumber } from 'lodash';
import * as Errors from '../../types/errors';
import type { MessageModel } from '../../models/messages';
import { getMessageById } from '../../messages/getMessageById';
import type { ConversationModel } from '../../models/conversations';
import { isGroupV2, 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 type { AttachmentType } from '../../textsecure/SendMessage';
import type { LinkPreviewType } from '../../types/message/LinkPreviews';
import type { BodyRangesType } from '../../types/Util';
import type { WhatIsThis } from '../../window.d';
import type { LoggerType } from '../../types/Logging';
import type {
ConversationQueueJobBundle,
NormalMessageSendJobData,
} from '../conversationJobQueue';
import { handleMultipleSendErrors } from './handleMultipleSendErrors';
import { ourProfileKeyService } from '../../services/ourProfileKey';
export async function sendNormalMessage(
conversation: ConversationModel,
{
isFinalAttempt,
shouldContinue,
timeRemaining,
log,
}: ConversationQueueJobBundle,
data: NormalMessageSendJobData
): Promise<void> {
const { Message } = window.Signal.Types;
const { messageId, revision } = data;
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;
}
const messageConversation = message.getConversation();
if (messageConversation !== conversation) {
log.error(
`Message conversation '${messageConversation?.idForLogging()}' does not match job conversation ${conversation.idForLogging()}`
);
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;
}
let profileKey: Uint8Array | undefined;
if (conversation.get('profileSharing')) {
profileKey = await ourProfileKeyService.get();
}
let originalError: Error | undefined;
try {
const {
allRecipientIdentifiers,
recipientIdentifiersWithoutMe,
untrustedConversationIds,
} = getMessageRecipients({
message,
conversation,
});
if (untrustedConversationIds.length) {
window.reduxActions.conversations.conversationStoppedByMissingVerification(
{
conversationId: conversation.id,
untrustedConversationIds,
}
);
throw new Error(
`Message ${messageId} sending blocked because ${untrustedConversationIds.length} conversation(s) were untrusted. Failing this attempt.`
);
}
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,
quote,
sticker,
} = await getMessageSendData({ log, message });
let messageSendPromise: Promise<CallbackResultType | void>;
if (recipientIdentifiersWithoutMe.length === 0) {
log.info('sending sync message only');
const dataMessage = await window.textsecure.messaging.getDataMessage({
attachments,
body,
groupV2: conversation.getGroupV2Info({
members: recipientIdentifiersWithoutMe,
}),
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) {
// Note: this will happen for all old jobs queued beore 5.32.x
if (isGroupV2(conversation.attributes) && !isNumber(revision)) {
log.error('No revision provided, but conversation is GroupV2');
}
const groupV2Info = conversation.getGroupV2Info({
members: recipientIdentifiersWithoutMe,
});
if (groupV2Info && isNumber(revision)) {
groupV2Info.revision = revision;
}
log.info('sending group message');
innerPromise = conversation.queueJob(
'conversationQueue/sendNormalMessage',
() =>
window.Signal.Util.sendToGroup({
contentHint: ContentHint.RESENDABLE,
groupSendOptions: {
attachments,
deletedForEveryoneTimestamp,
expireTimer,
groupV1: conversation.getGroupV1Info(
recipientIdentifiersWithoutMe
),
groupV2: groupV2Info,
messageText: body,
preview,
profileKey,
quote,
sticker,
timestamp: messageTimestamp,
mentions,
},
messageId,
sendOptions,
sendTarget: conversation.toSenderKeyTarget(),
sendType: 'message',
})
);
} else {
log.info('sending direct message');
innerPromise = window.textsecure.messaging.sendMessageToIdentifier({
identifier: recipientIdentifiersWithoutMe[0],
messageText: body,
attachments,
quote,
preview,
sticker,
reaction: undefined,
deletedForEveryoneTimestamp,
timestamp: messageTimestamp,
expireTimer,
contentHint: ContentHint.RESENDABLE,
groupId: undefined,
profileKey,
options: sendOptions,
});
}
messageSendPromise = message.send(
handleMessageSend(innerPromise, {
messageIds: [messageId],
sendType: 'message',
}),
saveErrors
);
// Because message.send swallows and processes errors, we'll await the inner promise
// to get the SendMessageProtoError, which gives us information upstream
// processors need to detect certain kinds of situations.
try {
await innerPromise;
} catch (error) {
if (error instanceof Error) {
originalError = error;
} else {
log.error(
`promiseForError threw something other than an error: ${Errors.toLogFormat(
error
)}`
);
}
}
}
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 (thrownError: unknown) {
const errors = [thrownError, ...messageSendErrors];
await handleMultipleSendErrors({
errors,
isFinalAttempt,
log,
markFailed: () => markMessageFailed(message, messageSendErrors),
timeRemaining,
// In the case of a failed group send thrownError will not be SentMessageProtoError,
// but we should have been able to harvest the original error. In the Note to Self
// send case, thrownError will be the error we care about, and we won't have an
// originalError.
toThrow: originalError || thrownError,
});
}
}
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({
log,
message,
}: Readonly<{
log: LoggerType;
message: MessageModel;
}>): Promise<{
attachments: Array<AttachmentType>;
body: undefined | string;
deletedForEveryoneTimestamp: undefined | number;
expireTimer: undefined | number;
mentions: undefined | BodyRangesType;
messageTimestamp: number;
preview: Array<LinkPreviewType>;
quote: WhatIsThis;
sticker: WhatIsThis;
}> {
const {
loadAttachmentData,
loadPreviewData,
loadQuoteData,
loadStickerData,
} = window.Signal.Migrations;
let messageTimestamp: number;
const sentAt = message.get('sent_at');
const timestamp = message.get('timestamp');
if (sentAt) {
messageTimestamp = sentAt;
} else if (timestamp) {
log.error('message lacked sent_at. Falling back to timestamp');
messageTimestamp = timestamp;
} else {
log.error(
'message lacked sent_at and timestamp. Falling back to current time'
);
messageTimestamp = Date.now();
}
const [attachmentsWithData, preview, quote, sticker] = 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')),
]);
const { body, attachments } = window.Whisper.Message.getLongMessageAttachment(
{
body: message.get('body'),
attachments: attachmentsWithData,
now: messageTimestamp,
}
);
return {
attachments,
body,
deletedForEveryoneTimestamp: message.get('deletedForEveryoneTimestamp'),
expireTimer: message.get('expireTimer'),
mentions: message.get('bodyRanges'),
messageTimestamp,
preview,
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, {
ourUuid: window.textsecure.storage.user.getCheckedUuid().toString(),
});
}
function didSendToEveryone(message: Readonly<MessageModel>): boolean {
const sendStateByConversationId =
message.get('sendStateByConversationId') || {};
return Object.values(sendStateByConversationId).every(sendState =>
isSent(sendState.status)
);
}

View File

@ -0,0 +1,146 @@
// Copyright 2022 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import { isNumber } from 'lodash';
import { handleMessageSend } from '../../util/handleMessageSend';
import { getSendOptions } from '../../util/getSendOptions';
import {
isDirectConversation,
isGroupV2,
} from '../../util/whatTypeOfConversation';
import { SignalService as Proto } from '../../protobuf';
import {
handleMultipleSendErrors,
maybeExpandErrors,
} from './handleMultipleSendErrors';
import { ourProfileKeyService } from '../../services/ourProfileKey';
import type { ConversationModel } from '../../models/conversations';
import type {
ConversationQueueJobBundle,
ProfileKeyJobData,
} from '../conversationJobQueue';
import type { CallbackResultType } from '../../textsecure/Types.d';
import { getUntrustedConversationIds } from './getUntrustedConversationIds';
import { areAllErrorsUnregistered } from './areAllErrorsUnregistered';
// Note: because we don't have a recipient map, we will resend this message to folks that
// got it on the first go-round, if some sends fail. This is okay, because a recipient
// getting your profileKey again is just fine.
export async function sendProfileKey(
conversation: ConversationModel,
{
isFinalAttempt,
shouldContinue,
timestamp,
timeRemaining,
log,
}: ConversationQueueJobBundle,
data: ProfileKeyJobData
): Promise<void> {
if (!shouldContinue) {
log.info('Ran out of time. Giving up on sending profile key');
return;
}
if (!conversation.get('profileSharing')) {
log.info('No longer sharing profile. Cancelling job.');
return;
}
const profileKey = await ourProfileKeyService.get();
if (!profileKey) {
log.info('Unable to fetch profile. Cancelling job.');
return;
}
log.info(
`starting profile key share to ${conversation.idForLogging()} with timestamp ${timestamp}`
);
const { revision } = data;
const sendOptions = await getSendOptions(conversation.attributes);
const { ContentHint } = Proto.UnidentifiedSenderMessage.Message;
const contentHint = ContentHint.RESENDABLE;
const sendType = 'profileKeyUpdate';
let sendPromise: Promise<CallbackResultType>;
// Note: flags and the profileKey itself are all that matter in the proto.
const untrustedConversationIds = getUntrustedConversationIds(
conversation.getRecipients()
);
if (untrustedConversationIds.length) {
window.reduxActions.conversations.conversationStoppedByMissingVerification({
conversationId: conversation.id,
untrustedConversationIds,
});
throw new Error(
`Profile key send blocked because ${untrustedConversationIds.length} conversation(s) were untrusted. Failing this attempt.`
);
}
if (isDirectConversation(conversation.attributes)) {
const proto = await window.textsecure.messaging.getContentMessage({
flags: Proto.DataMessage.Flags.PROFILE_KEY_UPDATE,
profileKey,
recipients: conversation.getRecipients(),
timestamp,
});
sendPromise = window.textsecure.messaging.sendIndividualProto({
contentHint,
identifier: conversation.getSendTarget(),
options: sendOptions,
proto,
timestamp,
});
} else {
if (isGroupV2(conversation.attributes) && !isNumber(revision)) {
log.error('No revision provided, but conversation is GroupV2');
}
const groupV2Info = conversation.getGroupV2Info();
if (groupV2Info && isNumber(revision)) {
groupV2Info.revision = revision;
}
sendPromise = window.Signal.Util.sendToGroup({
contentHint,
groupSendOptions: {
flags: Proto.DataMessage.Flags.PROFILE_KEY_UPDATE,
groupV1: conversation.getGroupV1Info(),
groupV2: groupV2Info,
profileKey,
timestamp,
},
messageId: undefined,
sendOptions,
sendTarget: conversation.toSenderKeyTarget(),
sendType,
});
}
try {
await handleMessageSend(sendPromise, {
messageIds: [],
sendType,
});
} catch (error: unknown) {
if (areAllErrorsUnregistered(conversation.attributes, error)) {
log.info(
'Group send failures were all UnregisteredUserError, returning succcessfully.'
);
return;
}
await handleMultipleSendErrors({
errors: maybeExpandErrors(error),
isFinalAttempt,
log,
timeRemaining,
toThrow: error,
});
}
}

View File

@ -0,0 +1,377 @@
// Copyright 2021-2022 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import { isNumber } from 'lodash';
import * as Errors from '../../types/errors';
import { repeat, zipObject } from '../../util/iterables';
import type { CallbackResultType } from '../../textsecure/Types.d';
import type { MessageModel } from '../../models/messages';
import type { MessageReactionType } from '../../model-types.d';
import type { ConversationModel } from '../../models/conversations';
import * as reactionUtil from '../../reactions/util';
import { isSent, SendStatus } from '../../messages/MessageSendState';
import { getMessageById } from '../../messages/getMessageById';
import {
isMe,
isDirectConversation,
isGroupV2,
} from '../../util/whatTypeOfConversation';
import { getSendOptions } from '../../util/getSendOptions';
import { SignalService as Proto } from '../../protobuf';
import { handleMessageSend } from '../../util/handleMessageSend';
import { ourProfileKeyService } from '../../services/ourProfileKey';
import { canReact } from '../../state/selectors/message';
import { findAndFormatContact } from '../../util/findAndFormatContact';
import { UUID } from '../../types/UUID';
import { handleMultipleSendErrors } from './handleMultipleSendErrors';
import type {
ConversationQueueJobBundle,
ReactionJobData,
} from '../conversationJobQueue';
export async function sendReaction(
conversation: ConversationModel,
{
isFinalAttempt,
shouldContinue,
timeRemaining,
log,
}: ConversationQueueJobBundle,
data: ReactionJobData
): Promise<void> {
const { messageId, revision } = data;
const ourUuid = window.textsecure.storage.user.getCheckedUuid().toString();
await window.ConversationController.load();
const ourConversationId =
window.ConversationController.getOurConversationIdOrThrow();
const message = await getMessageById(messageId);
if (!message) {
log.info(
`message ${messageId} was not found, maybe because it was deleted. Giving up on sending its reactions`
);
return;
}
const { pendingReaction, emojiToRemove } =
reactionUtil.getNewestPendingOutgoingReaction(
getReactions(message),
ourConversationId
);
if (!pendingReaction) {
log.info(`no pending reaction for ${messageId}. Doing nothing`);
return;
}
if (!canReact(message.attributes, ourConversationId, findAndFormatContact)) {
log.info(`could not react to ${messageId}. Removing this pending reaction`);
markReactionFailed(message, pendingReaction);
await window.Signal.Data.saveMessage(message.attributes, { ourUuid });
return;
}
if (!shouldContinue) {
log.info(
`reacting to message ${messageId} ran out of time. Giving up on sending it`
);
markReactionFailed(message, pendingReaction);
await window.Signal.Data.saveMessage(message.attributes, { ourUuid });
return;
}
let sendErrors: Array<Error> = [];
const saveErrors = (errors: Array<Error>): void => {
sendErrors = errors;
};
let originalError: Error | undefined;
try {
const messageConversation = message.getConversation();
if (messageConversation !== conversation) {
log.error(
`message conversation '${messageConversation?.idForLogging()}' does not match job conversation ${conversation.idForLogging()}`
);
return;
}
const {
allRecipientIdentifiers,
recipientIdentifiersWithoutMe,
untrustedConversationIds,
} = getRecipients(pendingReaction, conversation);
if (untrustedConversationIds.length) {
window.reduxActions.conversations.conversationStoppedByMissingVerification(
{
conversationId: conversation.id,
untrustedConversationIds,
}
);
throw new Error(
`Reaction for message ${messageId} sending blocked because ${untrustedConversationIds.length} conversation(s) were untrusted. Failing this attempt.`
);
}
const expireTimer = message.get('expireTimer');
const profileKey = conversation.get('profileSharing')
? await ourProfileKeyService.get()
: undefined;
const reactionForSend = pendingReaction.emoji
? pendingReaction
: {
...pendingReaction,
emoji: emojiToRemove,
remove: true,
};
const ephemeralMessageForReactionSend = new window.Whisper.Message({
id: UUID.generate.toString(),
type: 'outgoing',
conversationId: conversation.get('id'),
sent_at: pendingReaction.timestamp,
received_at: window.Signal.Util.incrementMessageCounter(),
received_at_ms: pendingReaction.timestamp,
reaction: reactionForSend,
timestamp: pendingReaction.timestamp,
sendStateByConversationId: zipObject(
Object.keys(pendingReaction.isSentByConversationId || {}),
repeat({
status: SendStatus.Pending,
updatedAt: Date.now(),
})
),
});
ephemeralMessageForReactionSend.doNotSave = true;
let didFullySend: boolean;
const successfulConversationIds = new Set<string>();
if (recipientIdentifiersWithoutMe.length === 0) {
log.info('sending sync reaction message only');
const dataMessage = await window.textsecure.messaging.getDataMessage({
attachments: [],
expireTimer,
groupV2: conversation.getGroupV2Info({
members: recipientIdentifiersWithoutMe,
}),
preview: [],
profileKey,
reaction: reactionForSend,
recipients: allRecipientIdentifiers,
timestamp: pendingReaction.timestamp,
});
await ephemeralMessageForReactionSend.sendSyncMessageOnly(
dataMessage,
saveErrors
);
didFullySend = true;
successfulConversationIds.add(ourConversationId);
} else {
const sendOptions = await getSendOptions(conversation.attributes);
const { ContentHint } = Proto.UnidentifiedSenderMessage.Message;
let promise: Promise<CallbackResultType>;
if (isDirectConversation(conversation.attributes)) {
log.info('sending direct reaction message');
promise = window.textsecure.messaging.sendMessageToIdentifier({
identifier: recipientIdentifiersWithoutMe[0],
messageText: undefined,
attachments: [],
quote: undefined,
preview: [],
sticker: undefined,
reaction: reactionForSend,
deletedForEveryoneTimestamp: undefined,
timestamp: pendingReaction.timestamp,
expireTimer,
contentHint: ContentHint.RESENDABLE,
groupId: undefined,
profileKey,
options: sendOptions,
});
} else {
log.info('sending group reaction message');
promise = conversation.queueJob(
'conversationQueue/sendReaction',
() => {
// Note: this will happen for all old jobs queued before 5.32.x
if (isGroupV2(conversation.attributes) && !isNumber(revision)) {
log.error('No revision provided, but conversation is GroupV2');
}
const groupV2Info = conversation.getGroupV2Info({
members: recipientIdentifiersWithoutMe,
});
if (groupV2Info && isNumber(revision)) {
groupV2Info.revision = revision;
}
return window.Signal.Util.sendToGroup({
contentHint: ContentHint.RESENDABLE,
groupSendOptions: {
groupV1: conversation.getGroupV1Info(
recipientIdentifiersWithoutMe
),
groupV2: groupV2Info,
reaction: reactionForSend,
timestamp: pendingReaction.timestamp,
expireTimer,
profileKey,
},
messageId,
sendOptions,
sendTarget: conversation.toSenderKeyTarget(),
sendType: 'reaction',
});
}
);
}
await ephemeralMessageForReactionSend.send(
handleMessageSend(promise, {
messageIds: [messageId],
sendType: 'reaction',
}),
saveErrors
);
// Because message.send swallows and processes errors, we'll await the inner promise
// to get the SendMessageProtoError, which gives us information upstream
/// processors need to detect certain kinds of errors.
try {
await promise;
} catch (error) {
if (error instanceof Error) {
originalError = error;
} else {
log.error(
`promise threw something other than an error: ${Errors.toLogFormat(
error
)}`
);
}
}
didFullySend = true;
const reactionSendStateByConversationId =
ephemeralMessageForReactionSend.get('sendStateByConversationId') || {};
for (const [conversationId, sendState] of Object.entries(
reactionSendStateByConversationId
)) {
if (isSent(sendState.status)) {
successfulConversationIds.add(conversationId);
} else {
didFullySend = false;
}
}
}
const newReactions = reactionUtil.markOutgoingReactionSent(
getReactions(message),
pendingReaction,
successfulConversationIds
);
setReactions(message, newReactions);
if (!didFullySend) {
throw new Error('reaction did not fully send');
}
} catch (thrownError: unknown) {
await handleMultipleSendErrors({
errors: [thrownError, ...sendErrors],
isFinalAttempt,
log,
markFailed: () => markReactionFailed(message, pendingReaction),
timeRemaining,
// In the case of a failed group send thrownError will not be SentMessageProtoError,
// but we should have been able to harvest the original error. In the Note to Self
// send case, thrownError will be the error we care about, and we won't have an
// originalError.
toThrow: originalError || thrownError,
});
} finally {
await window.Signal.Data.saveMessage(message.attributes, { ourUuid });
}
}
const getReactions = (message: MessageModel): Array<MessageReactionType> =>
message.get('reactions') || [];
const setReactions = (
message: MessageModel,
reactions: Array<MessageReactionType>
): void => {
if (reactions.length) {
message.set('reactions', reactions);
} else {
message.unset('reactions');
}
};
function getRecipients(
reaction: Readonly<MessageReactionType>,
conversation: ConversationModel
): {
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();
for (const id of reactionUtil.getUnsentConversationIds(reaction)) {
const recipient = window.ConversationController.get(id);
if (!recipient) {
continue;
}
const recipientIdentifier = recipient.getSendTarget();
const isRecipientMe = isMe(recipient.attributes);
if (
!recipientIdentifier ||
(!currentConversationRecipients.has(id) && !isRecipientMe)
) {
continue;
}
if (recipient.isUntrusted()) {
untrustedConversationIds.push(recipientIdentifier);
continue;
}
allRecipientIdentifiers.push(recipientIdentifier);
if (!isRecipientMe) {
recipientIdentifiersWithoutMe.push(recipientIdentifier);
}
}
return {
allRecipientIdentifiers,
recipientIdentifiersWithoutMe,
untrustedConversationIds,
};
}
function markReactionFailed(
message: MessageModel,
pendingReaction: MessageReactionType
): void {
const newReactions = reactionUtil.markOutgoingReactionFailed(
getReactions(message),
pendingReaction
);
setReactions(message, newReactions);
}

View File

@ -3,9 +3,8 @@
import type { WebAPIType } from '../textsecure/WebAPI';
import { conversationJobQueue } from './conversationJobQueue';
import { deliveryReceiptsJobQueue } from './deliveryReceiptsJobQueue';
import { normalMessageSendJobQueue } from './normalMessageSendJobQueue';
import { reactionJobQueue } from './reactionJobQueue';
import { readReceiptsJobQueue } from './readReceiptsJobQueue';
import { readSyncJobQueue } from './readSyncJobQueue';
import { removeStorageKeyJobQueue } from './removeStorageKeyJobQueue';
@ -26,8 +25,7 @@ export function initializeAllJobQueues({
reportSpamJobQueue.initialize({ server });
// General conversation send queue
normalMessageSendJobQueue.streamJobs();
reactionJobQueue.streamJobs();
conversationJobQueue.streamJobs();
// Single proto send queue, used for a variety of one-off simple messages
singleProtoJobQueue.streamJobs();

View File

@ -1,461 +0,0 @@
// Copyright 2021-2022 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import type PQueue from 'p-queue';
import type { LoggerType } from '../types/Logging';
import { exponentialBackoffMaxAttempts } from '../util/exponentialBackoff';
import { commonShouldJobContinue } from './helpers/commonShouldJobContinue';
import { InMemoryQueues } from './helpers/InMemoryQueues';
import type { MessageModel } from '../models/messages';
import { getMessageById } from '../messages/getMessageById';
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 type { AttachmentType } from '../textsecure/SendMessage';
import type { LinkPreviewType } from '../types/message/LinkPreviews';
import type { BodyRangesType } from '../types/Util';
import type { WhatIsThis } from '../window.d';
import { JobQueue } from './JobQueue';
import { jobQueueDatabaseStore } from './JobQueueDatabaseStore';
import { handleMultipleSendErrors } from './helpers/handleMultipleSendErrors';
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 inMemoryQueues = new InMemoryQueues();
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 };
}
protected override getInMemoryQueue({
data,
}: Readonly<{ data: NormalMessageSendJobData }>): PQueue {
return this.inMemoryQueues.get(data.conversationId);
}
protected async run(
{
data,
timestamp,
}: Readonly<{ data: NormalMessageSendJobData; timestamp: number }>,
{ attempt, log }: Readonly<{ attempt: number; log: LoggerType }>
): Promise<void> {
const { messageId } = data;
const timeRemaining = timestamp + MAX_RETRY_TIME - Date.now();
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,
timeRemaining,
});
await window.ConversationController.load();
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
);
await markMessageFailed(message, messageSendErrors);
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, log, message });
let messageSendPromise: Promise<unknown>;
if (recipientIdentifiersWithoutMe.length === 0) {
log.info('sending sync message only');
const dataMessage = await window.textsecure.messaging.getDataMessage({
attachments,
body,
groupV2: conversation.getGroupV2Info({
members: recipientIdentifiersWithoutMe,
}),
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 = conversation.queueJob(
'normalMessageSendJobQueue',
() =>
window.Signal.Util.sendToGroup({
contentHint: ContentHint.RESENDABLE,
groupSendOptions: {
attachments,
deletedForEveryoneTimestamp,
expireTimer,
groupV1: conversation.getGroupV1Info(
recipientIdentifiersWithoutMe
),
groupV2: conversation.getGroupV2Info({
members: recipientIdentifiersWithoutMe,
}),
messageText: body,
preview,
profileKey,
quote,
sticker,
timestamp: messageTimestamp,
mentions,
},
messageId,
sendOptions,
sendTarget: conversation.toSenderKeyTarget(),
sendType: 'message',
})
);
} else {
log.info('sending direct message');
innerPromise = window.textsecure.messaging.sendMessageToIdentifier({
identifier: recipientIdentifiersWithoutMe[0],
messageText: body,
attachments,
quote,
preview,
sticker,
reaction: undefined,
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 (thrownError: unknown) {
await handleMultipleSendErrors({
errors: [thrownError, ...messageSendErrors],
isFinalAttempt,
log,
markFailed: () => markMessageFailed(message, messageSendErrors),
timeRemaining,
});
}
}
}
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,
log,
message,
}: Readonly<{
conversation: ConversationModel;
log: LoggerType;
message: MessageModel;
}>): Promise<{
attachments: Array<AttachmentType>;
body: undefined | string;
deletedForEveryoneTimestamp: undefined | number;
expireTimer: undefined | number;
mentions: undefined | BodyRangesType;
messageTimestamp: number;
preview: Array<LinkPreviewType>;
profileKey: undefined | Uint8Array;
quote: WhatIsThis;
sticker: WhatIsThis;
}> {
let messageTimestamp: number;
const sentAt = message.get('sent_at');
const timestamp = message.get('timestamp');
if (sentAt) {
messageTimestamp = sentAt;
} else if (timestamp) {
log.error('message lacked sent_at. Falling back to timestamp');
messageTimestamp = timestamp;
} else {
log.error(
'message lacked sent_at and timestamp. Falling back to current time'
);
messageTimestamp = 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: message.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, {
ourUuid: window.textsecure.storage.user.getCheckedUuid().toString(),
});
}
function didSendToEveryone(message: Readonly<MessageModel>): boolean {
const sendStateByConversationId =
message.get('sendStateByConversationId') || {};
return Object.values(sendStateByConversationId).every(sendState =>
isSent(sendState.status)
);
}

View File

@ -1,350 +0,0 @@
// Copyright 2021-2022 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import * as z from 'zod';
import type PQueue from 'p-queue';
import { repeat, zipObject } from '../util/iterables';
import { exponentialBackoffMaxAttempts } from '../util/exponentialBackoff';
import * as durations from '../util/durations';
import type { LoggerType } from '../types/Logging';
import type { CallbackResultType } from '../textsecure/Types.d';
import type { MessageModel } from '../models/messages';
import type { MessageReactionType } from '../model-types.d';
import type { ConversationModel } from '../models/conversations';
import * as reactionUtil from '../reactions/util';
import { isSent, SendStatus } from '../messages/MessageSendState';
import { getMessageById } from '../messages/getMessageById';
import { isMe, isDirectConversation } from '../util/whatTypeOfConversation';
import { getSendOptions } from '../util/getSendOptions';
import { SignalService as Proto } from '../protobuf';
import { handleMessageSend } from '../util/handleMessageSend';
import { ourProfileKeyService } from '../services/ourProfileKey';
import { canReact } from '../state/selectors/message';
import { findAndFormatContact } from '../util/findAndFormatContact';
import { UUID } from '../types/UUID';
import { JobQueue } from './JobQueue';
import { jobQueueDatabaseStore } from './JobQueueDatabaseStore';
import { commonShouldJobContinue } from './helpers/commonShouldJobContinue';
import { handleMultipleSendErrors } from './helpers/handleMultipleSendErrors';
import { InMemoryQueues } from './helpers/InMemoryQueues';
const MAX_RETRY_TIME = durations.DAY;
const MAX_ATTEMPTS = exponentialBackoffMaxAttempts(MAX_RETRY_TIME);
const reactionJobData = z.object({
messageId: z.string(),
});
export type ReactionJobData = z.infer<typeof reactionJobData>;
export class ReactionJobQueue extends JobQueue<ReactionJobData> {
private readonly inMemoryQueues = new InMemoryQueues();
protected parseData(data: unknown): ReactionJobData {
return reactionJobData.parse(data);
}
protected override getInMemoryQueue({
data,
}: Readonly<{ data: Pick<ReactionJobData, 'messageId'> }>): PQueue {
return this.inMemoryQueues.get(data.messageId);
}
protected async run(
{ data, timestamp }: Readonly<{ data: ReactionJobData; timestamp: number }>,
{ attempt, log }: Readonly<{ attempt: number; log: LoggerType }>
): Promise<void> {
const { messageId } = data;
const ourUuid = window.textsecure.storage.user.getCheckedUuid().toString();
const timeRemaining = timestamp + MAX_RETRY_TIME - Date.now();
const isFinalAttempt = attempt >= MAX_ATTEMPTS;
// We don't immediately use this value because we may want to mark the reaction
// failed before doing so.
const shouldContinue = await commonShouldJobContinue({
attempt,
log,
timeRemaining,
});
await window.ConversationController.load();
const ourConversationId =
window.ConversationController.getOurConversationIdOrThrow();
const message = await getMessageById(messageId);
if (!message) {
log.info(
`message ${messageId} was not found, maybe because it was deleted. Giving up on sending its reactions`
);
return;
}
const { pendingReaction, emojiToRemove } =
reactionUtil.getNewestPendingOutgoingReaction(
getReactions(message),
ourConversationId
);
if (!pendingReaction) {
log.info(`no pending reaction for ${messageId}. Doing nothing`);
return;
}
if (
!canReact(message.attributes, ourConversationId, findAndFormatContact)
) {
log.info(
`could not react to ${messageId}. Removing this pending reaction`
);
markReactionFailed(message, pendingReaction);
await window.Signal.Data.saveMessage(message.attributes, { ourUuid });
return;
}
if (!shouldContinue) {
log.info(
`reacting to message ${messageId} ran out of time. Giving up on sending it`
);
markReactionFailed(message, pendingReaction);
await window.Signal.Data.saveMessage(message.attributes, { ourUuid });
return;
}
let sendErrors: Array<Error> = [];
const saveErrors = (errors: Array<Error>): void => {
sendErrors = errors;
};
try {
const conversation = message.getConversation();
if (!conversation) {
throw new Error(
`could not find conversation for message with ID ${messageId}`
);
}
const { allRecipientIdentifiers, recipientIdentifiersWithoutMe } =
getRecipients(pendingReaction, conversation);
const expireTimer = message.get('expireTimer');
const profileKey = conversation.get('profileSharing')
? await ourProfileKeyService.get()
: undefined;
const reactionForSend = pendingReaction.emoji
? pendingReaction
: {
...pendingReaction,
emoji: emojiToRemove,
remove: true,
};
const ephemeralMessageForReactionSend = new window.Whisper.Message({
id: UUID.generate.toString(),
type: 'outgoing',
conversationId: conversation.get('id'),
sent_at: pendingReaction.timestamp,
received_at: window.Signal.Util.incrementMessageCounter(),
received_at_ms: pendingReaction.timestamp,
reaction: reactionForSend,
timestamp: pendingReaction.timestamp,
sendStateByConversationId: zipObject(
Object.keys(pendingReaction.isSentByConversationId || {}),
repeat({
status: SendStatus.Pending,
updatedAt: Date.now(),
})
),
});
ephemeralMessageForReactionSend.doNotSave = true;
let didFullySend: boolean;
const successfulConversationIds = new Set<string>();
if (recipientIdentifiersWithoutMe.length === 0) {
log.info('sending sync reaction message only');
const dataMessage = await window.textsecure.messaging.getDataMessage({
attachments: [],
expireTimer,
groupV2: conversation.getGroupV2Info({
members: recipientIdentifiersWithoutMe,
}),
preview: [],
profileKey,
reaction: reactionForSend,
recipients: allRecipientIdentifiers,
timestamp: pendingReaction.timestamp,
});
await ephemeralMessageForReactionSend.sendSyncMessageOnly(
dataMessage,
saveErrors
);
didFullySend = true;
successfulConversationIds.add(ourConversationId);
} else {
const sendOptions = await getSendOptions(conversation.attributes);
const { ContentHint } = Proto.UnidentifiedSenderMessage.Message;
let promise: Promise<CallbackResultType>;
if (isDirectConversation(conversation.attributes)) {
log.info('sending direct reaction message');
promise = window.textsecure.messaging.sendMessageToIdentifier({
identifier: recipientIdentifiersWithoutMe[0],
messageText: undefined,
attachments: [],
quote: undefined,
preview: [],
sticker: undefined,
reaction: reactionForSend,
deletedForEveryoneTimestamp: undefined,
timestamp: pendingReaction.timestamp,
expireTimer,
contentHint: ContentHint.RESENDABLE,
groupId: undefined,
profileKey,
options: sendOptions,
});
} else {
log.info('sending group reaction message');
promise = window.Signal.Util.sendToGroup({
contentHint: ContentHint.RESENDABLE,
groupSendOptions: {
groupV1: conversation.getGroupV1Info(
recipientIdentifiersWithoutMe
),
groupV2: conversation.getGroupV2Info({
members: recipientIdentifiersWithoutMe,
}),
reaction: reactionForSend,
timestamp: pendingReaction.timestamp,
expireTimer,
profileKey,
},
messageId,
sendOptions,
sendTarget: conversation.toSenderKeyTarget(),
sendType: 'reaction',
});
}
await ephemeralMessageForReactionSend.send(
handleMessageSend(promise, {
messageIds: [messageId],
sendType: 'reaction',
}),
saveErrors
);
didFullySend = true;
const reactionSendStateByConversationId =
ephemeralMessageForReactionSend.get('sendStateByConversationId') ||
{};
for (const [conversationId, sendState] of Object.entries(
reactionSendStateByConversationId
)) {
if (isSent(sendState.status)) {
successfulConversationIds.add(conversationId);
} else {
didFullySend = false;
}
}
}
const newReactions = reactionUtil.markOutgoingReactionSent(
getReactions(message),
pendingReaction,
successfulConversationIds
);
setReactions(message, newReactions);
if (!didFullySend) {
throw new Error('reaction did not fully send');
}
} catch (thrownError: unknown) {
await handleMultipleSendErrors({
errors: [thrownError, ...sendErrors],
isFinalAttempt,
log,
markFailed: () => markReactionFailed(message, pendingReaction),
timeRemaining,
});
} finally {
await window.Signal.Data.saveMessage(message.attributes, { ourUuid });
}
}
}
export const reactionJobQueue = new ReactionJobQueue({
store: jobQueueDatabaseStore,
queueType: 'reactions',
maxAttempts: MAX_ATTEMPTS,
});
const getReactions = (message: MessageModel): Array<MessageReactionType> =>
message.get('reactions') || [];
const setReactions = (
message: MessageModel,
reactions: Array<MessageReactionType>
): void => {
if (reactions.length) {
message.set('reactions', reactions);
} else {
message.unset('reactions');
}
};
function getRecipients(
reaction: Readonly<MessageReactionType>,
conversation: ConversationModel
): {
allRecipientIdentifiers: Array<string>;
recipientIdentifiersWithoutMe: Array<string>;
} {
const allRecipientIdentifiers: Array<string> = [];
const recipientIdentifiersWithoutMe: Array<string> = [];
const currentConversationRecipients =
conversation.getRecipientConversationIds();
for (const id of reactionUtil.getUnsentConversationIds(reaction)) {
const recipient = window.ConversationController.get(id);
if (!recipient) {
continue;
}
const recipientIdentifier = recipient.getSendTarget();
const isRecipientMe = isMe(recipient.attributes);
if (
!recipientIdentifier ||
recipient.isUntrusted() ||
(!currentConversationRecipients.has(id) && !isRecipientMe)
) {
continue;
}
allRecipientIdentifiers.push(recipientIdentifier);
if (!isRecipientMe) {
recipientIdentifiersWithoutMe.push(recipientIdentifier);
}
}
return { allRecipientIdentifiers, recipientIdentifiersWithoutMe };
}
function markReactionFailed(
message: MessageModel,
pendingReaction: MessageReactionType
): void {
const newReactions = reactionUtil.markOutgoingReactionFailed(
getReactions(message),
pendingReaction
);
setReactions(message, newReactions);
}

View File

@ -16,8 +16,10 @@ import { handleMessageSend } from '../util/handleMessageSend';
import { getSendOptions } from '../util/getSendOptions';
import type { SingleProtoJobData } from '../textsecure/SendMessage';
import { singleProtoJobDataSchema } from '../textsecure/SendMessage';
import { handleMultipleSendErrors } from './helpers/handleMultipleSendErrors';
import { SendMessageProtoError } from '../textsecure/Errors';
import {
handleMultipleSendErrors,
maybeExpandErrors,
} from './helpers/handleMultipleSendErrors';
const MAX_RETRY_TIME = DAY;
const MAX_PARALLEL_JOBS = 5;
@ -91,16 +93,12 @@ export class SingleProtoJobQueue extends JobQueue<SingleProtoJobData> {
{ messageIds, sendType: type }
);
} catch (error: unknown) {
const errors =
error instanceof SendMessageProtoError
? error.errors || [error]
: [error];
await handleMultipleSendErrors({
errors,
errors: maybeExpandErrors(error),
isFinalAttempt,
log,
timeRemaining,
toThrow: error,
});
}
}

View File

@ -59,7 +59,6 @@ import { getTextWithMentions } from '../util/getTextWithMentions';
import { migrateColor } from '../util/migrateColor';
import { isNotNil } from '../util/isNotNil';
import { dropNull } from '../util/dropNull';
import { ourProfileKeyService } from '../services/ourProfileKey';
import { notificationService } from '../services/notifications';
import { getSendOptions } from '../util/getSendOptions';
import { isConversationAccepted } from '../util/isConversationAccepted';
@ -94,7 +93,10 @@ import {
isTapToView,
getMessagePropStatus,
} from '../state/selectors/message';
import { normalMessageSendJobQueue } from '../jobs/normalMessageSendJobQueue';
import {
conversationJobQueue,
conversationQueueJobEnum,
} from '../jobs/conversationJobQueue';
import { readReceiptsJobQueue } from '../jobs/readReceiptsJobQueue';
import { Deletes } from '../messageModifiers/Deletes';
import type { ReactionModel } from '../messageModifiers/Reactions';
@ -867,10 +869,7 @@ export class ConversationModel extends window.Backbone
}
isGroupV1AndDisabled(): boolean {
return (
isGroupV1(this.attributes) &&
window.Signal.RemoteConfig.isEnabled('desktop.disableGV1')
);
return isGroupV1(this.attributes);
}
isBlocked(): boolean {
@ -2453,29 +2452,6 @@ export class ConversationModel extends window.Backbone
}
}
// We only want to throw if there's a 'real' error contained with this information
// coming back from our low-level send infrastructure.
processSendResponse(
result: Error | CallbackResultType
): result is CallbackResultType {
if (result instanceof Error) {
throw result;
} else if (result && result.errors) {
// We filter out unregistered user errors, because we ignore those in groups
const wasThereARealError = window._.some(
result.errors,
error => error.name !== 'UnregisteredUserError'
);
if (wasThereARealError) {
throw result;
}
return true;
}
return true;
}
async safeGetVerified(): Promise<number> {
const uuid = this.getUuid();
if (!uuid) {
@ -3529,6 +3505,11 @@ export class ConversationModel extends window.Backbone
includePendingMembers?: boolean;
extraConversationsForSend?: Array<string>;
} = {}): Array<string> {
if (isDirectConversation(this.attributes)) {
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
return [this.getSendTarget()!];
}
const members = this.getMembers({ includePendingMembers });
// There are cases where we need to send to someone we just removed from the group, to
@ -3723,145 +3704,55 @@ export class ConversationModel extends window.Backbone
throw new Error('Cannot send DOE for a message older than three hours');
}
try {
await conversationJobQueue.add({
type: conversationQueueJobEnum.enum.DeleteForEveryone,
conversationId: this.id,
messageId,
recipients: this.getRecipients(),
revision: this.get('revision'),
targetTimestamp,
});
} catch (error) {
log.error(
'sendDeleteForEveryoneMessage: Failed to queue delete for everyone',
Errors.toLogFormat(error)
);
throw error;
}
const deleteModel = Deletes.getSingleton().add({
targetSentTimestamp: targetTimestamp,
fromId: window.ConversationController.getOurConversationId(),
});
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
const destination = this.getSendTarget()!;
await this.queueJob('sendDeleteForEveryone', async () => {
log.info(
'Sending deleteForEveryone to conversation',
this.idForLogging(),
'with timestamp',
timestamp
);
// We are only creating this model so we can use its sync message
// sending functionality. It will not be saved to the database.
const message = new window.Whisper.Message({
id: UUID.generate().toString(),
type: 'outgoing',
conversationId: this.get('id'),
sent_at: timestamp,
received_at: window.Signal.Util.incrementMessageCounter(),
received_at_ms: timestamp,
deletedForEveryoneTimestamp: targetTimestamp,
timestamp,
});
// We're offline!
if (!window.textsecure.messaging) {
throw new Error('Cannot send DOE while offline!');
}
const sendOptions = await getSendOptions(this.attributes);
const promise = (async () => {
let profileKey: Uint8Array | undefined;
if (this.get('profileSharing')) {
profileKey = await ourProfileKeyService.get();
}
const { ContentHint } = Proto.UnidentifiedSenderMessage.Message;
if (isDirectConversation(this.attributes)) {
return window.textsecure.messaging.sendMessageToIdentifier({
identifier: destination,
messageText: undefined,
attachments: [],
quote: undefined,
preview: [],
sticker: undefined,
reaction: undefined,
deletedForEveryoneTimestamp: targetTimestamp,
timestamp,
expireTimer: undefined,
contentHint: ContentHint.RESENDABLE,
groupId: undefined,
profileKey,
options: sendOptions,
});
}
return window.Signal.Util.sendToGroup({
contentHint: ContentHint.RESENDABLE,
groupSendOptions: {
groupV1: this.getGroupV1Info(),
groupV2: this.getGroupV2Info(),
deletedForEveryoneTimestamp: targetTimestamp,
timestamp,
profileKey,
},
messageId,
sendOptions,
sendTarget: this.toSenderKeyTarget(),
sendType: 'deleteForEveryone',
});
})();
// This is to ensure that the functions in send() and sendSyncMessage() don't save
// anything to the database.
message.doNotSave = true;
const result = await message.send(
handleMessageSend(promise, {
messageIds: [messageId],
sendType: 'deleteForEveryone',
})
);
if (!message.hasSuccessfulDelivery()) {
// This is handled by `conversation_view` which displays a toast on
// send error.
throw new Error('No successful delivery for delete for everyone');
}
Deletes.getSingleton().onDelete(deleteModel);
return result;
}).catch(error => {
log.error(
'Error sending deleteForEveryone',
deleteModel,
targetTimestamp,
error && error.stack
);
throw error;
});
Deletes.getSingleton().onDelete(deleteModel);
}
async sendProfileKeyUpdate(): Promise<void> {
const id = this.get('id');
const recipients = this.getRecipients();
if (!this.get('profileSharing')) {
log.error(
'Attempted to send profileKeyUpdate to conversation without profileSharing enabled',
id,
recipients
);
if (isMe(this.attributes)) {
return;
}
log.info('Sending profileKeyUpdate to conversation', id, recipients);
const profileKey = await ourProfileKeyService.get();
if (!profileKey) {
if (!this.get('profileSharing')) {
log.error(
'Attempted to send profileKeyUpdate but our profile key was not found'
'sendProfileKeyUpdate: profileSharing not enabled for conversation',
this.idForLogging()
);
return;
}
await handleMessageSend(
window.textsecure.messaging.sendProfileKeyUpdate(
profileKey,
recipients,
await getSendOptions(this.attributes),
this.get('groupId')
),
{ messageIds: [], sendType: 'profileKeyUpdate' }
);
try {
await conversationJobQueue.add({
type: conversationQueueJobEnum.enum.ProfileKey,
conversationId: this.id,
revision: this.get('revision'),
});
} catch (error) {
log.error(
'sendProfileKeyUpdate: Failed to queue profile share',
Errors.toLogFormat(error)
);
}
}
async enqueueMessageForSend(
@ -3979,8 +3870,13 @@ export class ConversationModel extends window.Backbone
'Expected a timestamp'
);
await normalMessageSendJobQueue.add(
{ messageId: message.id, conversationId: this.id },
await conversationJobQueue.add(
{
type: conversationQueueJobEnum.enum.NormalMessage,
conversationId: this.id,
messageId: message.id,
revision: this.get('revision'),
},
async jobToInsert => {
log.info(
`enqueueMessageForSend: saving message ${message.id} and job ${jobToInsert.id}`
@ -4374,6 +4270,12 @@ export class ConversationModel extends window.Backbone
return false;
}
if (this.isGroupV1AndDisabled()) {
throw new Error(
'updateExpirationTimer: GroupV1 is deprecated; cannot update expiration timer'
);
}
let expireTimer: number | undefined = providedExpireTimer;
let source = providedSource;
if (this.get('left')) {
@ -4398,6 +4300,23 @@ export class ConversationModel extends window.Backbone
source,
});
// if change wasn't made remotely, send it to the number/group
if (!receivedAt) {
try {
await conversationJobQueue.add({
type: conversationQueueJobEnum.enum.DirectExpirationTimerUpdate,
conversationId: this.id,
expireTimer,
});
} catch (error) {
log.error(
'updateExpirationTimer: Failed to queue expiration timer update',
Errors.toLogFormat(error)
);
throw error;
}
}
source = source || window.ConversationController.getOurConversationId();
// When we add a disappearing messages notification to the conversation, we want it
@ -4440,69 +4359,6 @@ export class ConversationModel extends window.Backbone
const message = window.MessageController.register(id, model);
this.addSingleMessage(message);
// if change was made remotely, don't send it to the number/group
if (receivedAt) {
return message;
}
const sendOptions = await getSendOptions(this.attributes);
let profileKey;
if (this.get('profileSharing')) {
profileKey = await ourProfileKeyService.get();
}
let promise;
if (isMe(this.attributes)) {
const flags = Proto.DataMessage.Flags.EXPIRATION_TIMER_UPDATE;
const dataMessage = await window.textsecure.messaging.getDataMessage({
attachments: [],
// body
// deletedForEveryoneTimestamp
expireTimer,
flags,
preview: [],
profileKey,
// quote
// reaction
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
recipients: [this.getSendTarget()!],
// sticker
timestamp: message.get('sent_at'),
});
return message.sendSyncMessageOnly(dataMessage);
}
if (isDirectConversation(this.attributes)) {
promise =
window.textsecure.messaging.sendExpirationTimerUpdateToIdentifier(
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
this.getSendTarget()!,
expireTimer,
message.get('sent_at'),
profileKey,
sendOptions
);
} else {
promise = window.textsecure.messaging.sendExpirationTimerUpdateToGroup(
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
this.get('groupId')!,
this.getRecipients(),
expireTimer,
message.get('sent_at'),
profileKey,
sendOptions
);
}
await message.send(
handleMessageSend(promise, {
messageIds: [],
sendType: 'expirationTimerUpdate',
})
);
return message;
}
@ -4543,49 +4399,54 @@ export class ConversationModel extends window.Backbone
return !this.get('left');
}
// Deprecated: only applies to GroupV1
async leaveGroup(): Promise<void> {
const now = Date.now();
if (this.get('type') === 'group') {
const groupId = this.get('groupId');
if (!groupId) {
throw new Error(`leaveGroup/${this.idForLogging()}: No groupId!`);
}
const groupIdentifiers = this.getRecipients();
this.set({ left: true });
window.Signal.Data.updateConversation(this.attributes);
const model = new window.Whisper.Message({
group_update: { left: 'You' },
conversationId: this.id,
type: 'outgoing',
sent_at: now,
received_at: window.Signal.Util.incrementMessageCounter(),
received_at_ms: now,
// TODO: DESKTOP-722
} as unknown as MessageAttributesType);
const id = await window.Signal.Data.saveMessage(model.attributes, {
ourUuid: window.textsecure.storage.user.getCheckedUuid().toString(),
});
model.set({ id });
const message = window.MessageController.register(model.id, model);
this.addSingleMessage(message);
const options = await getSendOptions(this.attributes);
message.send(
handleMessageSend(
window.textsecure.messaging.leaveGroup(
groupId,
groupIdentifiers,
options
),
{ messageIds: [], sendType: 'legacyGroupChange' }
)
if (!isGroupV1(this.attributes)) {
throw new Error(
`leaveGroup: Group ${this.idForLogging()} is not GroupV1!`
);
}
const now = Date.now();
const groupId = this.get('groupId');
if (!groupId) {
throw new Error(`leaveGroup/${this.idForLogging()}: No groupId!`);
}
const groupIdentifiers = this.getRecipients();
this.set({ left: true });
window.Signal.Data.updateConversation(this.attributes);
const model = new window.Whisper.Message({
group_update: { left: 'You' },
conversationId: this.id,
type: 'outgoing',
sent_at: now,
received_at: window.Signal.Util.incrementMessageCounter(),
received_at_ms: now,
// TODO: DESKTOP-722
} as unknown as MessageAttributesType);
const id = await window.Signal.Data.saveMessage(model.attributes, {
ourUuid: window.textsecure.storage.user.getCheckedUuid().toString(),
});
model.set({ id });
const message = window.MessageController.register(model.id, model);
this.addSingleMessage(message);
const options = await getSendOptions(this.attributes);
message.send(
handleMessageSend(
window.textsecure.messaging.leaveGroup(
groupId,
groupIdentifiers,
options
),
{ messageIds: [], sendType: 'legacyGroupChange' }
)
);
}
async markRead(

View File

@ -1,4 +1,4 @@
// Copyright 2020-2021 Signal Messenger, LLC
// Copyright 2020-2022 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import { isEmpty, isEqual, mapValues, maxBy, noop, omit, union } from 'lodash';
@ -125,8 +125,10 @@ import { ViewOnceOpenSyncs } from '../messageModifiers/ViewOnceOpenSyncs';
import * as AttachmentDownloads from '../messageModifiers/AttachmentDownloads';
import * as LinkPreview from '../types/LinkPreview';
import { SignalService as Proto } from '../protobuf';
import { normalMessageSendJobQueue } from '../jobs/normalMessageSendJobQueue';
import { reactionJobQueue } from '../jobs/reactionJobQueue';
import {
conversationJobQueue,
conversationQueueJobEnum,
} from '../jobs/conversationJobQueue';
import { notificationService } from '../services/notifications';
import type { LinkPreviewType } from '../types/message/LinkPreviews';
import * as log from '../logging/log';
@ -144,6 +146,7 @@ import {
} from '../messages/helpers';
import type { ReplacementValuesType } from '../types/I18N';
import { viewOnceOpenJobQueue } from '../jobs/viewOnceOpenJobQueue';
import type { ConversationQueueJobData } from '../jobs/conversationJobQueue';
/* eslint-disable camelcase */
/* eslint-disable more/no-then */
@ -1164,8 +1167,13 @@ export class MessageModel extends window.Backbone.Model<MessageAttributesType> {
this.set('sendStateByConversationId', newSendStateByConversationId);
await normalMessageSendJobQueue.add(
{ messageId: this.id, conversationId: conversation.id },
await conversationJobQueue.add(
{
type: conversationQueueJobEnum.enum.NormalMessage,
conversationId: conversation.id,
messageId: this.id,
revision: conversation.get('revision'),
},
async jobToInsert => {
await window.Signal.Data.saveMessage(this.attributes, {
jobToInsert,
@ -1441,7 +1449,7 @@ export class MessageModel extends window.Backbone.Model<MessageAttributesType> {
async sendSyncMessageOnly(
dataMessage: Uint8Array,
saveErrors?: (errors: Array<Error>) => void
): Promise<void> {
): Promise<CallbackResultType | void> {
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
const conv = this.getConversation()!;
this.set({ dataMessage });
@ -1461,8 +1469,9 @@ export class MessageModel extends window.Backbone.Model<MessageAttributesType> {
? result.unidentifiedDeliveries
: undefined,
});
} catch (result) {
const resultErrors = result?.errors;
return result;
} catch (error) {
const resultErrors = error?.errors;
const errors = Array.isArray(resultErrors)
? resultErrors
: [new Error('Unknown error')];
@ -1472,6 +1481,7 @@ export class MessageModel extends window.Backbone.Model<MessageAttributesType> {
// We don't save because we're about to save below.
this.saveErrors(errors, { skipSave: true });
}
throw error;
} finally {
await window.Signal.Data.saveMessage(this.attributes, {
ourUuid: window.textsecure.storage.user.getCheckedUuid().toString(),
@ -3180,9 +3190,14 @@ export class MessageModel extends window.Backbone.Model<MessageAttributesType> {
);
if (reaction.get('source') === ReactionSource.FromThisDevice) {
const jobData = { messageId: this.id };
const jobData: ConversationQueueJobData = {
type: conversationQueueJobEnum.enum.Reaction,
conversationId: conversation.id,
messageId: this.id,
revision: conversation.get('revision'),
};
if (shouldPersist) {
await reactionJobQueue.add(jobData, async jobToInsert => {
await conversationJobQueue.add(jobData, async jobToInsert => {
log.info(
`enqueueReactionForSend: saving message ${this.idForLogging()} and job ${
jobToInsert.id
@ -3194,7 +3209,7 @@ export class MessageModel extends window.Backbone.Model<MessageAttributesType> {
});
});
} else {
await reactionJobQueue.add(jobData);
await conversationJobQueue.add(jobData);
}
} else if (shouldPersist) {
await window.Signal.Data.saveMessage(this.attributes, {

View File

@ -76,11 +76,8 @@ import { getOwn } from '../util/getOwn';
import { isNormalNumber } from '../util/isNormalNumber';
import * as durations from '../util/durations';
import { handleMessageSend } from '../util/handleMessageSend';
import {
fetchMembershipProof,
getMembershipList,
wrapWithSyncMessageSend,
} from '../groups';
import { fetchMembershipProof, getMembershipList } from '../groups';
import { wrapWithSyncMessageSend } from '../util/wrapWithSyncMessageSend';
import type { ProcessedEnvelope } from '../textsecure/Types.d';
import { missingCaseError } from '../util/missingCaseError';
import { normalizeGroupCallTimestamp } from '../util/ringrtc/normalizeGroupCallTimestamp';

View File

@ -1,4 +1,4 @@
// Copyright 2020-2021 Signal Messenger, LLC
// Copyright 2020-2022 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
/* eslint-disable camelcase */
@ -1948,6 +1948,13 @@ async function removeMessages(ids: Array<string>): Promise<void> {
async function getMessageById(id: string): Promise<MessageType | undefined> {
const db = getInstance();
return getMessageByIdSync(db, id);
}
export function getMessageByIdSync(
db: Database,
id: string
): MessageType | undefined {
const row = db
.prepare<Query>('SELECT json FROM messages WHERE id = $id;')
.get({
@ -4549,7 +4556,13 @@ async function removeKnownDraftAttachments(
async function getJobsInQueue(queueType: string): Promise<Array<StoredJob>> {
const db = getInstance();
return getJobsInQueueSync(db, queueType);
}
export function getJobsInQueueSync(
db: Database,
queueType: string
): Array<StoredJob> {
return db
.prepare<Query>(
`
@ -4568,7 +4581,7 @@ async function getJobsInQueue(queueType: string): Promise<Array<StoredJob>> {
}));
}
function insertJobSync(db: Database, job: Readonly<StoredJob>): void {
export function insertJobSync(db: Database, job: Readonly<StoredJob>): void {
db.prepare<Query>(
`
INSERT INTO jobs

View File

@ -0,0 +1,109 @@
// Copyright 2022 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import type { Database } from 'better-sqlite3';
import type { LoggerType } from '../../types/Logging';
import { isRecord } from '../../util/isRecord';
import {
getJobsInQueueSync,
getMessageByIdSync,
insertJobSync,
} from '../Server';
export default function updateToSchemaVersion51(
currentVersion: number,
db: Database,
logger: LoggerType
): void {
if (currentVersion >= 51) {
return;
}
db.transaction(() => {
const deleteJobsInQueue = db.prepare(
'DELETE FROM jobs WHERE queueType = $queueType'
);
// First, make sure that reactions job data has a type and conversationId
const reactionsJobs = getJobsInQueueSync(db, 'reactions');
deleteJobsInQueue.run({ queueType: 'reactions' });
reactionsJobs.forEach(job => {
const { data, id } = job;
if (!isRecord(data)) {
logger.warn(
`updateToSchemaVersion51: reactions queue job ${id} was missing valid data`
);
return;
}
const { messageId } = data;
if (typeof messageId !== 'string') {
logger.warn(
`updateToSchemaVersion51: reactions queue job ${id} had a non-string messageId`
);
return;
}
const message = getMessageByIdSync(db, messageId);
if (!message) {
logger.warn(
`updateToSchemaVersion51: Unable to find message for reaction job ${id}`
);
return;
}
const { conversationId } = message;
if (typeof conversationId !== 'string') {
logger.warn(
`updateToSchemaVersion51: reactions queue job ${id} had a non-string conversationId`
);
return;
}
const newJob = {
...job,
queueType: 'conversation',
data: {
...data,
type: 'Reaction',
conversationId,
},
};
insertJobSync(db, newJob);
});
// Then make sure all normal send job data has a type
const normalSendJobs = getJobsInQueueSync(db, 'normal send');
deleteJobsInQueue.run({ queueType: 'normal send' });
normalSendJobs.forEach(job => {
const { data, id } = job;
if (!isRecord(data)) {
logger.warn(
`updateToSchemaVersion51: normal send queue job ${id} was missing valid data`
);
return;
}
const newJob = {
...job,
queueType: 'conversation',
data: {
...data,
type: 'NormalMessage',
},
};
insertJobSync(db, newJob);
});
db.pragma('user_version = 51');
})();
logger.info('updateToSchemaVersion51: success!');
}

View File

@ -1,4 +1,4 @@
// Copyright 2021 Signal Messenger, LLC
// Copyright 2021-2022 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import type { Database } from 'better-sqlite3';
@ -26,6 +26,7 @@ import updateToSchemaVersion47 from './47-further-optimize';
import updateToSchemaVersion48 from './48-fix-user-initiated-index';
import updateToSchemaVersion49 from './49-fix-preview-index';
import updateToSchemaVersion50 from './50-fix-messages-unread-index';
import updateToSchemaVersion51 from './51-centralize-conversation-jobs';
function updateToSchemaVersion1(
currentVersion: number,
@ -1915,6 +1916,7 @@ export const SCHEMA_VERSIONS = [
updateToSchemaVersion48,
updateToSchemaVersion49,
updateToSchemaVersion50,
updateToSchemaVersion51,
];
export function updateSchema(db: Database, logger: LoggerType): void {

View File

@ -52,7 +52,6 @@ import {
getGroupSizeRecommendedLimit,
getGroupSizeHardLimit,
} from '../../groups/limits';
import { getMessagesById } from '../../messages/getMessagesById';
import { isMessageUnread } from '../../util/isMessageUnread';
import { toggleSelectedContactForGroupAddition } from '../../groups/toggleSelectedContactForGroupAddition';
import type { GroupNameCollisionsWithIdsByTitle } from '../../util/groupMemberNameCollisions';
@ -61,8 +60,9 @@ import { writeProfile } from '../../services/writeProfile';
import { writeUsername } from '../../services/writeUsername';
import {
getConversationsByUsername,
getConversationIdsStoppingSend,
getConversationIdsStoppedForVerification,
getMe,
getMessageIdsPendingBecauseOfVerification,
getUsernameSaveState,
} from '../selectors/conversations';
import type { AvatarDataType } from '../../types/Avatar';
@ -71,9 +71,10 @@ import { getAvatarData } from '../../util/getAvatarData';
import { isSameAvatarData } from '../../util/isSameAvatarData';
import { longRunningTaskWrapper } from '../../util/longRunningTaskWrapper';
import {
UsernameSaveState,
ComposerStep,
ConversationVerificationState,
OneTimeModalState,
UsernameSaveState,
} from './conversationsEnums';
import { showToast } from '../../util/showToast';
import { ToastFailedToDeleteUsername } from '../../components/ToastFailedToDeleteUsername';
@ -81,6 +82,7 @@ import { ToastFailedToFetchUsername } from '../../components/ToastFailedToFetchU
import { isValidUsername } from '../../types/Username';
import type { NoopActionType } from './noop';
import { conversationJobQueue } from '../../jobs/conversationJobQueue';
// State
@ -277,6 +279,16 @@ type ComposerGroupCreationState = {
userAvatarData: Array<AvatarDataType>;
};
export type ConversationVerificationData =
| {
type: ConversationVerificationState.PendingVerification;
conversationsNeedingVerification: ReadonlyArray<string>;
}
| {
type: ConversationVerificationState.VerificationCancelled;
canceledAt: number;
};
export type FoundUsernameType = {
uuid: UUIDStringType;
username: string;
@ -331,13 +343,11 @@ export type ConversationsStateType = {
usernameSaveState: UsernameSaveState;
/**
* Each key is a conversation ID. Each value is an array of message IDs stopped by that
* conversation being unverified.
* Each key is a conversation ID. Each value is a value representing the state of
* verification: either a set of pending conversationIds to be approved, or a tombstone
* telling jobs to cancel themselves up to that timestamp.
*/
outboundMessagesPendingConversationVerification: Record<
string,
Array<string>
>;
verificationDataByConversation: Record<string, ConversationVerificationData>;
// Note: it's very important that both of these locations are always kept up to date
messagesLookup: MessageLookupType;
@ -369,15 +379,14 @@ export const getConversationCallMode = (
return CallMode.None;
};
const retryMessages = async (messageIds: Iterable<string>): Promise<void> => {
const messages = await getMessagesById(messageIds);
await Promise.all(messages.map(message => message.retrySend()));
};
// Actions
const CLEAR_MESSAGES_PENDING_CONVERSATION_VERIFICATION =
'conversations/CLEAR_MESSAGES_PENDING_CONVERSATION_VERIFICATION';
const CANCEL_CONVERSATION_PENDING_VERIFICATION =
'conversations/CANCEL_CONVERSATION_PENDING_VERIFICATION';
const CLEAR_CANCELLED_VERIFICATION =
'conversations/CLEAR_CANCELLED_VERIFICATION';
const CLEAR_CONVERSATIONS_PENDING_VERIFICATION =
'conversations/CLEAR_CONVERSATIONS_PENDING_VERIFICATION';
export const COLORS_CHANGED = 'conversations/COLORS_CHANGED';
export const COLOR_SELECTED = 'conversations/COLOR_SELECTED';
const COMPOSE_TOGGLE_EDITING_AVATAR =
@ -386,11 +395,17 @@ const COMPOSE_ADD_AVATAR = 'conversations/compose/ADD_AVATAR';
const COMPOSE_REMOVE_AVATAR = 'conversations/compose/REMOVE_AVATAR';
const COMPOSE_REPLACE_AVATAR = 'conversations/compose/REPLACE_AVATAR';
const CUSTOM_COLOR_REMOVED = 'conversations/CUSTOM_COLOR_REMOVED';
const MESSAGE_STOPPED_BY_MISSING_VERIFICATION =
'conversations/MESSAGE_STOPPED_BY_MISSING_VERIFICATION';
const CONVERSATION_STOPPED_BY_MISSING_VERIFICATION =
'conversations/CONVERSATION_STOPPED_BY_MISSING_VERIFICATION';
const REPLACE_AVATARS = 'conversations/REPLACE_AVATARS';
const UPDATE_USERNAME_SAVE_STATE = 'conversations/UPDATE_USERNAME_SAVE_STATE';
export type CancelVerificationDataByConversationActionType = {
type: typeof CANCEL_CONVERSATION_PENDING_VERIFICATION;
payload: {
canceledAt: number;
};
};
type CantAddContactToGroupActionType = {
type: 'CANT_ADD_CONTACT_TO_GROUP';
payload: {
@ -401,8 +416,14 @@ type ClearGroupCreationErrorActionType = { type: 'CLEAR_GROUP_CREATION_ERROR' };
type ClearInvitedUuidsForNewlyCreatedGroupActionType = {
type: 'CLEAR_INVITED_UUIDS_FOR_NEWLY_CREATED_GROUP';
};
type ClearMessagesPendingConversationVerificationActionType = {
type: typeof CLEAR_MESSAGES_PENDING_CONVERSATION_VERIFICATION;
type ClearVerificationDataByConversationActionType = {
type: typeof CLEAR_CONVERSATIONS_PENDING_VERIFICATION;
};
type ClearCancelledVerificationActionType = {
type: typeof CLEAR_CANCELLED_VERIFICATION;
payload: {
conversationId: string;
};
};
type CloseCantAddContactToGroupModalActionType = {
type: 'CLOSE_CANT_ADD_CONTACT_TO_GROUP_MODAL';
@ -515,10 +536,10 @@ export type MessageSelectedActionType = {
conversationId: string;
};
};
type MessageStoppedByMissingVerificationActionType = {
type: typeof MESSAGE_STOPPED_BY_MISSING_VERIFICATION;
type ConversationStoppedByMissingVerificationActionType = {
type: typeof CONVERSATION_STOPPED_BY_MISSING_VERIFICATION;
payload: {
messageId: string;
conversationId: string;
untrustedConversationIds: ReadonlyArray<string>;
};
};
@ -735,11 +756,13 @@ type ReplaceAvatarsActionType = {
};
};
export type ConversationActionType =
| CancelVerificationDataByConversationActionType
| CantAddContactToGroupActionType
| ClearCancelledVerificationActionType
| ClearChangedMessagesActionType
| ClearVerificationDataByConversationActionType
| ClearGroupCreationErrorActionType
| ClearInvitedUuidsForNewlyCreatedGroupActionType
| ClearMessagesPendingConversationVerificationActionType
| ClearSelectedMessageActionType
| ClearUnreadMetricsActionType
| CloseCantAddContactToGroupModalActionType
@ -754,12 +777,12 @@ export type ConversationActionType =
| ConversationAddedActionType
| ConversationChangedActionType
| ConversationRemovedActionType
| ConversationStoppedByMissingVerificationActionType
| ConversationUnloadedActionType
| CreateGroupFulfilledActionType
| CreateGroupPendingActionType
| CreateGroupRejectedActionType
| CustomColorRemovedActionType
| MessageStoppedByMissingVerificationActionType
| MessageChangedActionType
| MessageDeletedActionType
| MessageExpandedActionType
@ -800,8 +823,9 @@ export type ConversationActionType =
// Action Creators
export const actions = {
cancelMessagesPendingConversationVerification,
cancelConversationVerification,
cantAddContactToGroup,
clearCancelledConversationVerification,
clearChangedMessages,
clearGroupCreationError,
clearInvitedUuidsForNewlyCreatedGroup,
@ -819,11 +843,11 @@ export const actions = {
conversationAdded,
conversationChanged,
conversationRemoved,
conversationStoppedByMissingVerification,
conversationUnloaded,
createGroup,
deleteAvatarFromDisk,
doubleCheckMissingQuoteReference,
messageStoppedByMissingVerification,
messageChanged,
messageDeleted,
messageExpanded,
@ -868,7 +892,7 @@ export const actions = {
toggleConversationInChooseMembers,
toggleComposeEditingAvatar,
updateConversationModelSharedGroups,
verifyConversationsStoppingMessageSend,
verifyConversationsStoppingSend,
};
function filterAvatarData(
@ -1244,43 +1268,79 @@ function toggleComposeEditingAvatar(): ToggleComposeEditingAvatarActionType {
};
}
function verifyConversationsStoppingMessageSend(): ThunkAction<
export function cancelConversationVerification(
canceledAt?: number
): ThunkAction<
void,
RootStateType,
unknown,
ClearMessagesPendingConversationVerificationActionType
CancelVerificationDataByConversationActionType
> {
return async (dispatch, getState) => {
const { outboundMessagesPendingConversationVerification } =
getState().conversations;
const allMessageIds = new Set<string>();
const promises: Array<Promise<unknown>> = [];
Object.entries(outboundMessagesPendingConversationVerification).forEach(
([conversationId, messageIds]) => {
for (const messageId of messageIds) {
allMessageIds.add(messageId);
}
const conversation = window.ConversationController.get(conversationId);
if (!conversation) {
return;
}
if (conversation.isUnverified()) {
promises.push(conversation.setVerifiedDefault());
}
promises.push(conversation.setApproved());
}
);
promises.push(retryMessages(allMessageIds));
return (dispatch, getState) => {
const state = getState();
const conversationIdsBlocked =
getConversationIdsStoppedForVerification(state);
dispatch({
type: CLEAR_MESSAGES_PENDING_CONVERSATION_VERIFICATION,
type: CANCEL_CONVERSATION_PENDING_VERIFICATION,
payload: {
canceledAt: canceledAt ?? Date.now(),
},
});
// Start the blocked conversation queues up again
conversationIdsBlocked.forEach(conversationId => {
conversationJobQueue.resolveVerificationWaiter(conversationId);
});
};
}
function verifyConversationsStoppingSend(): ThunkAction<
void,
RootStateType,
unknown,
ClearVerificationDataByConversationActionType
> {
return async (dispatch, getState) => {
const state = getState();
const conversationIdsStoppingSend = getConversationIdsStoppingSend(state);
const conversationIdsBlocked =
getConversationIdsStoppedForVerification(state);
// Mark conversations as approved/verified as appropriate
const promises: Array<Promise<unknown>> = [];
conversationIdsStoppingSend.forEach(async conversationId => {
const conversation = window.ConversationController.get(conversationId);
if (!conversation) {
return;
}
if (conversation.isUnverified()) {
promises.push(conversation.setVerifiedDefault());
}
promises.push(conversation.setApproved());
});
dispatch({
type: CLEAR_CONVERSATIONS_PENDING_VERIFICATION,
});
await Promise.all(promises);
// Start the blocked conversation queues up again
conversationIdsBlocked.forEach(conversationId => {
conversationJobQueue.resolveVerificationWaiter(conversationId);
});
};
}
export function clearCancelledConversationVerification(
conversationId: string
): ClearCancelledVerificationActionType {
return {
type: CLEAR_CANCELLED_VERIFICATION,
payload: {
conversationId,
},
};
}
@ -1338,32 +1398,6 @@ function composeReplaceAvatar(
};
}
function cancelMessagesPendingConversationVerification(): ThunkAction<
void,
RootStateType,
unknown,
ClearMessagesPendingConversationVerificationActionType
> {
return async (dispatch, getState) => {
const messageIdsPending = getMessageIdsPendingBecauseOfVerification(
getState()
);
const messagesStopped = await getMessagesById([...messageIdsPending]);
messagesStopped.forEach(message => {
message.markFailed();
});
dispatch({
type: CLEAR_MESSAGES_PENDING_CONVERSATION_VERIFICATION,
});
await window.Signal.Data.saveMessages(
messagesStopped.map(message => message.attributes),
{ ourUuid: window.textsecure.storage.user.getCheckedUuid().toString() }
);
};
}
function cantAddContactToGroup(
conversationId: string
): CantAddContactToGroupActionType {
@ -1398,21 +1432,9 @@ function conversationChanged(
id: string,
data: ConversationType
): ThunkAction<void, RootStateType, unknown, ConversationChangedActionType> {
return async (dispatch, getState) => {
return dispatch => {
calling.groupMembersChanged(id);
if (!data.isUntrusted) {
const messageIdsPending =
getOwn(
getState().conversations
.outboundMessagesPendingConversationVerification,
id
) ?? [];
if (messageIdsPending.length) {
retryMessages(messageIdsPending);
}
}
dispatch({
type: 'CONVERSATION_CHANGED',
payload: {
@ -1511,16 +1533,13 @@ function selectMessage(
};
}
function messageStoppedByMissingVerification(
messageId: string,
untrustedConversationIds: ReadonlyArray<string>
): MessageStoppedByMissingVerificationActionType {
function conversationStoppedByMissingVerification(payload: {
conversationId: string;
untrustedConversationIds: ReadonlyArray<string>;
}): ConversationStoppedByMissingVerificationActionType {
return {
type: MESSAGE_STOPPED_BY_MISSING_VERIFICATION,
payload: {
messageId,
untrustedConversationIds,
},
type: CONVERSATION_STOPPED_BY_MISSING_VERIFICATION,
payload,
};
}
@ -2095,7 +2114,7 @@ export function getEmptyState(): ConversationsStateType {
conversationsByUuid: {},
conversationsByGroupId: {},
conversationsByUsername: {},
outboundMessagesPendingConversationVerification: {},
verificationDataByConversation: {},
messagesByConversation: {},
messagesLookup: {},
selectedMessageCounter: 0,
@ -2261,10 +2280,73 @@ export function reducer(
state: Readonly<ConversationsStateType> = getEmptyState(),
action: Readonly<ConversationActionType>
): ConversationsStateType {
if (action.type === CLEAR_MESSAGES_PENDING_CONVERSATION_VERIFICATION) {
if (action.type === CLEAR_CONVERSATIONS_PENDING_VERIFICATION) {
return {
...state,
outboundMessagesPendingConversationVerification: {},
verificationDataByConversation: {},
};
}
if (action.type === CLEAR_CANCELLED_VERIFICATION) {
const { conversationId } = action.payload;
const { verificationDataByConversation } = state;
const existingPendingState = getOwn(
verificationDataByConversation,
conversationId
);
// If there are active verifications required, this will do nothing.
if (
existingPendingState &&
existingPendingState.type ===
ConversationVerificationState.PendingVerification
) {
return state;
}
return {
...state,
verificationDataByConversation: omit(
verificationDataByConversation,
conversationId
),
};
}
if (action.type === CANCEL_CONVERSATION_PENDING_VERIFICATION) {
const { canceledAt } = action.payload;
const { verificationDataByConversation } = state;
const newverificationDataByConversation: Record<
string,
ConversationVerificationData
> = {};
const entries = Object.entries(verificationDataByConversation);
if (!entries.length) {
log.warn(
'CANCEL_CONVERSATION_PENDING_VERIFICATION: No conversations pending verification'
);
return state;
}
for (const [conversationId, data] of entries) {
if (
data.type === ConversationVerificationState.VerificationCancelled &&
data.canceledAt > canceledAt
) {
newverificationDataByConversation[conversationId] = data;
} else {
newverificationDataByConversation[conversationId] = {
type: ConversationVerificationState.VerificationCancelled,
canceledAt,
};
}
}
return {
...state,
verificationDataByConversation: newverificationDataByConversation,
};
}
@ -2356,9 +2438,6 @@ export function reducer(
[id]: data,
},
...updateConversationLookups(data, undefined, state),
outboundMessagesPendingConversationVerification: data.isUntrusted
? state.outboundMessagesPendingConversationVerification
: omit(state.outboundMessagesPendingConversationVerification, id),
};
}
if (action.type === 'CONVERSATION_CHANGED') {
@ -2384,7 +2463,7 @@ export function reducer(
showArchived = false;
}
// Inbox -> Archived: no conversation is selected
// Note: With today's stacked converastions architecture, this can result in weird
// Note: With today's stacked conversations architecture, this can result in weird
// behavior - no selected conversation in the left pane, but a conversation show
// in the right pane.
if (!existing.isArchived && data.isArchived) {
@ -2405,9 +2484,6 @@ export function reducer(
[id]: data,
},
...updateConversationLookups(data, existing, state),
outboundMessagesPendingConversationVerification: data.isUntrusted
? state.outboundMessagesPendingConversationVerification
: omit(state.outboundMessagesPendingConversationVerification, id),
};
}
if (action.type === 'CONVERSATION_REMOVED') {
@ -2511,30 +2587,48 @@ export function reducer(
selectedMessageCounter: state.selectedMessageCounter + 1,
};
}
if (action.type === MESSAGE_STOPPED_BY_MISSING_VERIFICATION) {
const { messageId, untrustedConversationIds } = action.payload;
if (action.type === CONVERSATION_STOPPED_BY_MISSING_VERIFICATION) {
const { conversationId, untrustedConversationIds } = action.payload;
const newOutboundMessagesPendingConversationVerification = {
...state.outboundMessagesPendingConversationVerification,
};
untrustedConversationIds.forEach(conversationId => {
const existingPendingMessageIds =
getOwn(
newOutboundMessagesPendingConversationVerification,
conversationId
) ?? [];
if (!existingPendingMessageIds.includes(messageId)) {
newOutboundMessagesPendingConversationVerification[conversationId] = [
...existingPendingMessageIds,
messageId,
];
}
});
const { verificationDataByConversation } = state;
const existingPendingState = getOwn(
verificationDataByConversation,
conversationId
);
if (
!existingPendingState ||
existingPendingState.type ===
ConversationVerificationState.VerificationCancelled
) {
return {
...state,
verificationDataByConversation: {
...verificationDataByConversation,
[conversationId]: {
type: ConversationVerificationState.PendingVerification as const,
conversationsNeedingVerification: untrustedConversationIds,
},
},
};
}
const conversationsNeedingVerification: ReadonlyArray<string> = Array.from(
new Set([
...existingPendingState.conversationsNeedingVerification,
...untrustedConversationIds,
])
);
return {
...state,
outboundMessagesPendingConversationVerification:
newOutboundMessagesPendingConversationVerification,
verificationDataByConversation: {
...verificationDataByConversation,
[conversationId]: {
type: ConversationVerificationState.PendingVerification as const,
conversationsNeedingVerification,
},
},
};
}
if (action.type === 'MESSAGE_CHANGED') {

View File

@ -28,3 +28,8 @@ export enum OneTimeModalState {
Showing,
Shown,
}
export enum ConversationVerificationState {
PendingVerification = 'PendingVerification',
VerificationCancelled = 'VerificationCancelled',
}

View File

@ -12,12 +12,17 @@ import type {
ConversationMessageType,
ConversationsStateType,
ConversationType,
ConversationVerificationData,
MessageLookupType,
MessagesByConversationType,
PreJoinConversationType,
} from '../ducks/conversations';
import type { UsernameSaveState } from '../ducks/conversationsEnums';
import { ComposerStep, OneTimeModalState } from '../ducks/conversationsEnums';
import {
ComposerStep,
OneTimeModalState,
ConversationVerificationState,
} from '../ducks/conversationsEnums';
import { getOwn } from '../../util/getOwn';
import { isNotNil } from '../../util/isNotNil';
import { deconstructLookup } from '../../util/deconstructLookup';
@ -995,52 +1000,59 @@ export const getGroupAdminsSelector = createSelector(
}
);
const getOutboundMessagesPendingConversationVerification = createSelector(
const getConversationVerificationData = createSelector(
getConversations,
(
conversations: Readonly<ConversationsStateType>
): Record<string, Array<string>> =>
conversations.outboundMessagesPendingConversationVerification
): Record<string, ConversationVerificationData> =>
conversations.verificationDataByConversation
);
const getConversationIdsStoppingMessageSendBecauseOfVerification =
createSelector(
getOutboundMessagesPendingConversationVerification,
(outboundMessagesPendingConversationVerification): Array<string> =>
Object.keys(outboundMessagesPendingConversationVerification)
);
export const getConversationIdsStoppedForVerification = createSelector(
getConversationVerificationData,
(verificationDataByConversation): Array<string> =>
Object.keys(verificationDataByConversation)
);
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 getConversationsStoppedForVerification = createSelector(
getConversationByIdSelector,
getConversationIdsStoppedForVerification,
(
conversationSelector: (id: string) => undefined | ConversationType,
conversationIds: ReadonlyArray<string>
): Array<ConversationType> => {
const conversations = conversationIds
.map(conversationId => conversationSelector(conversationId))
.filter(isNotNil);
return sortByTitle(conversations);
}
);
export const getNumberOfMessagesPendingBecauseOfVerification = createSelector(
getMessageIdsPendingBecauseOfVerification,
(messageIds: Readonly<Set<string>>): number => messageIds.size
export const getConversationIdsStoppingSend = createSelector(
getConversationVerificationData,
(pendingData): Array<string> => {
const result = new Set<string>();
Object.values(pendingData).forEach(item => {
if (item.type === ConversationVerificationState.PendingVerification) {
item.conversationsNeedingVerification.forEach(conversationId => {
result.add(conversationId);
});
}
});
return Array.from(result);
}
);
export const getConversationsStoppingSend = createSelector(
getConversationByIdSelector,
getConversationIdsStoppingSend,
(
conversationSelector: (id: string) => undefined | ConversationType,
conversationIds: ReadonlyArray<string>
): Array<ConversationType> => {
const conversations = conversationIds
.map(conversationId => conversationSelector(conversationId))
.filter(isNotNil);
return sortByTitle(conversations);
}
);

View File

@ -12,10 +12,7 @@ import { SmartSafetyNumberViewer } from './SafetyNumberViewer';
import type { StateType } from '../reducer';
import { getPreferredBadgeSelector } from '../selectors/badges';
import { getIntl, getTheme } from '../selectors/user';
import {
getConversationsStoppingMessageSendBecauseOfVerification,
getNumberOfMessagesPendingBecauseOfVerification,
} from '../selectors/conversations';
import { getConversationsStoppingSend } from '../selectors/conversations';
import { getIsCustomizingPreferredReactions } from '../selectors/preferredReactions';
import { mapDispatchToProps } from '../actions';
import type { SafetyNumberProps } from '../../components/SafetyNumberChangeDialog';
@ -23,13 +20,10 @@ import type { SafetyNumberProps } from '../../components/SafetyNumberChangeDialo
const mapStateToProps = (state: StateType) => {
return {
...state.app,
conversationsStoppingMessageSendBecauseOfVerification:
getConversationsStoppingMessageSendBecauseOfVerification(state),
conversationsStoppingSend: getConversationsStoppingSend(state),
getPreferredBadge: getPreferredBadgeSelector(state),
i18n: getIntl(state),
isCustomizingPreferredReactions: getIsCustomizingPreferredReactions(state),
numberOfMessagesPendingBecauseOfVerification:
getNumberOfMessagesPendingBecauseOfVerification(state),
renderCallManager: () => <SmartCallManager />,
renderCustomizingPreferredReactionsModal: () => (
<SmartCustomizingPreferredReactionsModal />

View File

@ -4,8 +4,9 @@
import { assert } from 'chai';
import {
OneTimeModalState,
ComposerStep,
ConversationVerificationState,
OneTimeModalState,
} from '../../../state/ducks/conversationsEnums';
import type {
ConversationLookupType,
@ -27,16 +28,17 @@ import {
getComposeSelectedContacts,
getContactNameColorSelector,
getConversationByIdSelector,
getConversationIdsStoppingSend,
getConversationIdsStoppedForVerification,
getConversationsByTitleSelector,
getConversationSelector,
getConversationsStoppingMessageSendBecauseOfVerification,
getConversationsStoppingSend,
getConversationsStoppedForVerification,
getFilteredCandidateContactsForNewGroup,
getFilteredComposeContacts,
getFilteredComposeGroups,
getInvitedContactsForNewlyCreatedGroup,
getMaximumGroupSizeModalState,
getMessageIdsPendingBecauseOfVerification,
getNumberOfMessagesPendingBecauseOfVerification,
getPlaceholderContact,
getRecommendedGroupSizeModalState,
getSelectedConversationId,
@ -289,19 +291,17 @@ describe('both/state/selectors/conversations', () => {
});
});
describe('#getConversationsStoppingMessageSendBecauseOfVerification', () => {
describe('#getConversationsStoppingSend', () => {
it('returns an empty array if there are no conversations stopping send', () => {
const state = getEmptyRootState();
assert.isEmpty(
getConversationsStoppingMessageSendBecauseOfVerification(state)
);
assert.isEmpty(getConversationsStoppingSend(state));
});
it('returns all conversations stopping message send', () => {
it('returns all conversations stopping send', () => {
const convo1 = makeConversation('abc');
const convo2 = makeConversation('def');
const state = {
const state: StateType = {
...getEmptyRootState(),
conversations: {
...getEmptyState(),
@ -309,77 +309,71 @@ describe('both/state/selectors/conversations', () => {
def: convo2,
abc: convo1,
},
outboundMessagesPendingConversationVerification: {
def: ['message 2', 'message 3'],
abc: ['message 1', 'message 2'],
verificationDataByConversation: {
'convo a': {
type: ConversationVerificationState.PendingVerification as const,
conversationsNeedingVerification: ['abc'],
},
'convo b': {
type: ConversationVerificationState.PendingVerification as const,
conversationsNeedingVerification: ['def', 'abc'],
},
},
},
};
assert.deepEqual(
getConversationsStoppingMessageSendBecauseOfVerification(state),
[convo1, convo2]
);
assert.sameDeepMembers(getConversationIdsStoppingSend(state), [
'abc',
'def',
]);
assert.sameDeepMembers(getConversationsStoppingSend(state), [
convo1,
convo2,
]);
});
});
describe('#getMessageIdsPendingBecauseOfVerification', () => {
it('returns an empty set if there are no conversations stopping send', () => {
describe('#getConversationStoppedForVerification', () => {
it('returns an empty array if there are no conversations stopping send', () => {
const state = getEmptyRootState();
assert.deepEqual(
getMessageIdsPendingBecauseOfVerification(state),
new Set()
);
assert.isEmpty(getConversationsStoppingSend(state));
});
it('returns a set of unique pending messages', () => {
const state = {
it('returns all conversations stopping send', () => {
const convoA = makeConversation('convo a');
const convoB = makeConversation('convo b');
const state: StateType = {
...getEmptyRootState(),
conversations: {
...getEmptyState(),
outboundMessagesPendingConversationVerification: {
abc: ['message 2', 'message 3'],
def: ['message 1', 'message 2'],
ghi: ['message 4'],
conversationLookup: {
'convo a': convoA,
'convo b': convoB,
},
verificationDataByConversation: {
'convo a': {
type: ConversationVerificationState.PendingVerification as const,
conversationsNeedingVerification: ['abc'],
},
'convo b': {
type: ConversationVerificationState.PendingVerification as const,
conversationsNeedingVerification: ['def', 'abc'],
},
},
},
};
assert.deepEqual(
getMessageIdsPendingBecauseOfVerification(state),
new Set(['message 1', 'message 2', 'message 3', 'message 4'])
);
});
});
assert.sameDeepMembers(getConversationIdsStoppedForVerification(state), [
'convo a',
'convo b',
]);
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
);
assert.sameDeepMembers(getConversationsStoppedForVerification(state), [
convoA,
convoB,
]);
});
});

View File

@ -9,19 +9,23 @@ import { set } from 'lodash/fp';
import { reducer as rootReducer } from '../../../state/reducer';
import { noopAction } from '../../../state/ducks/noop';
import {
OneTimeModalState,
ComposerStep,
ConversationVerificationState,
OneTimeModalState,
} from '../../../state/ducks/conversationsEnums';
import type {
CancelVerificationDataByConversationActionType,
ConversationMessageType,
ConversationType,
ConversationsStateType,
ConversationType,
MessageType,
SwitchToAssociatedViewActionType,
ToggleConversationInChooseMembersActionType,
} from '../../../state/ducks/conversations';
import {
actions,
cancelConversationVerification,
clearCancelledConversationVerification,
getConversationCallMode,
getEmptyState,
reducer,
@ -53,7 +57,7 @@ const {
closeRecommendedGroupSizeModal,
createGroup,
messageSizeChanged,
messageStoppedByMissingVerification,
conversationStoppedByMissingVerification,
openConversationInternal,
repairNewestMessage,
repairOldestMessage,
@ -898,32 +902,205 @@ describe('both/state/ducks/conversations', () => {
});
});
describe('MESSAGE_STOPPED_BY_MISSING_VERIFICATION', () => {
it('adds messages that need conversation verification, removing duplicates', () => {
describe('CONVERSATION_STOPPED_BY_MISSING_VERIFICATION', () => {
it('adds to state, removing duplicates', () => {
const first = reducer(
getEmptyState(),
messageStoppedByMissingVerification('message 1', ['convo 1'])
conversationStoppedByMissingVerification({
conversationId: 'convo A',
untrustedConversationIds: ['convo 1'],
})
);
const second = reducer(
first,
messageStoppedByMissingVerification('message 1', ['convo 2'])
conversationStoppedByMissingVerification({
conversationId: 'convo A',
untrustedConversationIds: ['convo 2'],
})
);
const third = reducer(
second,
messageStoppedByMissingVerification('message 2', [
'convo 1',
'convo 3',
])
conversationStoppedByMissingVerification({
conversationId: 'convo A',
untrustedConversationIds: ['convo 1', 'convo 3'],
})
);
assert.deepStrictEqual(
third.outboundMessagesPendingConversationVerification,
{
'convo 1': ['message 1', 'message 2'],
'convo 2': ['message 1'],
'convo 3': ['message 2'],
}
assert.deepStrictEqual(third.verificationDataByConversation, {
'convo A': {
type: ConversationVerificationState.PendingVerification,
conversationsNeedingVerification: ['convo 1', 'convo 2', 'convo 3'],
},
});
});
it('stomps on VerificationCancelled state', () => {
const state: ConversationsStateType = {
...getEmptyState(),
verificationDataByConversation: {
'convo A': {
type: ConversationVerificationState.VerificationCancelled,
canceledAt: Date.now(),
},
},
};
const actual = reducer(
state,
conversationStoppedByMissingVerification({
conversationId: 'convo A',
untrustedConversationIds: ['convo 1', 'convo 2'],
})
);
assert.deepStrictEqual(actual.verificationDataByConversation, {
'convo A': {
type: ConversationVerificationState.PendingVerification,
conversationsNeedingVerification: ['convo 1', 'convo 2'],
},
});
});
});
describe('CANCEL_CONVERSATION_PENDING_VERIFICATION', () => {
function getAction(
timestamp: number,
conversationsState: ConversationsStateType
): CancelVerificationDataByConversationActionType {
const dispatch = sinon.spy();
cancelConversationVerification(timestamp)(
dispatch,
() => ({
...getEmptyRootState(),
conversations: conversationsState,
}),
null
);
return dispatch.getCall(0).args[0];
}
it('replaces existing PendingVerification state', () => {
const now = Date.now();
const state: ConversationsStateType = {
...getEmptyState(),
verificationDataByConversation: {
'convo A': {
type: ConversationVerificationState.PendingVerification,
conversationsNeedingVerification: ['convo 1', 'convo 2'],
},
},
};
const action = getAction(now, state);
const actual = reducer(state, action);
assert.deepStrictEqual(actual.verificationDataByConversation, {
'convo A': {
type: ConversationVerificationState.VerificationCancelled,
canceledAt: now,
},
});
});
it('updates timestamp for existing VerificationCancelled state', () => {
const now = Date.now();
const state: ConversationsStateType = {
...getEmptyState(),
verificationDataByConversation: {
'convo A': {
type: ConversationVerificationState.VerificationCancelled,
canceledAt: now - 1,
},
},
};
const action = getAction(now, state);
const actual = reducer(state, action);
assert.deepStrictEqual(actual.verificationDataByConversation, {
'convo A': {
type: ConversationVerificationState.VerificationCancelled,
canceledAt: now,
},
});
});
it('uses newest timestamp when updating existing VerificationCancelled state', () => {
const now = Date.now();
const state: ConversationsStateType = {
...getEmptyState(),
verificationDataByConversation: {
'convo A': {
type: ConversationVerificationState.VerificationCancelled,
canceledAt: now,
},
},
};
const action = getAction(now, state);
const actual = reducer(state, action);
assert.deepStrictEqual(actual.verificationDataByConversation, {
'convo A': {
type: ConversationVerificationState.VerificationCancelled,
canceledAt: now,
},
});
});
it('does nothing if no existing state', () => {
const state: ConversationsStateType = getEmptyState();
const action = getAction(Date.now(), state);
const actual = reducer(state, action);
assert.strictEqual(actual, state);
});
});
describe('CANCEL_CONVERSATION_PENDING_VERIFICATION', () => {
it('removes existing VerificationCancelled state', () => {
const now = Date.now();
const state: ConversationsStateType = {
...getEmptyState(),
verificationDataByConversation: {
'convo A': {
type: ConversationVerificationState.VerificationCancelled,
canceledAt: now,
},
},
};
const actual = reducer(
state,
clearCancelledConversationVerification('convo A')
);
assert.deepStrictEqual(actual.verificationDataByConversation, {});
});
it('leaves existing PendingVerification state', () => {
const state: ConversationsStateType = {
...getEmptyState(),
verificationDataByConversation: {
'convo A': {
type: ConversationVerificationState.PendingVerification,
conversationsNeedingVerification: ['convo 1', 'convo 2'],
},
},
};
const actual = reducer(
state,
clearCancelledConversationVerification('convo A')
);
assert.deepStrictEqual(actual, state);
});
it('does nothing with empty state', () => {
const state: ConversationsStateType = getEmptyState();
const actual = reducer(
state,
clearCancelledConversationVerification('convo A')
);
assert.deepStrictEqual(actual, state);
});
});

View File

@ -425,7 +425,7 @@ describe('LeftPaneInboxHelper', () => {
});
describe('getConversationAndMessageAtIndex', () => {
it('returns pinned converastions, then non-pinned conversations', () => {
it('returns pinned conversations, then non-pinned conversations', () => {
const conversations = [
getDefaultConversation(),
getDefaultConversation(),

View File

@ -4,10 +4,34 @@
import { assert } from 'chai';
import * as sinon from 'sinon';
import { noop, omit } from 'lodash';
import { HTTPError } from '../../../textsecure/Errors';
import { HTTPError, SendMessageProtoError } from '../../../textsecure/Errors';
import { SECOND } from '../../../util/durations';
import { handleMultipleSendErrors } from '../../../jobs/helpers/handleMultipleSendErrors';
import {
handleMultipleSendErrors,
maybeExpandErrors,
} from '../../../jobs/helpers/handleMultipleSendErrors';
describe('maybeExpandErrors', () => {
// This returns a readonly array, but Chai wants a mutable one.
const expand = (input: unknown) => maybeExpandErrors(input) as Array<unknown>;
it("wraps the provided value if it's not a SendMessageProtoError with errors", () => {
const input = { foo: 123 };
assert.sameMembers(expand(input), [input]);
});
it('wraps the provided value if a SendMessageProtoError with no errors', () => {
const input = new SendMessageProtoError({});
assert.sameMembers(expand(input), [input]);
});
it("uses a SendMessageProtoError's errors", () => {
const errors = [new Error('one'), new Error('two')];
const input = new SendMessageProtoError({ errors });
assert.strictEqual(expand(input), errors);
});
});
describe('handleMultipleSendErrors', () => {
const make413 = (retryAfter: number): HTTPError =>
@ -43,8 +67,9 @@ describe('handleMultipleSendErrors', () => {
handleMultipleSendErrors({
...defaultOptions,
errors: [new Error('first'), new Error('second')],
toThrow: new Error('to throw'),
}),
'first'
'to throw'
);
});
@ -57,6 +82,7 @@ describe('handleMultipleSendErrors', () => {
errors: [new Error('uh oh')],
markFailed,
isFinalAttempt: true,
toThrow: new Error('to throw'),
})
);
@ -69,8 +95,9 @@ describe('handleMultipleSendErrors', () => {
...omit(defaultOptions, 'markFailed'),
errors: [new Error('Test message')],
isFinalAttempt: true,
toThrow: new Error('to throw'),
}),
'Test message'
'to throw'
);
});
@ -89,6 +116,7 @@ describe('handleMultipleSendErrors', () => {
make413(20),
],
timeRemaining: 99999999,
toThrow: new Error('to throw'),
});
} catch (err) {
// No-op
@ -112,6 +140,7 @@ describe('handleMultipleSendErrors', () => {
...defaultOptions,
errors: [make413(9999)],
timeRemaining: 99,
toThrow: new Error('to throw'),
});
} catch (err) {
// No-op
@ -130,6 +159,7 @@ describe('handleMultipleSendErrors', () => {
...defaultOptions,
errors: [new Error('uh oh')],
isFinalAttempt: true,
toThrow: new Error('to throw'),
})
);
});
@ -142,6 +172,7 @@ describe('handleMultipleSendErrors', () => {
...defaultOptions,
errors: [new Error('uh oh'), { code: 508 }, make413(99999)],
markFailed: noop,
toThrow: new Error('to throw'),
})
);
});
@ -153,6 +184,7 @@ describe('handleMultipleSendErrors', () => {
...defaultOptions,
errors: [{ code: 508 }],
markFailed,
toThrow: new Error('to throw'),
});
sinon.assert.calledOnceWithExactly(markFailed);

View File

@ -1,4 +1,4 @@
// Copyright 2021 Signal Messenger, LLC
// Copyright 2021-2022 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import { assert } from 'chai';
@ -8,6 +8,7 @@ import { v4 as generateGuid } from 'uuid';
import { SCHEMA_VERSIONS } from '../sql/migrations';
import { consoleLogger } from '../util/consoleLogger';
import { getJobsInQueueSync, insertJobSync } from '../sql/Server';
const OUR_UUID = generateGuid();
@ -1325,7 +1326,7 @@ describe('SQL migrations test', () => {
});
});
describe('updateToSchemaVersion49', () => {
describe('updateToSchemaVersion50', () => {
it('creates usable index for messages_unread', () => {
updateToVersion(50);
@ -1351,4 +1352,252 @@ describe('SQL migrations test', () => {
assert.notInclude(details, 'SCAN');
});
});
describe('updateToSchemaVersion51', () => {
it('moves reactions/normal send jobs over to conversation queue', () => {
updateToVersion(50);
const MESSAGE_ID_1 = generateGuid();
const CONVERSATION_ID_1 = generateGuid();
db.exec(
`
INSERT INTO messages
(id, json)
VALUES ('${MESSAGE_ID_1}', '${JSON.stringify({
conversationId: CONVERSATION_ID_1,
})}')
`
);
db.exec(
`
INSERT INTO jobs
(id, timestamp, queueType, data)
VALUES
('id-1', 1, 'random job', '{}'),
('id-2', 2, 'normal send', '{}'),
('id-3', 3, 'reactions', '{"messageId":"${MESSAGE_ID_1}"}'),
('id-4', 4, 'conversation', '{}');
`
);
const totalJobs = db.prepare('SELECT COUNT(*) FROM jobs;').pluck();
const normalSendJobs = db
.prepare("SELECT COUNT(*) FROM jobs WHERE queueType = 'normal send';")
.pluck();
const conversationJobs = db
.prepare("SELECT COUNT(*) FROM jobs WHERE queueType = 'conversation';")
.pluck();
const reactionJobs = db
.prepare("SELECT COUNT(*) FROM jobs WHERE queueType = 'reactions';")
.pluck();
assert.strictEqual(totalJobs.get(), 4, 'before total');
assert.strictEqual(normalSendJobs.get(), 1, 'before normal');
assert.strictEqual(conversationJobs.get(), 1, 'before conversation');
assert.strictEqual(reactionJobs.get(), 1, 'before reaction');
updateToVersion(51);
assert.strictEqual(totalJobs.get(), 4, 'after total');
assert.strictEqual(normalSendJobs.get(), 0, 'after normal');
assert.strictEqual(conversationJobs.get(), 3, 'after conversation');
assert.strictEqual(reactionJobs.get(), 0, 'after reaction');
});
it('updates reactions jobs with their conversationId', () => {
updateToVersion(50);
const MESSAGE_ID_1 = generateGuid();
const MESSAGE_ID_2 = generateGuid();
const MESSAGE_ID_3 = generateGuid();
const CONVERSATION_ID_1 = generateGuid();
const CONVERSATION_ID_2 = generateGuid();
insertJobSync(db, {
id: 'id-1',
timestamp: 1,
queueType: 'reactions',
data: {
messageId: MESSAGE_ID_1,
},
});
insertJobSync(db, {
id: 'id-2',
timestamp: 2,
queueType: 'reactions',
data: {
messageId: MESSAGE_ID_2,
},
});
insertJobSync(db, {
id: 'id-3-missing-data',
timestamp: 3,
queueType: 'reactions',
});
insertJobSync(db, {
id: 'id-4-non-string-messageId',
timestamp: 1,
queueType: 'reactions',
data: {
messageId: 4,
},
});
insertJobSync(db, {
id: 'id-5-missing-message',
timestamp: 5,
queueType: 'reactions',
data: {
messageId: 'missing',
},
});
insertJobSync(db, {
id: 'id-6-missing-conversation',
timestamp: 6,
queueType: 'reactions',
data: {
messageId: MESSAGE_ID_3,
},
});
const messageJson1 = JSON.stringify({
conversationId: CONVERSATION_ID_1,
});
const messageJson2 = JSON.stringify({
conversationId: CONVERSATION_ID_2,
});
db.exec(
`
INSERT INTO messages
(id, conversationId, json)
VALUES
('${MESSAGE_ID_1}', '${CONVERSATION_ID_1}', '${messageJson1}'),
('${MESSAGE_ID_2}', '${CONVERSATION_ID_2}', '${messageJson2}'),
('${MESSAGE_ID_3}', null, '{}');
`
);
const totalJobs = db.prepare('SELECT COUNT(*) FROM jobs;').pluck();
const reactionJobs = db
.prepare("SELECT COUNT(*) FROM jobs WHERE queueType = 'reactions';")
.pluck();
const conversationJobs = db
.prepare("SELECT COUNT(*) FROM jobs WHERE queueType = 'conversation';")
.pluck();
assert.strictEqual(totalJobs.get(), 6, 'total jobs before');
assert.strictEqual(reactionJobs.get(), 6, 'reaction jobs before');
assert.strictEqual(conversationJobs.get(), 0, 'conversation jobs before');
updateToVersion(51);
assert.strictEqual(totalJobs.get(), 2, 'total jobs after');
assert.strictEqual(reactionJobs.get(), 0, 'reaction jobs after');
assert.strictEqual(conversationJobs.get(), 2, 'conversation jobs after');
const jobs = getJobsInQueueSync(db, 'conversation');
assert.deepEqual(jobs, [
{
id: 'id-1',
timestamp: 1,
queueType: 'conversation',
data: {
type: 'Reaction',
conversationId: CONVERSATION_ID_1,
messageId: MESSAGE_ID_1,
},
},
{
id: 'id-2',
timestamp: 2,
queueType: 'conversation',
data: {
type: 'Reaction',
conversationId: CONVERSATION_ID_2,
messageId: MESSAGE_ID_2,
},
},
]);
});
it('updates normal send jobs with their conversationId', () => {
updateToVersion(50);
const MESSAGE_ID_1 = generateGuid();
const MESSAGE_ID_2 = generateGuid();
const CONVERSATION_ID_1 = generateGuid();
const CONVERSATION_ID_2 = generateGuid();
insertJobSync(db, {
id: 'id-1',
timestamp: 1,
queueType: 'normal send',
data: {
conversationId: CONVERSATION_ID_1,
messageId: MESSAGE_ID_1,
},
});
insertJobSync(db, {
id: 'id-2',
timestamp: 2,
queueType: 'normal send',
data: {
conversationId: CONVERSATION_ID_2,
messageId: MESSAGE_ID_2,
},
});
insertJobSync(db, {
id: 'id-3-missing-data',
timestamp: 3,
queueType: 'normal send',
});
const totalJobs = db.prepare('SELECT COUNT(*) FROM jobs;').pluck();
const normalSend = db
.prepare("SELECT COUNT(*) FROM jobs WHERE queueType = 'normal send';")
.pluck();
const conversationJobs = db
.prepare("SELECT COUNT(*) FROM jobs WHERE queueType = 'conversation';")
.pluck();
assert.strictEqual(totalJobs.get(), 3, 'total jobs before');
assert.strictEqual(normalSend.get(), 3, 'normal send jobs before');
assert.strictEqual(conversationJobs.get(), 0, 'conversation jobs before');
updateToVersion(51);
assert.strictEqual(totalJobs.get(), 2, 'total jobs after');
assert.strictEqual(normalSend.get(), 0, 'normal send jobs after');
assert.strictEqual(conversationJobs.get(), 2, 'conversation jobs after');
const jobs = getJobsInQueueSync(db, 'conversation');
assert.deepEqual(jobs, [
{
id: 'id-1',
timestamp: 1,
queueType: 'conversation',
data: {
type: 'NormalMessage',
conversationId: CONVERSATION_ID_1,
messageId: MESSAGE_ID_1,
},
},
{
id: 'id-2',
timestamp: 2,
queueType: 'conversation',
data: {
type: 'NormalMessage',
conversationId: CONVERSATION_ID_2,
messageId: MESSAGE_ID_2,
},
},
]);
});
});
});

View File

@ -195,6 +195,7 @@ export type MessageOptionsType = {
export type GroupSendOptionsType = {
attachments?: Array<AttachmentType>;
expireTimer?: number;
flags?: number;
groupV2?: GroupV2InfoType;
groupV1?: GroupV1InfoType;
messageText?: string;
@ -764,20 +765,21 @@ export default class MessageSender {
options: Readonly<GroupSendOptionsType>
): MessageOptionsType {
const {
messageText,
timestamp,
attachments,
quote,
preview,
sticker,
reaction,
expireTimer,
profileKey,
deletedForEveryoneTimestamp,
groupV2,
groupV1,
mentions,
expireTimer,
flags,
groupCallUpdate,
groupV1,
groupV2,
mentions,
messageText,
preview,
profileKey,
quote,
reaction,
sticker,
timestamp,
} = options;
if (!groupV1 && !groupV2) {
@ -815,6 +817,7 @@ export default class MessageSender {
body: messageText,
deletedForEveryoneTimestamp,
expireTimer,
flags,
groupCallUpdate,
groupV2,
group: groupV1
@ -970,12 +973,14 @@ export default class MessageSender {
async sendIndividualProto({
contentHint,
groupId,
identifier,
options,
proto,
timestamp,
}: Readonly<{
contentHint: number;
groupId?: string;
identifier: string | undefined;
options?: SendOptionsType;
proto: Proto.DataMessage | Proto.Content | PlaintextContent;
@ -993,7 +998,7 @@ export default class MessageSender {
this.sendMessageProto({
callback,
contentHint,
groupId: undefined,
groupId,
options,
proto,
recipients: [identifier],
@ -1534,35 +1539,6 @@ export default class MessageSender {
// Sending messages to contacts
async sendProfileKeyUpdate(
profileKey: Readonly<Uint8Array>,
recipients: ReadonlyArray<string>,
options: Readonly<SendOptionsType>,
groupId?: string
): Promise<CallbackResultType> {
const { ContentHint } = Proto.UnidentifiedSenderMessage.Message;
return this.sendMessage({
messageOptions: {
recipients,
timestamp: Date.now(),
profileKey,
flags: Proto.DataMessage.Flags.PROFILE_KEY_UPDATE,
...(groupId
? {
group: {
id: groupId,
type: Proto.GroupContext.Type.DELIVER,
},
}
: {}),
},
contentHint: ContentHint.RESENDABLE,
groupId: undefined,
options,
});
}
async sendCallingMessage(
recipientId: string,
callingMessage: Readonly<Proto.ICallingMessage>,
@ -1699,29 +1675,6 @@ export default class MessageSender {
};
}
async sendExpirationTimerUpdateToIdentifier(
identifier: string,
expireTimer: number | undefined,
timestamp: number,
profileKey?: Readonly<Uint8Array>,
options?: Readonly<SendOptionsType>
): Promise<CallbackResultType> {
const { ContentHint } = Proto.UnidentifiedSenderMessage.Message;
return this.sendMessage({
messageOptions: {
recipients: [identifier],
timestamp,
expireTimer,
profileKey,
flags: Proto.DataMessage.Flags.EXPIRATION_TIMER_UPDATE,
},
contentHint: ContentHint.RESENDABLE,
groupId: undefined,
options,
});
}
async sendRetryRequest({
groupId,
options,
@ -2020,65 +1973,6 @@ export default class MessageSender {
});
}
async sendExpirationTimerUpdateToGroup(
groupId: string,
groupIdentifiers: ReadonlyArray<string>,
expireTimer: number | undefined,
timestamp: number,
profileKey?: Readonly<Uint8Array>,
options?: Readonly<SendOptionsType>
): Promise<CallbackResultType> {
const myNumber = window.textsecure.storage.user.getNumber();
const myUuid = window.textsecure.storage.user.getUuid()?.toString();
const recipients = groupIdentifiers.filter(
identifier => identifier !== myNumber && identifier !== myUuid
);
const messageOptions = {
recipients,
timestamp,
expireTimer,
profileKey,
flags: Proto.DataMessage.Flags.EXPIRATION_TIMER_UPDATE,
group: {
id: groupId,
type: Proto.GroupContext.Type.DELIVER,
},
};
const proto = await this.getContentMessage(messageOptions);
if (recipients.length === 0) {
return Promise.resolve({
successfulIdentifiers: [],
failoverIdentifiers: [],
errors: [],
unidentifiedDeliveries: [],
dataMessage: await this.getDataMessage(messageOptions),
});
}
const { ContentHint } = Proto.UnidentifiedSenderMessage.Message;
const contentHint = ContentHint.RESENDABLE;
const sendLogCallback =
groupIdentifiers.length > 1
? this.makeSendLogCallback({
contentHint,
proto: Buffer.from(Proto.Content.encode(proto).finish()),
sendType: 'expirationTimerUpdate',
timestamp,
})
: undefined;
return this.sendGroupProto({
contentHint,
groupId: undefined, // only for GV2 ids
options,
proto,
recipients,
sendLogCallback,
timestamp,
});
}
// Simple pass-throughs
async getProfile(

View File

@ -1,4 +1,4 @@
// Copyright 2021 Signal Messenger, LLC
// Copyright 2021-2022 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import type { ConversationAttributesType } from '../model-types.d';
@ -19,6 +19,7 @@ import {
} from './phoneNumberSharingMode';
import type { SerializedCertificateType } from '../textsecure/OutgoingMessage';
import { SenderCertificateMode } from '../textsecure/OutgoingMessage';
import { isNotNil } from './isNotNil';
const SEALED_SENDER = {
UNKNOWN: 0,
@ -27,6 +28,39 @@ const SEALED_SENDER = {
UNRESTRICTED: 3,
};
export async function getSendOptionsForRecipients(
recipients: ReadonlyArray<string>
): Promise<SendOptionsType> {
const conversations = recipients
.map(identifier => window.ConversationController.get(identifier))
.filter(isNotNil);
const metadataList = await Promise.all(
conversations.map(conversation => getSendOptions(conversation.attributes))
);
return metadataList.reduce(
(acc, current): SendOptionsType => {
const { sendMetadata: accMetadata } = acc;
const { sendMetadata: currentMetadata } = current;
if (!currentMetadata) {
return acc;
}
if (!accMetadata) {
return current;
}
Object.assign(accMetadata, currentMetadata);
return acc;
},
{
sendMetadata: {},
}
);
}
export async function getSendOptions(
conversationAttrs: ConversationAttributesType,
options: { syncMessage?: boolean } = {}

View File

@ -7826,15 +7826,15 @@
},
{
"rule": "jQuery-load(",
"path": "ts/jobs/normalMessageSendJobQueue.ts",
"path": "ts/jobs/conversationJobQueue.ts",
"line": " await window.ConversationController.load();",
"reasonCategory": "falseMatch",
"updated": "2021-12-15T19:58:28.089Z"
},
{
"rule": "jQuery-load(",
"path": "ts/jobs/reactionJobQueue.ts",
"line": " await window.ConversationController.load();",
"path": "ts/jobs/helpers/sendReaction.ts",
"line": " await window.ConversationController.load();",
"reasonCategory": "falseMatch",
"updated": "2021-11-04T16:14:03.477Z"
},
@ -8080,4 +8080,4 @@
"reasonCategory": "usageTrusted",
"updated": "2021-09-17T21:02:59.414Z"
}
]
]

View File

@ -30,6 +30,15 @@ export function isMe(conversationAttrs: ConversationAttributesType): boolean {
return Boolean((e164 && e164 === ourNumber) || (uuid && uuid === ourUuid));
}
export function isGroup(
conversationAttrs: Pick<
ConversationAttributesType,
'groupId' | 'groupVersion'
>
): boolean {
return isGroupV2(conversationAttrs) || isGroupV1(conversationAttrs);
}
export function isGroupV1(
conversationAttrs: Pick<ConversationAttributesType, 'groupId'>
): boolean {

View File

@ -0,0 +1,108 @@
// Copyright 2022 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import * as log from '../logging/log';
import { SendMessageProtoError } from '../textsecure/Errors';
import { getSendOptions } from './getSendOptions';
import { handleMessageSend } from './handleMessageSend';
import type { CallbackResultType } from '../textsecure/Types.d';
import type { ConversationModel } from '../models/conversations';
import type { SendTypesType } from './handleMessageSend';
import type MessageSender from '../textsecure/SendMessage';
import { areAllErrorsUnregistered } from '../jobs/helpers/areAllErrorsUnregistered';
export async function wrapWithSyncMessageSend({
conversation,
logId,
messageIds,
send,
sendType,
timestamp,
}: {
conversation: ConversationModel;
logId: string;
messageIds: Array<string>;
send: (sender: MessageSender) => Promise<CallbackResultType>;
sendType: SendTypesType;
timestamp: number;
}): Promise<void> {
const sender = window.textsecure.messaging;
if (!sender) {
throw new Error(
`wrapWithSyncMessageSend/${logId}: textsecure.messaging is not available!`
);
}
let response: CallbackResultType | undefined;
let error: Error | undefined;
let didSuccessfullySendOne = false;
try {
response = await handleMessageSend(send(sender), { messageIds, sendType });
didSuccessfullySendOne = true;
} catch (thrown) {
if (thrown instanceof SendMessageProtoError) {
didSuccessfullySendOne = Boolean(
thrown.successfulIdentifiers && thrown.successfulIdentifiers.length > 0
);
error = thrown;
}
if (thrown instanceof Error) {
error = thrown;
} else {
log.error(
`wrapWithSyncMessageSend/${logId}: Thrown value was not an Error, returning early`
);
throw error;
}
}
if (!response && !error) {
throw new Error(
`wrapWithSyncMessageSend/${logId}: message send didn't return result or error!`
);
}
const dataMessage =
response?.dataMessage ||
(error instanceof SendMessageProtoError ? error.dataMessage : undefined);
if (didSuccessfullySendOne) {
if (!dataMessage) {
log.error(
`wrapWithSyncMessageSend/${logId}: dataMessage was not returned by send!`
);
} else {
log.error(`wrapWithSyncMessageSend/${logId}: Sending sync message...`);
const ourConversation =
window.ConversationController.getOurConversationOrThrow();
const options = await getSendOptions(ourConversation.attributes, {
syncMessage: true,
});
await handleMessageSend(
sender.sendSyncMessage({
destination: ourConversation.get('e164'),
destinationUuid: ourConversation.get('uuid'),
encodedDataMessage: dataMessage,
expirationStartTimestamp: null,
options,
timestamp,
}),
{ messageIds, sendType: sendType === 'message' ? 'sentSync' : sendType }
);
}
}
if (error instanceof Error) {
if (areAllErrorsUnregistered(conversation.attributes, error)) {
log.info(
`wrapWithSyncMessageSend/${logId}: Group send failures were all UnregisteredUserError, returning succcessfully.`
);
return;
}
throw error;
}
}