Introduce new conversationJobQueue
This commit is contained in:
parent
37d4776472
commit
30783c887c
40 changed files with 3111 additions and 1742 deletions
|
@ -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",
|
||||
|
|
|
@ -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'
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -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}
|
||||
/>
|
||||
|
|
238
ts/groups.ts
238
ts/groups.ts
|
@ -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(
|
||||
|
|
347
ts/jobs/conversationJobQueue.ts
Normal file
347
ts/jobs/conversationJobQueue.ts
Normal 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,
|
||||
});
|
20
ts/jobs/helpers/areAllErrorsUnregistered.ts
Normal file
20
ts/jobs/helpers/areAllErrorsUnregistered.ts
Normal 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)
|
||||
);
|
||||
}
|
14
ts/jobs/helpers/getUntrustedConversationIds.ts
Normal file
14
ts/jobs/helpers/getUntrustedConversationIds.ts
Normal 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();
|
||||
});
|
||||
}
|
|
@ -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;
|
||||
}
|
||||
|
|
149
ts/jobs/helpers/sendDeleteForEveryone.ts
Normal file
149
ts/jobs/helpers/sendDeleteForEveryone.ts
Normal 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,
|
||||
});
|
||||
}
|
||||
}
|
||||
);
|
||||
}
|
125
ts/jobs/helpers/sendDirectExpirationTimerUpdate.ts
Normal file
125
ts/jobs/helpers/sendDirectExpirationTimerUpdate.ts
Normal 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,
|
||||
});
|
||||
}
|
||||
}
|
124
ts/jobs/helpers/sendGroupUpdate.ts
Normal file
124
ts/jobs/helpers/sendGroupUpdate.ts
Normal 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,
|
||||
});
|
||||
}
|
||||
}
|
446
ts/jobs/helpers/sendNormalMessage.ts
Normal file
446
ts/jobs/helpers/sendNormalMessage.ts
Normal 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)
|
||||
);
|
||||
}
|
146
ts/jobs/helpers/sendProfileKey.ts
Normal file
146
ts/jobs/helpers/sendProfileKey.ts
Normal 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,
|
||||
});
|
||||
}
|
||||
}
|
377
ts/jobs/helpers/sendReaction.ts
Normal file
377
ts/jobs/helpers/sendReaction.ts
Normal 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);
|
||||
}
|
|
@ -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();
|
||||
|
|
|
@ -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)
|
||||
);
|
||||
}
|
|
@ -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);
|
||||
}
|
|
@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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(
|
||||
|
|
|
@ -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, {
|
||||
|
|
|
@ -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';
|
||||
|
|
|
@ -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
|
||||
|
|
109
ts/sql/migrations/51-centralize-conversation-jobs.ts
Normal file
109
ts/sql/migrations/51-centralize-conversation-jobs.ts
Normal 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!');
|
||||
}
|
|
@ -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 {
|
||||
|
|
|
@ -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') {
|
||||
|
|
|
@ -28,3 +28,8 @@ export enum OneTimeModalState {
|
|||
Showing,
|
||||
Shown,
|
||||
}
|
||||
|
||||
export enum ConversationVerificationState {
|
||||
PendingVerification = 'PendingVerification',
|
||||
VerificationCancelled = 'VerificationCancelled',
|
||||
}
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
);
|
||||
|
|
|
@ -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 />
|
||||
|
|
|
@ -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,
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
@ -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(),
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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,
|
||||
},
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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(
|
||||
|
|
|
@ -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 } = {}
|
||||
|
|
|
@ -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"
|
||||
}
|
||||
]
|
||||
]
|
||||
|
|
|
@ -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 {
|
||||
|
|
108
ts/util/wrapWithSyncMessageSend.ts
Normal file
108
ts/util/wrapWithSyncMessageSend.ts
Normal 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;
|
||||
}
|
||||
}
|
Loading…
Add table
Reference in a new issue