diff --git a/_locales/en/messages.json b/_locales/en/messages.json index a33ea82ef..899a3fc1b 100644 --- a/_locales/en/messages.json +++ b/_locales/en/messages.json @@ -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", diff --git a/ts/RemoteConfig.ts b/ts/RemoteConfig.ts index 81b82d354..1834a7d69 100644 --- a/ts/RemoteConfig.ts +++ b/ts/RemoteConfig.ts @@ -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' diff --git a/ts/background.ts b/ts/background.ts index 9f42a574b..7aae6f36e 100644 --- a/ts/background.ts +++ b/ts/background.ts @@ -897,10 +897,12 @@ export async function startApp(): Promise { 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 { ), messagesByConversation: {}, messagesLookup: {}, - outboundMessagesPendingConversationVerification: {}, + verificationDataByConversation: {}, selectedConversationId: undefined, selectedMessage: undefined, selectedMessageCounter: 0, @@ -942,6 +944,7 @@ export async function startApp(): Promise { tempPath: window.baseTempPath, regionCode: window.storage.get('regionCode'), ourConversationId, + ourDeviceId, ourNumber, ourUuid, platform: window.platform, diff --git a/ts/components/App.tsx b/ts/components/App.tsx index 9eb2256d2..b416fae31 100644 --- a/ts/components/App.tsx +++ b/ts/components/App.tsx @@ -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 = ( ); } diff --git a/ts/components/Inbox.tsx b/ts/components/Inbox.tsx index 3a21b799d..826674f42 100644 --- a/ts/components/Inbox.tsx +++ b/ts/components/Inbox.tsx @@ -20,31 +20,29 @@ type InboxViewOptionsType = Backbone.ViewOptions & { }; export type PropsType = { - cancelMessagesPendingConversationVerification: () => void; - conversationsStoppingMessageSendBecauseOfVerification: Array; + cancelConversationVerification: () => void; + conversationsStoppingSend: Array; 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(null); const viewRef = useRef(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 = ( diff --git a/ts/groups.ts b/ts/groups.ts index dfc691549..c730c2f6d 100644 --- a/ts/groups.ts +++ b/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 { - 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; - send: (sender: MessageSender) => Promise; - sendType: SendTypesType; - timestamp: number; -}): Promise { - 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( diff --git a/ts/jobs/conversationJobQueue.ts b/ts/jobs/conversationJobQueue.ts new file mode 100644 index 000000000..fcacde677 --- /dev/null +++ b/ts/jobs/conversationJobQueue.ts @@ -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; + +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; + +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; + +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 { + private readonly inMemoryQueues = new InMemoryQueues(); + private readonly verificationWaitMap = new Map< + string, + { + resolve: (value: unknown) => unknown; + reject: (error: Error) => unknown; + promise: Promise; + } + >(); + + 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 { + 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 { + 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 = []; + 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, +}); diff --git a/ts/jobs/helpers/areAllErrorsUnregistered.ts b/ts/jobs/helpers/areAllErrorsUnregistered.ts new file mode 100644 index 000000000..32719b9e4 --- /dev/null +++ b/ts/jobs/helpers/areAllErrorsUnregistered.ts @@ -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) + ); +} diff --git a/ts/jobs/helpers/getUntrustedConversationIds.ts b/ts/jobs/helpers/getUntrustedConversationIds.ts new file mode 100644 index 000000000..3d5100c82 --- /dev/null +++ b/ts/jobs/helpers/getUntrustedConversationIds.ts @@ -0,0 +1,14 @@ +// Copyright 2022 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +export function getUntrustedConversationIds( + recipients: ReadonlyArray +): Array { + return recipients.filter(recipient => { + const recipientConversation = window.ConversationController.getOrCreate( + recipient, + 'private' + ); + return recipientConversation.isUntrusted(); + }); +} diff --git a/ts/jobs/helpers/handleMultipleSendErrors.ts b/ts/jobs/helpers/handleMultipleSendErrors.ts index 831b9f065..377fcc960 100644 --- a/ts/jobs/helpers/handleMultipleSendErrors.ts +++ b/ts/jobs/helpers/handleMultipleSendErrors.ts @@ -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 { + 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; isFinalAttempt: boolean; log: Pick; markFailed?: (() => void) | (() => Promise); timeRemaining: number; + toThrow: unknown; }>): Promise { strictAssert(errors.length, 'Expected at least one error'); @@ -66,5 +79,5 @@ export async function handleMultipleSendErrors({ }); } - throw errors[0]; + throw toThrow; } diff --git a/ts/jobs/helpers/sendDeleteForEveryone.ts b/ts/jobs/helpers/sendDeleteForEveryone.ts new file mode 100644 index 000000000..9bb2720c0 --- /dev/null +++ b/ts/jobs/helpers/sendDeleteForEveryone.ts @@ -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 { + 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, + }); + } + } + ); +} diff --git a/ts/jobs/helpers/sendDirectExpirationTimerUpdate.ts b/ts/jobs/helpers/sendDirectExpirationTimerUpdate.ts new file mode 100644 index 000000000..61d19e4de --- /dev/null +++ b/ts/jobs/helpers/sendDirectExpirationTimerUpdate.ts @@ -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 { + 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, + }); + } +} diff --git a/ts/jobs/helpers/sendGroupUpdate.ts b/ts/jobs/helpers/sendGroupUpdate.ts new file mode 100644 index 000000000..ee973267d --- /dev/null +++ b/ts/jobs/helpers/sendGroupUpdate.ts @@ -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 { + 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, + }); + } +} diff --git a/ts/jobs/helpers/sendNormalMessage.ts b/ts/jobs/helpers/sendNormalMessage.ts new file mode 100644 index 000000000..96da39938 --- /dev/null +++ b/ts/jobs/helpers/sendNormalMessage.ts @@ -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 { + 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 = []; + + // 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) => { + 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; + + 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; + 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; + recipientIdentifiersWithoutMe: Array; + untrustedConversationIds: Array; +} { + const allRecipientIdentifiers: Array = []; + const recipientIdentifiersWithoutMe: Array = []; + const untrustedConversationIds: Array = []; + + 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; + body: undefined | string; + deletedForEveryoneTimestamp: undefined | number; + expireTimer: undefined | number; + mentions: undefined | BodyRangesType; + messageTimestamp: number; + preview: Array; + 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 +): Promise { + 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): boolean { + const sendStateByConversationId = + message.get('sendStateByConversationId') || {}; + return Object.values(sendStateByConversationId).every(sendState => + isSent(sendState.status) + ); +} diff --git a/ts/jobs/helpers/sendProfileKey.ts b/ts/jobs/helpers/sendProfileKey.ts new file mode 100644 index 000000000..ba190b411 --- /dev/null +++ b/ts/jobs/helpers/sendProfileKey.ts @@ -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 { + 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; + + // 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, + }); + } +} diff --git a/ts/jobs/helpers/sendReaction.ts b/ts/jobs/helpers/sendReaction.ts new file mode 100644 index 000000000..f9773c26b --- /dev/null +++ b/ts/jobs/helpers/sendReaction.ts @@ -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 { + 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 = []; + const saveErrors = (errors: Array): 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(); + + 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; + 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 => + message.get('reactions') || []; + +const setReactions = ( + message: MessageModel, + reactions: Array +): void => { + if (reactions.length) { + message.set('reactions', reactions); + } else { + message.unset('reactions'); + } +}; + +function getRecipients( + reaction: Readonly, + conversation: ConversationModel +): { + allRecipientIdentifiers: Array; + recipientIdentifiersWithoutMe: Array; + untrustedConversationIds: Array; +} { + const allRecipientIdentifiers: Array = []; + const recipientIdentifiersWithoutMe: Array = []; + const untrustedConversationIds: Array = []; + + 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); +} diff --git a/ts/jobs/initializeAllJobQueues.ts b/ts/jobs/initializeAllJobQueues.ts index 2d1dd4d1b..97a20ae7f 100644 --- a/ts/jobs/initializeAllJobQueues.ts +++ b/ts/jobs/initializeAllJobQueues.ts @@ -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(); diff --git a/ts/jobs/normalMessageSendJobQueue.ts b/ts/jobs/normalMessageSendJobQueue.ts deleted file mode 100644 index 238e89c75..000000000 --- a/ts/jobs/normalMessageSendJobQueue.ts +++ /dev/null @@ -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 { - 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 { - 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 = []; - - // 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) => { - 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; - - 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; - 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; - recipientIdentifiersWithoutMe: Array; - untrustedConversationIds: Array; -} { - const allRecipientIdentifiers: Array = []; - const recipientIdentifiersWithoutMe: Array = []; - const untrustedConversationIds: Array = []; - - 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; - body: undefined | string; - deletedForEveryoneTimestamp: undefined | number; - expireTimer: undefined | number; - mentions: undefined | BodyRangesType; - messageTimestamp: number; - preview: Array; - 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 -): Promise { - 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): boolean { - const sendStateByConversationId = - message.get('sendStateByConversationId') || {}; - return Object.values(sendStateByConversationId).every(sendState => - isSent(sendState.status) - ); -} diff --git a/ts/jobs/reactionJobQueue.ts b/ts/jobs/reactionJobQueue.ts deleted file mode 100644 index dea1de8fa..000000000 --- a/ts/jobs/reactionJobQueue.ts +++ /dev/null @@ -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; - -export class ReactionJobQueue extends JobQueue { - private readonly inMemoryQueues = new InMemoryQueues(); - - protected parseData(data: unknown): ReactionJobData { - return reactionJobData.parse(data); - } - - protected override getInMemoryQueue({ - data, - }: Readonly<{ data: Pick }>): 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 { - 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 = []; - const saveErrors = (errors: Array): 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(); - - 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; - 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 => - message.get('reactions') || []; - -const setReactions = ( - message: MessageModel, - reactions: Array -): void => { - if (reactions.length) { - message.set('reactions', reactions); - } else { - message.unset('reactions'); - } -}; - -function getRecipients( - reaction: Readonly, - conversation: ConversationModel -): { - allRecipientIdentifiers: Array; - recipientIdentifiersWithoutMe: Array; -} { - const allRecipientIdentifiers: Array = []; - const recipientIdentifiersWithoutMe: Array = []; - - 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); -} diff --git a/ts/jobs/singleProtoJobQueue.ts b/ts/jobs/singleProtoJobQueue.ts index fc099a126..7e61f030e 100644 --- a/ts/jobs/singleProtoJobQueue.ts +++ b/ts/jobs/singleProtoJobQueue.ts @@ -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 { { 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, }); } } diff --git a/ts/models/conversations.ts b/ts/models/conversations.ts index 9a403dbde..853931491 100644 --- a/ts/models/conversations.ts +++ b/ts/models/conversations.ts @@ -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 { const uuid = this.getUuid(); if (!uuid) { @@ -3529,6 +3505,11 @@ export class ConversationModel extends window.Backbone includePendingMembers?: boolean; extraConversationsForSend?: Array; } = {}): Array { + 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 { - 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 { - 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( diff --git a/ts/models/messages.ts b/ts/models/messages.ts index c48f7a96f..5b0f3d815 100644 --- a/ts/models/messages.ts +++ b/ts/models/messages.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 { 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 { 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 { async sendSyncMessageOnly( dataMessage: Uint8Array, saveErrors?: (errors: Array) => void - ): Promise { + ): Promise { // 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 { ? 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 { // 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 { ); 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 { }); }); } else { - await reactionJobQueue.add(jobData); + await conversationJobQueue.add(jobData); } } else if (shouldPersist) { await window.Signal.Data.saveMessage(this.attributes, { diff --git a/ts/services/calling.ts b/ts/services/calling.ts index 928147cda..14fa3dd99 100644 --- a/ts/services/calling.ts +++ b/ts/services/calling.ts @@ -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'; diff --git a/ts/sql/Server.ts b/ts/sql/Server.ts index de24ac672..2df0d4fdd 100644 --- a/ts/sql/Server.ts +++ b/ts/sql/Server.ts @@ -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): Promise { async function getMessageById(id: string): Promise { const db = getInstance(); + return getMessageByIdSync(db, id); +} + +export function getMessageByIdSync( + db: Database, + id: string +): MessageType | undefined { const row = db .prepare('SELECT json FROM messages WHERE id = $id;') .get({ @@ -4549,7 +4556,13 @@ async function removeKnownDraftAttachments( async function getJobsInQueue(queueType: string): Promise> { const db = getInstance(); + return getJobsInQueueSync(db, queueType); +} +export function getJobsInQueueSync( + db: Database, + queueType: string +): Array { return db .prepare( ` @@ -4568,7 +4581,7 @@ async function getJobsInQueue(queueType: string): Promise> { })); } -function insertJobSync(db: Database, job: Readonly): void { +export function insertJobSync(db: Database, job: Readonly): void { db.prepare( ` INSERT INTO jobs diff --git a/ts/sql/migrations/51-centralize-conversation-jobs.ts b/ts/sql/migrations/51-centralize-conversation-jobs.ts new file mode 100644 index 000000000..5355a3837 --- /dev/null +++ b/ts/sql/migrations/51-centralize-conversation-jobs.ts @@ -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!'); +} diff --git a/ts/sql/migrations/index.ts b/ts/sql/migrations/index.ts index f0a03c4f2..dc15d8890 100644 --- a/ts/sql/migrations/index.ts +++ b/ts/sql/migrations/index.ts @@ -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 { diff --git a/ts/state/ducks/conversations.ts b/ts/state/ducks/conversations.ts index 73b54eb14..4336c1593 100644 --- a/ts/state/ducks/conversations.ts +++ b/ts/state/ducks/conversations.ts @@ -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; }; +export type ConversationVerificationData = + | { + type: ConversationVerificationState.PendingVerification; + conversationsNeedingVerification: ReadonlyArray; + } + | { + 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 - >; + verificationDataByConversation: Record; // 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): Promise => { - 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; }; }; @@ -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(); - const promises: Array> = []; - - 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> = []; + 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 { - 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 -): MessageStoppedByMissingVerificationActionType { +function conversationStoppedByMissingVerification(payload: { + conversationId: string; + untrustedConversationIds: ReadonlyArray; +}): 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 = getEmptyState(), action: Readonly ): 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 = 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') { diff --git a/ts/state/ducks/conversationsEnums.ts b/ts/state/ducks/conversationsEnums.ts index 802c82652..956898688 100644 --- a/ts/state/ducks/conversationsEnums.ts +++ b/ts/state/ducks/conversationsEnums.ts @@ -28,3 +28,8 @@ export enum OneTimeModalState { Showing, Shown, } + +export enum ConversationVerificationState { + PendingVerification = 'PendingVerification', + VerificationCancelled = 'VerificationCancelled', +} diff --git a/ts/state/selectors/conversations.ts b/ts/state/selectors/conversations.ts index 0b2427376..382150e30 100644 --- a/ts/state/selectors/conversations.ts +++ b/ts/state/selectors/conversations.ts @@ -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 - ): Record> => - conversations.outboundMessagesPendingConversationVerification + ): Record => + conversations.verificationDataByConversation ); -const getConversationIdsStoppingMessageSendBecauseOfVerification = - createSelector( - getOutboundMessagesPendingConversationVerification, - (outboundMessagesPendingConversationVerification): Array => - Object.keys(outboundMessagesPendingConversationVerification) - ); +export const getConversationIdsStoppedForVerification = createSelector( + getConversationVerificationData, + (verificationDataByConversation): Array => + Object.keys(verificationDataByConversation) +); -export const getConversationsStoppingMessageSendBecauseOfVerification = - createSelector( - getConversationByIdSelector, - getConversationIdsStoppingMessageSendBecauseOfVerification, - ( - conversationSelector: (id: string) => undefined | ConversationType, - conversationIds: ReadonlyArray - ): Array => { - const conversations = conversationIds - .map(conversationId => conversationSelector(conversationId)) - .filter(isNotNil); - return sortByTitle(conversations); - } - ); - -export const getMessageIdsPendingBecauseOfVerification = createSelector( - getOutboundMessagesPendingConversationVerification, - (outboundMessagesPendingConversationVerification): Set => { - const result = new Set(); - 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 + ): Array => { + const conversations = conversationIds + .map(conversationId => conversationSelector(conversationId)) + .filter(isNotNil); + return sortByTitle(conversations); } ); -export const getNumberOfMessagesPendingBecauseOfVerification = createSelector( - getMessageIdsPendingBecauseOfVerification, - (messageIds: Readonly>): number => messageIds.size +export const getConversationIdsStoppingSend = createSelector( + getConversationVerificationData, + (pendingData): Array => { + const result = new Set(); + 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 + ): Array => { + const conversations = conversationIds + .map(conversationId => conversationSelector(conversationId)) + .filter(isNotNil); + return sortByTitle(conversations); + } ); diff --git a/ts/state/smart/App.tsx b/ts/state/smart/App.tsx index d8c49f5bb..019e6060f 100644 --- a/ts/state/smart/App.tsx +++ b/ts/state/smart/App.tsx @@ -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: () => , renderCustomizingPreferredReactionsModal: () => ( diff --git a/ts/test-both/state/selectors/conversations_test.ts b/ts/test-both/state/selectors/conversations_test.ts index 734f8872b..9046e0e69 100644 --- a/ts/test-both/state/selectors/conversations_test.ts +++ b/ts/test-both/state/selectors/conversations_test.ts @@ -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, + ]); }); }); diff --git a/ts/test-electron/state/ducks/conversations_test.ts b/ts/test-electron/state/ducks/conversations_test.ts index 75797639b..2870d8d2d 100644 --- a/ts/test-electron/state/ducks/conversations_test.ts +++ b/ts/test-electron/state/ducks/conversations_test.ts @@ -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); }); }); diff --git a/ts/test-node/components/leftPane/LeftPaneInboxHelper_test.tsx b/ts/test-node/components/leftPane/LeftPaneInboxHelper_test.tsx index 8512416b7..a3c4f02ca 100644 --- a/ts/test-node/components/leftPane/LeftPaneInboxHelper_test.tsx +++ b/ts/test-node/components/leftPane/LeftPaneInboxHelper_test.tsx @@ -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(), diff --git a/ts/test-node/jobs/helpers/handleMultipleSendErrors_test.ts b/ts/test-node/jobs/helpers/handleMultipleSendErrors_test.ts index 38614cd44..579856a92 100644 --- a/ts/test-node/jobs/helpers/handleMultipleSendErrors_test.ts +++ b/ts/test-node/jobs/helpers/handleMultipleSendErrors_test.ts @@ -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; + + 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); diff --git a/ts/test-node/sql_migrations_test.ts b/ts/test-node/sql_migrations_test.ts index c2b67bc95..0786b4c67 100644 --- a/ts/test-node/sql_migrations_test.ts +++ b/ts/test-node/sql_migrations_test.ts @@ -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, + }, + }, + ]); + }); + }); }); diff --git a/ts/textsecure/SendMessage.ts b/ts/textsecure/SendMessage.ts index e385196fc..be9fd7af2 100644 --- a/ts/textsecure/SendMessage.ts +++ b/ts/textsecure/SendMessage.ts @@ -195,6 +195,7 @@ export type MessageOptionsType = { export type GroupSendOptionsType = { attachments?: Array; expireTimer?: number; + flags?: number; groupV2?: GroupV2InfoType; groupV1?: GroupV1InfoType; messageText?: string; @@ -764,20 +765,21 @@ export default class MessageSender { options: Readonly ): 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, - recipients: ReadonlyArray, - options: Readonly, - groupId?: string - ): Promise { - 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, @@ -1699,29 +1675,6 @@ export default class MessageSender { }; } - async sendExpirationTimerUpdateToIdentifier( - identifier: string, - expireTimer: number | undefined, - timestamp: number, - profileKey?: Readonly, - options?: Readonly - ): Promise { - 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, - expireTimer: number | undefined, - timestamp: number, - profileKey?: Readonly, - options?: Readonly - ): Promise { - 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( diff --git a/ts/util/getSendOptions.ts b/ts/util/getSendOptions.ts index 765413d4b..f6388418f 100644 --- a/ts/util/getSendOptions.ts +++ b/ts/util/getSendOptions.ts @@ -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 +): Promise { + 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 } = {} diff --git a/ts/util/lint/exceptions.json b/ts/util/lint/exceptions.json index 5fe30a675..935e82993 100644 --- a/ts/util/lint/exceptions.json +++ b/ts/util/lint/exceptions.json @@ -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" } -] \ No newline at end of file +] diff --git a/ts/util/whatTypeOfConversation.ts b/ts/util/whatTypeOfConversation.ts index 5cce2f247..bcad8fce3 100644 --- a/ts/util/whatTypeOfConversation.ts +++ b/ts/util/whatTypeOfConversation.ts @@ -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 ): boolean { diff --git a/ts/util/wrapWithSyncMessageSend.ts b/ts/util/wrapWithSyncMessageSend.ts new file mode 100644 index 000000000..5117f22ac --- /dev/null +++ b/ts/util/wrapWithSyncMessageSend.ts @@ -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; + send: (sender: MessageSender) => Promise; + sendType: SendTypesType; + timestamp: number; +}): Promise { + 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; + } +}