diff --git a/ts/CI.ts b/ts/CI.ts index d3174793f..0f56f4874 100644 --- a/ts/CI.ts +++ b/ts/CI.ts @@ -15,6 +15,7 @@ import { migrateAllMessages } from './messages/migrateMessageData'; import { SECOND } from './util/durations'; import { isSignalRoute } from './util/signalRoutes'; import { strictAssert } from './util/assert'; +import { MessageModel } from './models/messages'; type ResolveType = (data: unknown) => void; @@ -142,12 +143,7 @@ export function getCI({ deviceName }: GetCIOptionsType): CIType { [sentAt] ); return messages.map( - m => - window.MessageCache.__DEPRECATED$register( - m.id, - m, - 'CI.getMessagesBySentAt' - ).attributes + m => window.MessageCache.register(new MessageModel(m)).attributes ); } diff --git a/ts/CI/benchmarkConversationOpen.ts b/ts/CI/benchmarkConversationOpen.ts index 18fd4277a..fd50e12d4 100644 --- a/ts/CI/benchmarkConversationOpen.ts +++ b/ts/CI/benchmarkConversationOpen.ts @@ -16,6 +16,7 @@ import { stats } from '../util/benchmark/stats'; import type { StatsType } from '../util/benchmark/stats'; import type { MessageAttributesType } from '../model-types.d'; import * as log from '../logging/log'; +import { postSaveUpdates } from '../util/cleanup'; const BUFFER_DELAY_MS = 50; @@ -90,6 +91,7 @@ export async function populateConversationWithMessages({ await DataWriter.saveMessages(messages, { forceSave: true, ourAci, + postSaveUpdates, }); conversation.set('active_at', Date.now()); diff --git a/ts/background.ts b/ts/background.ts index 423b63106..0655366b2 100644 --- a/ts/background.ts +++ b/ts/background.ts @@ -203,6 +203,9 @@ import { maybeQueueDeviceNameFetch, onDeviceNameChangeSync, } from './util/onDeviceNameChangeSync'; +import { postSaveUpdates } from './util/cleanup'; +import { handleDataMessage } from './messages/handleDataMessage'; +import { MessageModel } from './models/messages'; export function isOverHourIntoPast(timestamp: number): boolean { return isNumber(timestamp) && isOlderThan(timestamp, HOUR); @@ -1421,7 +1424,7 @@ export async function startApp(): Promise { void badgeImageFileDownloader.checkForFilesToDownload(); - initializeExpiringMessageService(singleProtoJobQueue); + initializeExpiringMessageService(); log.info('Blocked uuids cleanup: starting...'); const blockedUuids = window.storage.get(BLOCKED_UUIDS_ID, []); @@ -1473,6 +1476,7 @@ export async function startApp(): Promise { await DataWriter.saveMessages(newMessageAttributes, { ourAci: window.textsecure.storage.user.getCheckedAci(), + postSaveUpdates, }); } log.info('Expiration start timestamp cleanup: complete'); @@ -2627,7 +2631,7 @@ export async function startApp(): Promise { } // Don't wait for handleDataMessage, as it has its own per-conversation queueing - drop(message.handleDataMessage(data.message, event.confirm)); + drop(handleDataMessage(message, data.message, event.confirm)); } async function onProfileKey({ @@ -2803,7 +2807,7 @@ export async function startApp(): Promise { unidentifiedDeliveries, }; - return new window.Whisper.Message(partialMessage); + return new MessageModel(partialMessage); } // Works with 'sent' and 'message' data sent from MessageReceiver @@ -3021,7 +3025,7 @@ export async function startApp(): Promise { // Don't wait for handleDataMessage, as it has its own per-conversation queueing drop( - message.handleDataMessage(data.message, event.confirm, { + handleDataMessage(message, data.message, event.confirm, { data, }) ); @@ -3060,7 +3064,7 @@ export async function startApp(): Promise { type: data.message.isStory ? 'story' : 'incoming', unidentifiedDeliveryReceived: data.unidentifiedDeliveryReceived, }; - return new window.Whisper.Message(partialMessage); + return new MessageModel(partialMessage); } // Returns `false` if this message isn't a group call message. diff --git a/ts/groups.ts b/ts/groups.ts index 704617535..fbbac7d99 100644 --- a/ts/groups.ts +++ b/ts/groups.ts @@ -102,6 +102,8 @@ import { } from './util/groupSendEndorsements'; import { getProfile } from './util/getProfile'; import { generateMessageId } from './util/generateMessageId'; +import { postSaveUpdates } from './util/cleanup'; +import { MessageModel } from './models/messages'; type AccessRequiredEnum = Proto.AccessControl.AccessRequired; @@ -253,7 +255,7 @@ export type GroupV2ChangeDetailType = export type GroupV2ChangeType = { from?: ServiceIdString; - details: Array; + details: ReadonlyArray; }; export type GroupFields = { @@ -2016,7 +2018,7 @@ export async function createGroupV2( revision: groupV2Info.revision, }); - const createdTheGroupMessage: MessageAttributesType = { + const createdTheGroupMessage = new MessageModel({ ...generateMessageId(incrementMessageCounter()), schemaVersion: MAX_MESSAGE_SCHEMA, @@ -2032,17 +2034,12 @@ export async function createGroupV2( from: ourAci, details: [{ type: 'create' }], }, - }; - await DataWriter.saveMessages([createdTheGroupMessage], { - forceSave: true, - ourAci, }); - window.MessageCache.__DEPRECATED$register( - createdTheGroupMessage.id, - new window.Whisper.Message(createdTheGroupMessage), - 'createGroupV2' - ); - conversation.trigger('newmessage', createdTheGroupMessage); + await window.MessageCache.saveMessage(createdTheGroupMessage, { + forceSave: true, + }); + window.MessageCache.register(createdTheGroupMessage); + drop(conversation.onNewMessage(createdTheGroupMessage)); if (expireTimer) { await conversation.updateExpirationTimer(expireTimer, { @@ -3442,6 +3439,7 @@ async function appendChangeMessages( log.info(`appendChangeMessages/${logId}: updating ${first.id}`); await DataWriter.saveMessage(first, { ourAci, + postSaveUpdates, // We don't use forceSave here because this is an update of existing // message. @@ -3453,6 +3451,7 @@ async function appendChangeMessages( await DataWriter.saveMessages(rest, { ourAci, forceSave: true, + postSaveUpdates, }); } else { log.info( @@ -3461,15 +3460,13 @@ async function appendChangeMessages( await DataWriter.saveMessages(mergedMessages, { ourAci, forceSave: true, + postSaveUpdates, }); } let newMessages = 0; for (const changeMessage of mergedMessages) { - const existing = window.MessageCache.__DEPRECATED$getById( - changeMessage.id, - 'appendChangeMessages' - ); + const existing = window.MessageCache.getById(changeMessage.id); // Update existing message if (existing) { @@ -3481,12 +3478,8 @@ async function appendChangeMessages( continue; } - window.MessageCache.__DEPRECATED$register( - changeMessage.id, - new window.Whisper.Message(changeMessage), - 'appendChangeMessages' - ); - conversation.trigger('newmessage', changeMessage); + const model = window.MessageCache.register(new MessageModel(changeMessage)); + drop(conversation.onNewMessage(model)); newMessages += 1; } diff --git a/ts/jobs/AttachmentDownloadManager.ts b/ts/jobs/AttachmentDownloadManager.ts index e6d987fbb..00365e266 100644 --- a/ts/jobs/AttachmentDownloadManager.ts +++ b/ts/jobs/AttachmentDownloadManager.ts @@ -24,7 +24,7 @@ import { AttachmentVariant, mightBeOnBackupTier, } from '../types/Attachment'; -import { __DEPRECATED$getMessageById } from '../messages/getMessageById'; +import { getMessageById } from '../messages/getMessageById'; import { KIBIBYTE, getMaximumIncomingAttachmentSizeInKb, @@ -52,6 +52,7 @@ import { } from '../AttachmentCrypto'; import { safeParsePartial } from '../util/schemas'; import { createBatcher } from '../util/batcher'; +import { postSaveUpdates } from '../util/cleanup'; export enum AttachmentDownloadUrgency { IMMEDIATE = 'immediate', @@ -327,10 +328,7 @@ async function runDownloadAttachmentJob({ const jobIdForLogging = getJobIdForLogging(job); const logId = `AttachmentDownloadManager/runDownloadAttachmentJob/${jobIdForLogging}`; - const message = await __DEPRECATED$getMessageById( - job.messageId, - 'runDownloadAttachmentJob' - ); + const message = await getMessageById(job.messageId); if (!message) { log.error(`${logId} message not found`); @@ -430,6 +428,7 @@ async function runDownloadAttachmentJob({ // is good await DataWriter.saveMessage(message.attributes, { ourAci: window.textsecure.storage.user.getCheckedAci(), + postSaveUpdates, }); } } diff --git a/ts/jobs/helpers/sendDeleteForEveryone.ts b/ts/jobs/helpers/sendDeleteForEveryone.ts index 7930cf09e..26131e343 100644 --- a/ts/jobs/helpers/sendDeleteForEveryone.ts +++ b/ts/jobs/helpers/sendDeleteForEveryone.ts @@ -28,7 +28,7 @@ import { getUntrustedConversationServiceIds } from './getUntrustedConversationSe import { handleMessageSend } from '../../util/handleMessageSend'; import { isConversationAccepted } from '../../util/isConversationAccepted'; import { isConversationUnregistered } from '../../util/isConversationUnregistered'; -import { __DEPRECATED$getMessageById } from '../../messages/getMessageById'; +import { getMessageById } from '../../messages/getMessageById'; import { isNotNil } from '../../util/isNotNil'; import type { CallbackResultType } from '../../textsecure/Types.d'; import type { MessageModel } from '../../models/messages'; @@ -38,6 +38,7 @@ import type { LoggerType } from '../../types/Logging'; import type { ServiceIdString } from '../../types/ServiceId'; import { isStory } from '../../messages/helpers'; import { sendToGroup } from '../../util/sendToGroup'; +import { postSaveUpdates } from '../../util/cleanup'; export async function sendDeleteForEveryone( conversation: ConversationModel, @@ -60,7 +61,7 @@ export async function sendDeleteForEveryone( const logId = `sendDeleteForEveryone(${conversation.idForLogging()}, ${messageId})`; - const message = await __DEPRECATED$getMessageById(messageId, logId); + const message = await getMessageById(messageId); if (!message) { log.error(`${logId}: Failed to fetch message. Failing job.`); return; @@ -307,6 +308,7 @@ async function updateMessageWithSuccessfulSends( }); await DataWriter.saveMessage(message.attributes, { ourAci: window.textsecure.storage.user.getCheckedAci(), + postSaveUpdates, }); return; @@ -330,6 +332,7 @@ async function updateMessageWithSuccessfulSends( }); await DataWriter.saveMessage(message.attributes, { ourAci: window.textsecure.storage.user.getCheckedAci(), + postSaveUpdates, }); } @@ -346,5 +349,6 @@ async function updateMessageWithFailure( message.set({ deletedForEveryoneFailed: true }); await DataWriter.saveMessage(message.attributes, { ourAci: window.textsecure.storage.user.getCheckedAci(), + postSaveUpdates, }); } diff --git a/ts/jobs/helpers/sendDeleteStoryForEveryone.ts b/ts/jobs/helpers/sendDeleteStoryForEveryone.ts index 9846da35a..1b454a53d 100644 --- a/ts/jobs/helpers/sendDeleteStoryForEveryone.ts +++ b/ts/jobs/helpers/sendDeleteStoryForEveryone.ts @@ -21,7 +21,7 @@ import { getUntrustedConversationServiceIds } from './getUntrustedConversationSe import { handleMessageSend } from '../../util/handleMessageSend'; import { isConversationAccepted } from '../../util/isConversationAccepted'; import { isConversationUnregistered } from '../../util/isConversationUnregistered'; -import { __DEPRECATED$getMessageById } from '../../messages/getMessageById'; +import { getMessageById } from '../../messages/getMessageById'; import { isNotNil } from '../../util/isNotNil'; import type { CallbackResultType } from '../../textsecure/Types.d'; import type { MessageModel } from '../../models/messages'; @@ -29,6 +29,7 @@ import { SendMessageProtoError } from '../../textsecure/Errors'; import { strictAssert } from '../../util/assert'; import type { LoggerType } from '../../types/Logging'; import { isStory } from '../../messages/helpers'; +import { postSaveUpdates } from '../../util/cleanup'; export async function sendDeleteStoryForEveryone( ourConversation: ConversationModel, @@ -46,10 +47,7 @@ export async function sendDeleteStoryForEveryone( const logId = `sendDeleteStoryForEveryone(${storyId})`; - const message = await __DEPRECATED$getMessageById( - storyId, - 'sendDeleteStoryForEveryone' - ); + const message = await getMessageById(storyId); if (!message) { log.error(`${logId}: Failed to fetch message. Failing job.`); return; @@ -284,6 +282,7 @@ async function updateMessageWithSuccessfulSends( }); await DataWriter.saveMessage(message.attributes, { ourAci: window.textsecure.storage.user.getCheckedAci(), + postSaveUpdates, }); return; @@ -307,6 +306,7 @@ async function updateMessageWithSuccessfulSends( }); await DataWriter.saveMessage(message.attributes, { ourAci: window.textsecure.storage.user.getCheckedAci(), + postSaveUpdates, }); } @@ -323,5 +323,6 @@ async function updateMessageWithFailure( message.set({ deletedForEveryoneFailed: true }); await DataWriter.saveMessage(message.attributes, { ourAci: window.textsecure.storage.user.getCheckedAci(), + postSaveUpdates, }); } diff --git a/ts/jobs/helpers/sendNormalMessage.ts b/ts/jobs/helpers/sendNormalMessage.ts index 7f551f55c..87a6db3a8 100644 --- a/ts/jobs/helpers/sendNormalMessage.ts +++ b/ts/jobs/helpers/sendNormalMessage.ts @@ -8,7 +8,7 @@ import { DataWriter } from '../../sql/Client'; import * as Errors from '../../types/errors'; import { strictAssert } from '../../util/assert'; import type { MessageModel } from '../../models/messages'; -import { __DEPRECATED$getMessageById } from '../../messages/getMessageById'; +import { getMessageById } from '../../messages/getMessageById'; import type { ConversationModel } from '../../models/conversations'; import { isGroup, isGroupV2, isMe } from '../../util/whatTypeOfConversation'; import { getSendOptions } from '../../util/getSendOptions'; @@ -56,6 +56,13 @@ import { import { getMessageSentTimestamp } from '../../util/getMessageSentTimestamp'; import { isSignalConversation } from '../../util/isSignalConversation'; import { isBodyTooLong, trimBody } from '../../util/longAttachment'; +import { + markFailed, + saveErrorsOnMessage, +} from '../../test-node/util/messageFailures'; +import { getMessageIdForLogging } from '../../util/idForLogging'; +import { postSaveUpdates } from '../../util/cleanup'; +import { send, sendSyncMessageOnly } from '../../messages/send'; const MAX_CONCURRENT_ATTACHMENT_UPLOADS = 5; @@ -73,10 +80,7 @@ export async function sendNormalMessage( const { Message } = window.Signal.Types; const { messageId, revision, editedMessageTimestamp } = data; - const message = await __DEPRECATED$getMessageById( - messageId, - 'sendNormalMessage' - ); + const message = await getMessageById(messageId); if (!message) { log.info( `message ${messageId} was not found, maybe because it was deleted. Giving up on sending it` @@ -84,7 +88,9 @@ export async function sendNormalMessage( return; } - const messageConversation = message.getConversation(); + const messageConversation = window.ConversationController.get( + message.get('conversationId') + ); if (messageConversation !== conversation) { log.error( `Message conversation '${messageConversation?.idForLogging()}' does not match job conversation ${conversation.idForLogging()}` @@ -106,7 +112,7 @@ export async function sendNormalMessage( return; } - if (message.isErased() || message.get('deletedForEveryone')) { + if (message.get('isErased') || message.get('deletedForEveryone')) { log.info(`message ${messageId} was erased. Giving up on sending it`); return; } @@ -285,7 +291,7 @@ export async function sendNormalMessage( timestamp: targetTimestamp, reaction, }); - messageSendPromise = message.sendSyncMessageOnly({ + messageSendPromise = sendSyncMessageOnly(message, { dataMessage, saveErrors, targetTimestamp, @@ -407,7 +413,7 @@ export async function sendNormalMessage( }); } - messageSendPromise = message.send({ + messageSendPromise = send(message, { promise: handleMessageSend(innerPromise, { messageIds: [messageId], sendType: 'message', @@ -657,14 +663,13 @@ async function getMessageSendData({ uploadQueue, }), uploadMessageSticker(message, uploadQueue), - storyId - ? __DEPRECATED$getMessageById(storyId, 'sendNormalMessage') - : undefined, + storyId ? getMessageById(storyId) : undefined, ]); // Save message after uploading attachments await DataWriter.saveMessage(message.attributes, { ourAci: window.textsecure.storage.user.getCheckedAci(), + postSaveUpdates, }); const storyReaction = message.get('storyReaction'); @@ -732,7 +737,7 @@ async function uploadSingleAttachment({ const uploaded = await uploadAttachment(withData); // Add digest to the attachment - const logId = `uploadSingleAttachment(${message.idForLogging()}`; + const logId = `uploadSingleAttachment(${getMessageIdForLogging(message.attributes)}`; const oldAttachments = getPropForTimestamp({ log, message: message.attributes, @@ -788,7 +793,7 @@ async function uploadLongMessageAttachment({ const uploaded = await uploadAttachment(withData); // Add digest to the attachment - const logId = `uploadLongMessageAttachment(${message.idForLogging()}`; + const logId = `uploadLongMessageAttachment(${getMessageIdForLogging(message.attributes)}`; const oldAttachment = getPropForTimestamp({ log, message: message.attributes, @@ -872,7 +877,7 @@ async function uploadMessageQuote({ ); // Update message with attachment digests - const logId = `uploadMessageQuote(${message.idForLogging()}`; + const logId = `uploadMessageQuote(${getMessageIdForLogging(message.attributes)}`; const oldQuote = getPropForTimestamp({ log, message: message.attributes, @@ -980,7 +985,7 @@ async function uploadMessagePreviews({ ); // Update message with attachment digests - const logId = `uploadMessagePreviews(${message.idForLogging()}`; + const logId = `uploadMessagePreviews(${getMessageIdForLogging(message.attributes)}`; const oldPreview = getPropForTimestamp({ log, message: message.attributes, @@ -1043,7 +1048,7 @@ async function uploadMessageSticker( ); // Add digest to the attachment - const logId = `uploadMessageSticker(${message.idForLogging()}`; + const logId = `uploadMessageSticker(${getMessageIdForLogging(message.attributes)}`; const existingSticker = message.get('sticker'); strictAssert( existingSticker?.data !== undefined, @@ -1054,11 +1059,13 @@ async function uploadMessageSticker( existingSticker.data.path === startingSticker?.data?.path, `${logId}: Sticker was uploaded, but message has a different sticker` ); - message.set('sticker', { - ...existingSticker, - data: { - ...existingSticker.data, - ...copyCdnFields(uploaded), + message.set({ + sticker: { + ...existingSticker, + data: { + ...existingSticker.data, + ...copyCdnFields(uploaded), + }, }, }); @@ -1111,7 +1118,7 @@ async function uploadMessageContacts( ); // Add digest to the attachment - const logId = `uploadMessageContacts(${message.idForLogging()}`; + const logId = `uploadMessageContacts(${getMessageIdForLogging(message.attributes)}`; const oldContact = message.get('contact'); strictAssert(oldContact, `${logId}: Contacts are gone after upload`); @@ -1148,7 +1155,7 @@ async function uploadMessageContacts( }, }; }); - message.set('contact', newContact); + message.set({ contact: newContact }); return uploadedContacts; } @@ -1162,10 +1169,9 @@ async function markMessageFailed({ message: MessageModel; targetTimestamp: number; }): Promise { - message.markFailed(targetTimestamp); - void message.saveErrors(errors, { skipSave: true }); - await DataWriter.saveMessage(message.attributes, { - ourAci: window.textsecure.storage.user.getCheckedAci(), + markFailed(message, targetTimestamp); + await saveErrorsOnMessage(message, errors, { + skipSave: false, }); } diff --git a/ts/jobs/helpers/sendReaction.ts b/ts/jobs/helpers/sendReaction.ts index fc1c6b0a7..f17210ed8 100644 --- a/ts/jobs/helpers/sendReaction.ts +++ b/ts/jobs/helpers/sendReaction.ts @@ -7,14 +7,14 @@ import * as Errors from '../../types/errors'; import { strictAssert } from '../../util/assert'; import { repeat, zipObject } from '../../util/iterables'; import type { CallbackResultType } from '../../textsecure/Types.d'; -import type { MessageModel } from '../../models/messages'; +import { MessageModel } from '../../models/messages'; import type { MessageReactionType } from '../../model-types.d'; import type { ConversationModel } from '../../models/conversations'; import { DataWriter } from '../../sql/Client'; import * as reactionUtil from '../../reactions/util'; import { isSent, SendStatus } from '../../messages/MessageSendState'; -import { __DEPRECATED$getMessageById } from '../../messages/getMessageById'; +import { getMessageById } from '../../messages/getMessageById'; import { isIncoming } from '../../messages/helpers'; import { isMe, @@ -41,6 +41,9 @@ import { isConversationAccepted } from '../../util/isConversationAccepted'; import { isConversationUnregistered } from '../../util/isConversationUnregistered'; import type { LoggerType } from '../../types/Logging'; import { sendToGroup } from '../../util/sendToGroup'; +import { hydrateStoryContext } from '../../util/hydrateStoryContext'; +import { postSaveUpdates } from '../../util/cleanup'; +import { send, sendSyncMessageOnly } from '../../messages/send'; export async function sendReaction( conversation: ConversationModel, @@ -61,7 +64,7 @@ export async function sendReaction( const ourConversationId = window.ConversationController.getOurConversationIdOrThrow(); - const message = await __DEPRECATED$getMessageById(messageId, 'sendReaction'); + 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` @@ -87,7 +90,10 @@ export async function sendReaction( if (!canReact(message.attributes, ourConversationId, findAndFormatContact)) { log.info(`could not react to ${messageId}. Removing this pending reaction`); markReactionFailed(message, pendingReaction); - await DataWriter.saveMessage(message.attributes, { ourAci }); + await DataWriter.saveMessage(message.attributes, { + ourAci, + postSaveUpdates, + }); return; } @@ -96,7 +102,10 @@ export async function sendReaction( `reacting to message ${messageId} ran out of time. Giving up on sending it` ); markReactionFailed(message, pendingReaction); - await DataWriter.saveMessage(message.attributes, { ourAci }); + await DataWriter.saveMessage(message.attributes, { + ourAci, + postSaveUpdates, + }); return; } @@ -108,7 +117,9 @@ export async function sendReaction( let originalError: Error | undefined; try { - const messageConversation = message.getConversation(); + const messageConversation = window.ConversationController.get( + message.get('conversationId') + ); if (messageConversation !== conversation) { log.error( `message conversation '${messageConversation?.idForLogging()}' does not match job conversation ${conversation.idForLogging()}` @@ -158,7 +169,7 @@ export async function sendReaction( targetAuthorAci, remove: !emoji, }; - const ephemeralMessageForReactionSend = new window.Whisper.Message({ + const ephemeralMessageForReactionSend = new MessageModel({ ...generateMessageId(incrementMessageCounter()), type: 'outgoing', conversationId: conversation.get('id'), @@ -173,14 +184,13 @@ export async function sendReaction( }) ), }); - // Adds the reaction's attributes to the message cache so that we can - // safely `set` on it later. - window.MessageCache.toMessageAttributes( - ephemeralMessageForReactionSend.attributes - ); ephemeralMessageForReactionSend.doNotSave = true; + // Adds the reaction's attributes to the message cache so that we can + // safely `set` on it later. + window.MessageCache.register(ephemeralMessageForReactionSend); + let didFullySend: boolean; const successfulConversationIds = new Set(); @@ -199,7 +209,7 @@ export async function sendReaction( recipients: allRecipientServiceIds, timestamp: pendingReaction.timestamp, }); - await ephemeralMessageForReactionSend.sendSyncMessageOnly({ + await sendSyncMessageOnly(ephemeralMessageForReactionSend, { dataMessage, saveErrors, targetTimestamp: pendingReaction.timestamp, @@ -292,7 +302,7 @@ export async function sendReaction( ); } - await ephemeralMessageForReactionSend.send({ + await send(ephemeralMessageForReactionSend, { promise: handleMessageSend(promise, { messageIds: [messageId], sendType: 'reaction', @@ -334,19 +344,16 @@ export async function sendReaction( if (!ephemeralMessageForReactionSend.doNotSave) { const reactionMessage = ephemeralMessageForReactionSend; - await reactionMessage.hydrateStoryContext(message.attributes, { + await hydrateStoryContext(reactionMessage.id, message.attributes, { shouldSave: false, }); await DataWriter.saveMessage(reactionMessage.attributes, { ourAci, forceSave: true, + postSaveUpdates, }); - window.MessageCache.__DEPRECATED$register( - reactionMessage.id, - reactionMessage, - 'sendReaction' - ); + window.MessageCache.register(reactionMessage); void conversation.addSingleMessage(reactionMessage.attributes); } } @@ -375,7 +382,10 @@ export async function sendReaction( toThrow: originalError || thrownError, }); } finally { - await DataWriter.saveMessage(message.attributes, { ourAci }); + await DataWriter.saveMessage(message.attributes, { + ourAci, + postSaveUpdates, + }); } } @@ -388,9 +398,9 @@ const setReactions = ( reactions: Array ): void => { if (reactions.length) { - message.set('reactions', reactions); + message.set({ reactions }); } else { - message.set('reactions', undefined); + message.set({ reactions: undefined }); } }; diff --git a/ts/jobs/helpers/sendStory.ts b/ts/jobs/helpers/sendStory.ts index 9f89ea70b..9e0648122 100644 --- a/ts/jobs/helpers/sendStory.ts +++ b/ts/jobs/helpers/sendStory.ts @@ -39,6 +39,13 @@ import { distributionListToSendTarget } from '../../util/distributionListToSendT import { uploadAttachment } from '../../util/uploadAttachment'; import { SendMessageChallengeError } from '../../textsecure/Errors'; import type { OutgoingTextAttachmentType } from '../../textsecure/SendMessage'; +import { + markFailed, + notifyStorySendFailed, + saveErrorsOnMessage, +} from '../../test-node/util/messageFailures'; +import { postSaveUpdates } from '../../util/cleanup'; +import { send } from '../../messages/send'; export async function sendStory( conversation: ConversationModel, @@ -71,40 +78,40 @@ export async function sendStory( } const notFound = new Set(messageIds); - const messages = (await getMessagesById(messageIds, 'sendStory')).filter( - message => { - notFound.delete(message.id); + const messages = (await getMessagesById(messageIds)).filter(message => { + notFound.delete(message.id); - const distributionId = message.get('storyDistributionListId'); - const logId = `stories.sendStory(${timestamp}/${distributionId})`; + const distributionId = message.get('storyDistributionListId'); + const logId = `stories.sendStory(${timestamp}/${distributionId})`; - const messageConversation = message.getConversation(); - if (messageConversation !== conversation) { - log.error( - `${logId}: Message conversation ` + - `'${messageConversation?.idForLogging()}' does not match job ` + - `conversation ${conversation.idForLogging()}` - ); - return false; - } - - if (message.get('timestamp') !== timestamp) { - log.error( - `${logId}: Message timestamp ${message.get( - 'timestamp' - )} does not match job timestamp` - ); - return false; - } - - if (message.isErased() || message.get('deletedForEveryone')) { - log.info(`${logId}: message was erased. Giving up on sending it`); - return false; - } - - return true; + const messageConversation = window.ConversationController.get( + message.get('conversationId') + ); + if (messageConversation !== conversation) { + log.error( + `${logId}: Message conversation ` + + `'${messageConversation?.idForLogging()}' does not match job ` + + `conversation ${conversation.idForLogging()}` + ); + return false; } - ); + + if (message.get('timestamp') !== timestamp) { + log.error( + `${logId}: Message timestamp ${message.get( + 'timestamp' + )} does not match job timestamp` + ); + return false; + } + + if (message.get('isErased') || message.get('deletedForEveryone')) { + log.info(`${logId}: message was erased. Giving up on sending it`); + return false; + } + + return true; + }); for (const messageId of notFound) { log.info( @@ -367,7 +374,7 @@ export async function sendStory( // eslint-disable-next-line no-param-reassign message.doNotSendSyncMessage = true; - const messageSendPromise = message.send({ + const messageSendPromise = send(message, { promise: handleMessageSend(innerPromise, { messageIds: [message.id], sendType: 'story', @@ -535,16 +542,17 @@ export async function sendStory( }, {} as SendStateByConversationId); if (hasFailedSends) { - message.notifyStorySendFailed(); + notifyStorySendFailed(message); } if (isEqual(oldSendStateByConversationId, newSendStateByConversationId)) { return; } - message.set('sendStateByConversationId', newSendStateByConversationId); + message.set({ sendStateByConversationId: newSendStateByConversationId }); return DataWriter.saveMessage(message.attributes, { ourAci: window.textsecure.storage.user.getCheckedAci(), + postSaveUpdates, }); }) ); @@ -688,10 +696,9 @@ async function markMessageFailed( message: MessageModel, errors: Array ): Promise { - message.markFailed(); - void message.saveErrors(errors, { skipSave: true }); - await DataWriter.saveMessage(message.attributes, { - ourAci: window.textsecure.storage.user.getCheckedAci(), + markFailed(message); + await saveErrorsOnMessage(message, errors, { + skipSave: false, }); } diff --git a/ts/messageModifiers/AttachmentDownloads.ts b/ts/messageModifiers/AttachmentDownloads.ts index 7ab071fc8..97a8d4091 100644 --- a/ts/messageModifiers/AttachmentDownloads.ts +++ b/ts/messageModifiers/AttachmentDownloads.ts @@ -6,7 +6,55 @@ import type { AttachmentDownloadJobTypeType } from '../types/AttachmentDownload' import type { AttachmentType } from '../types/Attachment'; import { getAttachmentSignatureSafe, isDownloaded } from '../types/Attachment'; -import { __DEPRECATED$getMessageById } from '../messages/getMessageById'; +import { getMessageById } from '../messages/getMessageById'; + +export async function markAttachmentAsCorrupted( + messageId: string, + attachment: AttachmentType +): Promise { + const message = await getMessageById(messageId); + + if (!message) { + return; + } + + if (!attachment.path) { + throw new Error( + "Attachment can't be marked as corrupted because it wasn't loaded" + ); + } + + // We intentionally don't check in quotes/stickers/contacts/... here, + // because this function should be called only for something that can + // be displayed as a generic attachment. + const attachments: ReadonlyArray = + message.get('attachments') || []; + + let changed = false; + const newAttachments = attachments.map(existing => { + if (existing.path !== attachment.path) { + return existing; + } + changed = true; + + return { + ...existing, + isCorrupted: true, + }; + }); + + if (!changed) { + throw new Error( + "Attachment can't be marked as corrupted because it wasn't found" + ); + } + + log.info('markAttachmentAsCorrupted: marking an attachment as corrupted'); + + message.set({ + attachments: newAttachments, + }); +} export async function addAttachmentToMessage( messageId: string, @@ -15,7 +63,7 @@ export async function addAttachmentToMessage( { type }: { type: AttachmentDownloadJobTypeType } ): Promise { const logPrefix = `${jobLogId}/addAttachmentToMessage`; - const message = await __DEPRECATED$getMessageById(messageId, logPrefix); + const message = await getMessageById(messageId); if (!message) { return; diff --git a/ts/messageModifiers/Deletes.ts b/ts/messageModifiers/Deletes.ts index b94c87703..4d5128603 100644 --- a/ts/messageModifiers/Deletes.ts +++ b/ts/messageModifiers/Deletes.ts @@ -9,6 +9,7 @@ import * as Errors from '../types/errors'; import { deleteForEveryone } from '../util/deleteForEveryone'; import { drop } from '../util/drop'; import { getMessageSentTimestampSet } from '../util/getMessageSentTimestampSet'; +import { MessageModel } from '../models/messages'; export type DeleteAttributesType = { envelopeId: string; @@ -86,10 +87,8 @@ export async function onDelete(del: DeleteAttributesType): Promise { return; } - const message = window.MessageCache.__DEPRECATED$register( - targetMessage.id, - targetMessage, - 'Deletes.onDelete' + const message = window.MessageCache.register( + new MessageModel(targetMessage) ); await deleteForEveryone(message, del); diff --git a/ts/messageModifiers/Edits.ts b/ts/messageModifiers/Edits.ts index 1b9d5c413..83325cfe4 100644 --- a/ts/messageModifiers/Edits.ts +++ b/ts/messageModifiers/Edits.ts @@ -13,6 +13,7 @@ import { isAttachmentDownloadQueueEmpty, registerQueueEmptyCallback, } from '../util/attachmentDownloadQueue'; +import { MessageModel } from '../models/messages'; export type EditAttributesType = { conversationId: string; @@ -134,10 +135,8 @@ export async function onEdit(edit: EditAttributesType): Promise { return; } - const message = window.MessageCache.__DEPRECATED$register( - targetMessage.id, - targetMessage, - 'Edits.onEdit' + const message = window.MessageCache.register( + new MessageModel(targetMessage) ); await handleEditMessage(message.attributes, edit); diff --git a/ts/messageModifiers/MessageReceipts.ts b/ts/messageModifiers/MessageReceipts.ts index 9db7b26d1..9c75a7194 100644 --- a/ts/messageModifiers/MessageReceipts.ts +++ b/ts/messageModifiers/MessageReceipts.ts @@ -32,6 +32,9 @@ import { RECEIPT_BATCHER_WAIT_MS, } from '../types/Receipt'; import { drop } from '../util/drop'; +import { getMessageById } from '../messages/getMessageById'; +import { postSaveUpdates } from '../util/cleanup'; +import { MessageModel } from '../models/messages'; const { deleteSentProtoRecipient, removeSyncTaskById } = DataWriter; @@ -78,12 +81,11 @@ const processReceiptBatcher = createWaitBatcher({ > = new Map(); function addReceiptAndTargetMessage( - message: MessageAttributesType, + message: MessageModel, receipt: MessageReceiptAttributesType ): void { const existing = receiptsByMessageId.get(message.id); if (!existing) { - window.MessageCache.toMessageAttributes(message); receiptsByMessageId.set(message.id, [receipt]); } else { existing.push(receipt); @@ -151,9 +153,10 @@ const processReceiptBatcher = createWaitBatcher({ ); if (targetMessages.length) { - targetMessages.forEach(msg => - addReceiptAndTargetMessage(msg, receipt) - ); + targetMessages.forEach(msg => { + const model = window.MessageCache.register(new MessageModel(msg)); + addReceiptAndTargetMessage(model, receipt); + }); } else { // Nope, no target message was found const { receiptSync } = receipt; @@ -188,53 +191,43 @@ async function processReceiptsForMessage( } // Get message from cache or DB - const message = await window.MessageCache.resolveAttributes( - 'processReceiptsForMessage', - messageId - ); + const message = await getMessageById(messageId); + if (!message) { + throw new Error( + `processReceiptsForMessage: Failed to find message ${messageId}` + ); + } - // Note: it is important to have no `await` in between `resolveAttributes` and - // `setAttributes` since it might overwrite other updates otherwise. - const { updatedMessage, validReceipts, droppedReceipts } = - updateMessageWithReceipts(message, receipts); + const { validReceipts } = await updateMessageWithReceipts(message, receipts); - // Save it to cache & to DB, and remove dropped receipts - await Promise.all([ - window.MessageCache.setAttributes({ - messageId, - messageAttributes: updatedMessage, - skipSaveToDatabase: false, - }), - Promise.all(droppedReceipts.map(remove)), - ]); + const ourAci = window.textsecure.storage.user.getCheckedAci(); + await DataWriter.saveMessage(message.attributes, { ourAci, postSaveUpdates }); // Confirm/remove receipts, and delete sent protos for (const receipt of validReceipts) { // eslint-disable-next-line no-await-in-loop await remove(receipt); - drop(addToDeleteSentProtoBatcher(receipt, updatedMessage)); + drop(addToDeleteSentProtoBatcher(receipt, message.attributes)); } // notify frontend listeners const conversation = window.ConversationController.get( - message.conversationId + message.get('conversationId') ); conversation?.debouncedUpdateLastMessage?.(); } -function updateMessageWithReceipts( - message: MessageAttributesType, +async function updateMessageWithReceipts( + message: MessageModel, receipts: Array -): { - updatedMessage: MessageAttributesType; +): Promise<{ validReceipts: Array; - droppedReceipts: Array; -} { - const logId = `updateMessageWithReceipts(timestamp=${message.timestamp})`; +}> { + const logId = `updateMessageWithReceipts(timestamp=${message.get('timestamp')})`; const droppedReceipts: Array = []; const receiptsToProcess = receipts.filter(receipt => { - if (shouldDropReceipt(receipt, message)) { + if (shouldDropReceipt(receipt, message.attributes)) { const { receiptSync } = receipt; log.info( `${logId}: Dropping a receipt ${receiptSync.type} for sentAt=${receiptSync.messageSentAt}` @@ -257,14 +250,16 @@ function updateMessageWithReceipts( ); // Generate the updated message synchronously - let updatedMessage: MessageAttributesType = { ...message }; + let { attributes } = message; for (const receipt of receiptsToProcess) { - updatedMessage = { - ...updatedMessage, - ...updateMessageSendStateWithReceipt(updatedMessage, receipt), + attributes = { + ...attributes, + ...updateMessageSendStateWithReceipt(attributes, receipt), }; } - return { updatedMessage, validReceipts: receiptsToProcess, droppedReceipts }; + message.set(attributes); + + return { validReceipts: receiptsToProcess }; } const deleteSentProtoBatcher = createWaitBatcher({ @@ -310,7 +305,7 @@ function getTargetMessage({ sourceConversationId: string; messagesMatchingTimestamp: ReadonlyArray; targetTimestamp: number; -}): MessageAttributesType | null { +}): MessageModel | null { if (messagesMatchingTimestamp.length === 0) { return null; } @@ -366,7 +361,7 @@ function getTargetMessage({ } const message = matchingMessages[0]; - return window.MessageCache.toMessageAttributes(message); + return window.MessageCache.register(new MessageModel(message)); } const wasDeliveredWithSealedSender = ( conversationId: string, diff --git a/ts/messageModifiers/Reactions.ts b/ts/messageModifiers/Reactions.ts index bf8a40d7f..2abb137ce 100644 --- a/ts/messageModifiers/Reactions.ts +++ b/ts/messageModifiers/Reactions.ts @@ -1,23 +1,45 @@ // Copyright 2020 Signal Messenger, LLC // SPDX-License-Identifier: AGPL-3.0-only +import { maxBy } from 'lodash'; + import type { AciString } from '../types/ServiceId'; import type { MessageAttributesType, + MessageReactionType, ReadonlyMessageAttributesType, } from '../model-types.d'; -import type { MessageModel } from '../models/messages'; -import type { ReactionSource } from '../reactions/ReactionSource'; -import { DataReader } from '../sql/Client'; +import { MessageModel } from '../models/messages'; +import { ReactionSource } from '../reactions/ReactionSource'; +import { DataReader, DataWriter } from '../sql/Client'; import * as Errors from '../types/errors'; import * as log from '../logging/log'; -import { getAuthor } from '../messages/helpers'; +import { getAuthor, isIncoming, isOutgoing } from '../messages/helpers'; import { getMessageSentTimestampSet } from '../util/getMessageSentTimestampSet'; -import { isMe } from '../util/whatTypeOfConversation'; -import { isStory } from '../state/selectors/message'; +import { isDirectConversation, isMe } from '../util/whatTypeOfConversation'; +import { + getMessagePropStatus, + hasErrors, + isStory, +} from '../state/selectors/message'; import { getPropForTimestamp } from '../util/editHelpers'; import { isSent } from '../messages/MessageSendState'; import { strictAssert } from '../util/assert'; +import { repeat, zipObject } from '../util/iterables'; +import { getMessageIdForLogging } from '../util/idForLogging'; +import { hydrateStoryContext } from '../util/hydrateStoryContext'; +import { shouldReplyNotifyUser } from '../util/shouldReplyNotifyUser'; +import { drop } from '../util/drop'; +import * as reactionUtil from '../reactions/util'; +import { isNewReactionReplacingPrevious } from '../reactions/util'; +import { notificationService } from '../services/notifications'; +import { ReactionReadStatus } from '../types/Reactions'; +import type { ConversationQueueJobData } from '../jobs/conversationJobQueue'; +import { + conversationJobQueue, + conversationQueueJobEnum, +} from '../jobs/conversationJobQueue'; +import { postSaveUpdates } from '../util/cleanup'; export type ReactionAttributesType = { emoji: string; @@ -36,24 +58,26 @@ export type ReactionAttributesType = { receivedAtDate: number; }; -const reactions = new Map(); +const reactionCache = new Map(); function remove(reaction: ReactionAttributesType): void { - reactions.delete(reaction.envelopeId); + reactionCache.delete(reaction.envelopeId); reaction.removeFromMessageReceiverCache(); } export function findReactionsForMessage( message: ReadonlyMessageAttributesType ): Array { - const matchingReactions = Array.from(reactions.values()).filter(reaction => { - return isMessageAMatchForReaction({ - message, - targetTimestamp: reaction.targetTimestamp, - targetAuthorAci: reaction.targetAuthorAci, - reactionSenderConversationId: reaction.fromId, - }); - }); + const matchingReactions = Array.from(reactionCache.values()).filter( + reaction => { + return isMessageAMatchForReaction({ + message, + targetTimestamp: reaction.targetTimestamp, + targetAuthorAci: reaction.targetAuthorAci, + reactionSenderConversationId: reaction.fromId, + }); + } + ); matchingReactions.forEach(reaction => remove(reaction)); return matchingReactions; @@ -173,7 +197,7 @@ function isMessageAMatchForReaction({ export async function onReaction( reaction: ReactionAttributesType ): Promise { - reactions.set(reaction.envelopeId, reaction); + reactionCache.set(reaction.envelopeId, reaction); const logId = `Reactions.onReaction(timestamp=${reaction.timestamp};target=${reaction.targetTimestamp})`; @@ -231,23 +255,21 @@ export async function onReaction( return; } - const targetMessageModel = window.MessageCache.__DEPRECATED$register( - targetMessage.id, - targetMessage, - 'Reactions.onReaction' + const targetMessageModel = window.MessageCache.register( + new MessageModel(targetMessage) ); // Use the generated message in ts/background.ts to create a message // if the reaction is targeted at a story. if (!isStory(targetMessage)) { - await targetMessageModel.handleReaction(reaction); + await handleReaction(targetMessageModel, reaction); } else { const generatedMessage = reaction.generatedMessageForStoryReaction; strictAssert( generatedMessage, 'Generated message must exist for story reaction' ); - await generatedMessage.handleReaction(reaction, { + await handleReaction(generatedMessage, reaction, { storyMessage: targetMessage, }); } @@ -260,3 +282,324 @@ export async function onReaction( log.error(`${logId} error:`, Errors.toLogFormat(error)); } } + +export async function handleReaction( + message: MessageModel, + reaction: ReactionAttributesType, + { + storyMessage, + shouldPersist = true, + }: { + storyMessage?: MessageAttributesType; + shouldPersist?: boolean; + } = {} +): Promise { + const { attributes } = message; + + if (message.get('deletedForEveryone')) { + return; + } + + // We allow you to react to messages with outgoing errors only if it has sent + // successfully to at least one person. + if ( + hasErrors(attributes) && + (isIncoming(attributes) || + getMessagePropStatus( + attributes, + window.ConversationController.getOurConversationIdOrThrow() + ) !== 'partial-sent') + ) { + return; + } + + const conversation = window.ConversationController.get( + message.attributes.conversationId + ); + if (!conversation) { + return; + } + + const isFromThisDevice = reaction.source === ReactionSource.FromThisDevice; + const isFromSync = reaction.source === ReactionSource.FromSync; + const isFromSomeoneElse = reaction.source === ReactionSource.FromSomeoneElse; + strictAssert( + isFromThisDevice || isFromSync || isFromSomeoneElse, + 'Reaction can only be from this device, from sync, or from someone else' + ); + + const newReaction: MessageReactionType = { + emoji: reaction.remove ? undefined : reaction.emoji, + fromId: reaction.fromId, + targetTimestamp: reaction.targetTimestamp, + timestamp: reaction.timestamp, + isSentByConversationId: isFromThisDevice + ? zipObject(conversation.getMemberConversationIds(), repeat(false)) + : undefined, + }; + + // Reactions to stories are saved as separate messages, and so require a totally + // different codepath. + if (storyMessage) { + if (isFromThisDevice) { + log.info( + 'handleReaction: sending story reaction to ' + + `${getMessageIdForLogging(storyMessage)} from this device` + ); + } else { + if (isFromSomeoneElse) { + log.info( + 'handleReaction: receiving story reaction to ' + + `${getMessageIdForLogging(storyMessage)} from someone else` + ); + } else if (isFromSync) { + log.info( + 'handleReaction: receiving story reaction to ' + + `${getMessageIdForLogging(storyMessage)} from another device` + ); + } + + const generatedMessage = reaction.generatedMessageForStoryReaction; + strictAssert( + generatedMessage, + 'Story reactions must provide storyReactionMessage' + ); + const targetConversation = window.ConversationController.get( + generatedMessage.get('conversationId') + ); + strictAssert( + targetConversation, + 'handleReaction: targetConversation not found' + ); + + window.MessageCache.register(generatedMessage); + generatedMessage.set({ + expireTimer: isDirectConversation(targetConversation.attributes) + ? targetConversation.get('expireTimer') + : undefined, + storyId: storyMessage.id, + storyReaction: { + emoji: reaction.emoji, + targetAuthorAci: reaction.targetAuthorAci, + targetTimestamp: reaction.targetTimestamp, + }, + }); + + await hydrateStoryContext(generatedMessage.id, storyMessage, { + shouldSave: false, + }); + // Note: generatedMessage comes with an id, so we have to force this save + await DataWriter.saveMessage(generatedMessage.attributes, { + ourAci: window.textsecure.storage.user.getCheckedAci(), + forceSave: true, + postSaveUpdates, + }); + + log.info('Reactions.onReaction adding reaction to story', { + reactionMessageId: getMessageIdForLogging(generatedMessage.attributes), + storyId: getMessageIdForLogging(storyMessage), + targetTimestamp: reaction.targetTimestamp, + timestamp: reaction.timestamp, + }); + + window.MessageCache.register(generatedMessage); + if (isDirectConversation(targetConversation.attributes)) { + await targetConversation.addSingleMessage(generatedMessage.attributes); + if (!targetConversation.get('active_at')) { + targetConversation.set({ + active_at: generatedMessage.attributes.timestamp, + }); + await DataWriter.updateConversation(targetConversation.attributes); + } + } + + if (isFromSomeoneElse) { + log.info( + 'handleReaction: notifying for story reaction to ' + + `${getMessageIdForLogging(storyMessage)} from someone else` + ); + if ( + await shouldReplyNotifyUser( + generatedMessage.attributes, + targetConversation + ) + ) { + drop(targetConversation.notify(generatedMessage.attributes)); + } + } + } + } else { + // Reactions to all messages other than stories will update the target message + const previousLength = (message.get('reactions') || []).length; + + if (isFromThisDevice) { + log.info( + `handleReaction: sending reaction to ${getMessageIdForLogging(message.attributes)} ` + + 'from this device' + ); + + const reactions = reactionUtil.addOutgoingReaction( + message.get('reactions') || [], + newReaction + ); + message.set({ reactions }); + } else { + const oldReactions = message.get('reactions') || []; + let reactions: Array; + const oldReaction = oldReactions.find(re => + isNewReactionReplacingPrevious(re, newReaction) + ); + if (oldReaction) { + notificationService.removeBy({ + ...oldReaction, + messageId: message.id, + }); + } + + if (reaction.remove) { + log.info( + 'handleReaction: removing reaction for message', + getMessageIdForLogging(message.attributes) + ); + + if (isFromSync) { + reactions = oldReactions.filter( + re => + !isNewReactionReplacingPrevious(re, newReaction) || + re.timestamp > reaction.timestamp + ); + } else { + reactions = oldReactions.filter( + re => !isNewReactionReplacingPrevious(re, newReaction) + ); + } + message.set({ reactions }); + } else { + log.info( + 'handleReaction: adding reaction for message', + getMessageIdForLogging(message.attributes) + ); + + let reactionToAdd: MessageReactionType; + if (isFromSync) { + const ourReactions = [ + newReaction, + ...oldReactions.filter(re => re.fromId === reaction.fromId), + ]; + reactionToAdd = maxBy(ourReactions, 'timestamp') || newReaction; + } else { + reactionToAdd = newReaction; + } + + reactions = oldReactions.filter( + re => !isNewReactionReplacingPrevious(re, reaction) + ); + reactions.push(reactionToAdd); + message.set({ reactions }); + + if (isOutgoing(message.attributes) && isFromSomeoneElse) { + void conversation.notify(message.attributes, reaction); + } + } + } + + if (reaction.remove) { + await DataWriter.removeReactionFromConversation({ + emoji: reaction.emoji, + fromId: reaction.fromId, + targetAuthorServiceId: reaction.targetAuthorAci, + targetTimestamp: reaction.targetTimestamp, + }); + } else { + await DataWriter.addReaction( + { + conversationId: message.get('conversationId'), + emoji: reaction.emoji, + fromId: reaction.fromId, + messageId: message.id, + messageReceivedAt: message.get('received_at'), + targetAuthorAci: reaction.targetAuthorAci, + targetTimestamp: reaction.targetTimestamp, + timestamp: reaction.timestamp, + }, + { + readStatus: isFromThisDevice + ? ReactionReadStatus.Read + : ReactionReadStatus.Unread, + } + ); + } + + const currentLength = (message.get('reactions') || []).length; + log.info( + 'handleReaction:', + `Done processing reaction for message ${getMessageIdForLogging(message.attributes)}.`, + `Went from ${previousLength} to ${currentLength} reactions.` + ); + } + + if (isFromThisDevice) { + let jobData: ConversationQueueJobData; + if (storyMessage) { + strictAssert( + newReaction.emoji !== undefined, + 'New story reaction must have an emoji' + ); + + const generatedMessage = reaction.generatedMessageForStoryReaction; + strictAssert( + generatedMessage, + 'Story reactions must provide storyReactionmessage' + ); + + await hydrateStoryContext(generatedMessage.id, message.attributes, { + shouldSave: false, + }); + await DataWriter.saveMessage(generatedMessage.attributes, { + ourAci: window.textsecure.storage.user.getCheckedAci(), + forceSave: true, + postSaveUpdates, + }); + + window.MessageCache.register(generatedMessage); + + void conversation.addSingleMessage(generatedMessage.attributes); + + jobData = { + type: conversationQueueJobEnum.enum.NormalMessage, + conversationId: conversation.id, + messageId: generatedMessage.id, + revision: conversation.get('revision'), + }; + } else { + jobData = { + type: conversationQueueJobEnum.enum.Reaction, + conversationId: conversation.id, + messageId: message.id, + revision: conversation.get('revision'), + }; + } + if (shouldPersist) { + await conversationJobQueue.add(jobData, async jobToInsert => { + log.info( + `enqueueReactionForSend: saving message ${getMessageIdForLogging(message.attributes)} and job ${ + jobToInsert.id + }` + ); + await DataWriter.saveMessage(message.attributes, { + jobToInsert, + ourAci: window.textsecure.storage.user.getCheckedAci(), + postSaveUpdates, + }); + }); + } else { + await conversationJobQueue.add(jobData); + } + } else if (shouldPersist && !isStory(message.attributes)) { + await DataWriter.saveMessage(message.attributes, { + ourAci: window.textsecure.storage.user.getCheckedAci(), + postSaveUpdates, + }); + window.reduxActions.conversations.markOpenConversationRead(conversation.id); + } +} diff --git a/ts/messageModifiers/ReadSyncs.ts b/ts/messageModifiers/ReadSyncs.ts index 35c6f6959..111114b92 100644 --- a/ts/messageModifiers/ReadSyncs.ts +++ b/ts/messageModifiers/ReadSyncs.ts @@ -17,6 +17,8 @@ import { queueUpdateMessage } from '../util/messageBatcher'; import { strictAssert } from '../util/assert'; import { isAciString } from '../util/isAciString'; import { DataReader, DataWriter } from '../sql/Client'; +import { markRead } from '../services/MessageUpdater'; +import { MessageModel } from '../models/messages'; const { removeSyncTaskById } = DataWriter; @@ -146,11 +148,7 @@ export async function onSync(sync: ReadSyncAttributesType): Promise { notificationService.removeBy({ messageId: found.id }); - const message = window.MessageCache.__DEPRECATED$register( - found.id, - found, - 'ReadSyncs.onSync' - ); + const message = window.MessageCache.register(new MessageModel(found)); const readAt = Math.min(readSync.readAt, Date.now()); const newestSentAt = readSync.timestamp; @@ -158,11 +156,12 @@ export async function onSync(sync: ReadSyncAttributesType): Promise { // timer to the time specified by the read sync if it's earlier than // the previous read time. if (isMessageUnread(message.attributes)) { - // TODO DESKTOP-1509: use MessageUpdater.markRead once this is TS - message.markRead(readAt, { skipSave: true }); + message.set(markRead(message.attributes, readAt, { skipSave: true })); const updateConversation = async () => { - const conversation = message.getConversation(); + const conversation = window.ConversationController.get( + message.get('conversationId') + ); strictAssert(conversation, `${logId}: conversation not found`); // onReadMessage may result in messages older than this one being // marked read. We want those messages to have the same expire timer @@ -174,7 +173,9 @@ export async function onSync(sync: ReadSyncAttributesType): Promise { // only available during initialization if (StartupQueue.isAvailable()) { - const conversation = message.getConversation(); + const conversation = window.ConversationController.get( + message.get('conversationId') + ); strictAssert( conversation, `${logId}: conversation not found (StartupQueue)` diff --git a/ts/messageModifiers/ViewOnceOpenSyncs.ts b/ts/messageModifiers/ViewOnceOpenSyncs.ts index 5bdccd44e..0bbfd174d 100644 --- a/ts/messageModifiers/ViewOnceOpenSyncs.ts +++ b/ts/messageModifiers/ViewOnceOpenSyncs.ts @@ -7,6 +7,8 @@ import { DataReader } from '../sql/Client'; import * as Errors from '../types/errors'; import * as log from '../logging/log'; import { getMessageIdForLogging } from '../util/idForLogging'; +import { markViewOnceMessageViewed } from '../services/MessageUpdater'; +import { MessageModel } from '../models/messages'; export type ViewOnceOpenSyncAttributesType = { removeFromMessageReceiverCache: () => unknown; @@ -93,12 +95,8 @@ export async function onSync( return; } - const message = window.MessageCache.__DEPRECATED$register( - found.id, - found, - 'ViewOnceOpenSyncs.onSync' - ); - await message.markViewOnceMessageViewed({ fromSync: true }); + const message = window.MessageCache.register(new MessageModel(found)); + await markViewOnceMessageViewed(message, { fromSync: true }); viewOnceSyncs.delete(sync.timestamp); sync.removeFromMessageReceiverCache(); diff --git a/ts/messageModifiers/ViewSyncs.ts b/ts/messageModifiers/ViewSyncs.ts index 95747f8f3..e57c18c03 100644 --- a/ts/messageModifiers/ViewSyncs.ts +++ b/ts/messageModifiers/ViewSyncs.ts @@ -19,6 +19,7 @@ import { queueUpdateMessage } from '../util/messageBatcher'; import { AttachmentDownloadUrgency } from '../jobs/AttachmentDownloadManager'; import { isAciString } from '../util/isAciString'; import { DataReader, DataWriter } from '../sql/Client'; +import { MessageModel } from '../models/messages'; export const viewSyncTaskSchema = z.object({ type: z.literal('ViewSync').readonly(), @@ -114,11 +115,7 @@ export async function onSync(sync: ViewSyncAttributesType): Promise { notificationService.removeBy({ messageId: found.id }); - const message = window.MessageCache.__DEPRECATED$register( - found.id, - found, - 'ViewSyncs.onSync' - ); + const message = window.MessageCache.register(new MessageModel(found)); let didChangeMessage = false; if (message.get('readStatus') !== ReadStatus.Viewed) { diff --git a/ts/messages/copyQuote.ts b/ts/messages/copyQuote.ts index 0e22ba7b2..b52d60db0 100644 --- a/ts/messages/copyQuote.ts +++ b/ts/messages/copyQuote.ts @@ -5,10 +5,6 @@ import { omit } from 'lodash'; import * as log from '../logging/log'; import type { QuotedMessageType } from '../model-types'; -import type { - MessageAttributesType, - ReadonlyMessageAttributesType, -} from '../model-types.d'; import { SignalService } from '../protobuf'; import { isGiftBadge, isTapToView } from '../state/selectors/message'; import type { ProcessedQuote } from '../textsecure/Types'; @@ -18,16 +14,15 @@ import { getQuoteBodyText } from '../util/getQuoteBodyText'; import { isQuoteAMatch, messageHasPaymentEvent } from './helpers'; import * as Errors from '../types/errors'; import { isDownloadable } from '../types/Attachment'; +import type { MessageModel } from '../models/messages'; export type MinimalMessageCache = Readonly<{ findBySentAt( sentAt: number, - predicate: (attributes: ReadonlyMessageAttributesType) => boolean - ): Promise; - upgradeSchema( - attributes: MessageAttributesType, - minSchemaVersion: number - ): Promise; + predicate: (attributes: MessageModel) => boolean + ): Promise; + upgradeSchema(message: MessageModel, minSchemaVersion: number): Promise; + register(message: MessageModel): MessageModel; }>; export type CopyQuoteOptionsType = Readonly<{ @@ -57,8 +52,11 @@ export const copyFromQuotedMessage = async ( isViewOnce: false, }; - const queryMessage = await messageCache.findBySentAt(id, attributes => - isQuoteAMatch(attributes, conversationId, result) + const queryMessage = await messageCache.findBySentAt( + id, + (message: MessageModel) => { + return isQuoteAMatch(message.attributes, conversationId, result); + } ); if (queryMessage == null) { @@ -74,21 +72,19 @@ export const copyFromQuotedMessage = async ( }; export const copyQuoteContentFromOriginal = async ( - providedOriginalMessage: MessageAttributesType, + message: MessageModel, quote: QuotedMessageType, { messageCache = window.MessageCache }: CopyQuoteOptionsType = {} ): Promise => { - let originalMessage = providedOriginalMessage; - const { attachments } = quote; const firstAttachment = attachments ? attachments[0] : undefined; - if (messageHasPaymentEvent(originalMessage)) { + if (messageHasPaymentEvent(message.attributes)) { // eslint-disable-next-line no-param-reassign - quote.payment = originalMessage.payment; + quote.payment = message.get('payment'); } - if (isTapToView(originalMessage)) { + if (isTapToView(message.attributes)) { // eslint-disable-next-line no-param-reassign quote.text = undefined; // eslint-disable-next-line no-param-reassign @@ -103,7 +99,7 @@ export const copyQuoteContentFromOriginal = async ( return; } - const isMessageAGiftBadge = isGiftBadge(originalMessage); + const isMessageAGiftBadge = isGiftBadge(message.attributes); if (isMessageAGiftBadge !== quote.isGiftBadge) { log.warn( `copyQuoteContentFromOriginal: Quote.isGiftBadge: ${quote.isGiftBadge}, isGiftBadge(message): ${isMessageAGiftBadge}` @@ -124,18 +120,18 @@ export const copyQuoteContentFromOriginal = async ( quote.isViewOnce = false; // eslint-disable-next-line no-param-reassign - quote.text = getQuoteBodyText(originalMessage, quote.id); + quote.text = getQuoteBodyText(message.attributes, quote.id); // eslint-disable-next-line no-param-reassign - quote.bodyRanges = originalMessage.bodyRanges; + quote.bodyRanges = message.attributes.bodyRanges; if (!firstAttachment || !firstAttachment.contentType) { return; } try { - originalMessage = await messageCache.upgradeSchema( - originalMessage, + await messageCache.upgradeSchema( + message, window.Signal.Types.Message.VERSION_NEEDED_FOR_DISPLAY ); } catch (error) { @@ -150,7 +146,7 @@ export const copyQuoteContentFromOriginal = async ( attachments: queryAttachments = [], preview: queryPreview = [], sticker, - } = originalMessage; + } = message.attributes; if (queryAttachments.length > 0) { const queryFirst = queryAttachments[0]; diff --git a/ts/messages/getMessageById.ts b/ts/messages/getMessageById.ts index e8595bcd9..55e2fd3a7 100644 --- a/ts/messages/getMessageById.ts +++ b/ts/messages/getMessageById.ts @@ -2,20 +2,15 @@ // SPDX-License-Identifier: AGPL-3.0-only import * as log from '../logging/log'; -import { DataReader } from '../sql/Client'; -import type { MessageAttributesType } from '../model-types.d'; import * as Errors from '../types/errors'; -import type { MessageModel } from '../models/messages'; +import { DataReader } from '../sql/Client'; +import { MessageModel } from '../models/messages'; +import type { MessageAttributesType } from '../model-types.d'; -export async function __DEPRECATED$getMessageById( - messageId: string, - location: string +export async function getMessageById( + messageId: string ): Promise { - const innerLocation = `__DEPRECATED$getMessageById/${location}`; - const message = window.MessageCache.__DEPRECATED$getById( - messageId, - innerLocation - ); + const message = window.MessageCache.getById(messageId); if (message) { return message; } @@ -34,9 +29,5 @@ export async function __DEPRECATED$getMessageById( return undefined; } - return window.MessageCache.__DEPRECATED$register( - found.id, - found, - innerLocation - ); + return window.MessageCache.register(new MessageModel(found)); } diff --git a/ts/messages/getMessagesById.ts b/ts/messages/getMessagesById.ts index 77d0988ba..65e542e56 100644 --- a/ts/messages/getMessagesById.ts +++ b/ts/messages/getMessagesById.ts @@ -3,23 +3,18 @@ import * as log from '../logging/log'; import { DataReader } from '../sql/Client'; -import type { MessageModel } from '../models/messages'; +import { MessageModel } from '../models/messages'; import type { MessageAttributesType } from '../model-types.d'; import * as Errors from '../types/errors'; export async function getMessagesById( - messageIds: Iterable, - location: string + messageIds: Iterable ): Promise> { - const innerLocation = `getMessagesById/${location}`; const messagesFromMemory: Array = []; const messageIdsToLookUpInDatabase: Array = []; for (const messageId of messageIds) { - const message = window.MessageCache.__DEPRECATED$getById( - messageId, - innerLocation - ); + const message = window.MessageCache.getById(messageId); if (message) { messagesFromMemory.push(message); } else { @@ -41,15 +36,8 @@ export async function getMessagesById( return []; } - const messagesFromDatabase = rawMessagesFromDatabase.map(rawMessage => { - // We use `window.Whisper.Message` instead of `MessageModel` here to avoid a circular - // import. - const message = new window.Whisper.Message(rawMessage); - return window.MessageCache.__DEPRECATED$register( - message.id, - message, - innerLocation - ); + const messagesFromDatabase = rawMessagesFromDatabase.map(message => { + return window.MessageCache.register(new MessageModel(message)); }); return [...messagesFromMemory, ...messagesFromDatabase]; diff --git a/ts/messages/handleDataMessage.ts b/ts/messages/handleDataMessage.ts new file mode 100644 index 000000000..3bf069319 --- /dev/null +++ b/ts/messages/handleDataMessage.ts @@ -0,0 +1,817 @@ +// Copyright 2024 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +import { isNumber, partition } from 'lodash'; + +import * as log from '../logging/log'; +import * as Errors from '../types/errors'; +import * as MIME from '../types/MIME'; +import * as LinkPreview from '../types/LinkPreview'; + +import { getAuthor, isStory, messageHasPaymentEvent } from './helpers'; +import { getMessageIdForLogging } from '../util/idForLogging'; +import { getSenderIdentifier } from '../util/getSenderIdentifier'; +import { isNormalNumber } from '../util/isNormalNumber'; +import { getOwn } from '../util/getOwn'; +import { + SendActionType, + sendStateReducer, + SendStatus, +} from './MessageSendState'; +import { DataReader, DataWriter } from '../sql/Client'; +import { eraseMessageContents, postSaveUpdates } from '../util/cleanup'; +import { + isDirectConversation, + isGroup, + isGroupV1, +} from '../util/whatTypeOfConversation'; +import { generateMessageId } from '../util/generateMessageId'; +import { + hasErrors, + isEndSession, + isExpirationTimerUpdate, + isGroupUpdate, + isTapToView, + isUnsupportedMessage, +} from '../state/selectors/message'; +import { drop } from '../util/drop'; +import { strictAssert } from '../util/assert'; +import { isAciString } from '../util/isAciString'; +import { copyFromQuotedMessage } from './copyQuote'; +import { findStoryMessages } from '../util/findStoryMessage'; +import { getRoomIdFromCallLink } from '../util/callLinksRingrtc'; +import { isNotNil } from '../util/isNotNil'; +import { normalizeServiceId } from '../types/ServiceId'; +import { BodyRange } from '../types/BodyRange'; +import { hydrateStoryContext } from '../util/hydrateStoryContext'; +import { isMessageEmpty } from '../util/isMessageEmpty'; +import { isValidTapToView } from '../util/isValidTapToView'; +import { getNotificationTextForMessage } from '../util/getNotificationTextForMessage'; +import { getMessageAuthorText } from '../util/getMessageAuthorText'; +import { GiftBadgeStates } from '../components/conversation/Message'; +import { getUserLanguages } from '../util/userLanguages'; +import { parseBoostBadgeListFromServer } from '../badges/parseBadgesFromServer'; +import { SignalService as Proto } from '../protobuf'; +import { + modifyTargetMessage, + ModifyTargetMessageResult, +} from '../util/modifyTargetMessage'; +import { saveAndNotify } from './saveAndNotify'; +import { MessageModel } from '../models/messages'; + +import type { SentEventData } from '../textsecure/messageReceiverEvents'; +import type { + ProcessedDataMessage, + ProcessedUnidentifiedDeliveryStatus, +} from '../textsecure/Types'; +import type { ServiceIdString } from '../types/ServiceId'; +import type { LinkPreviewType } from '../types/message/LinkPreviews'; + +const CURRENT_PROTOCOL_VERSION = Proto.DataMessage.ProtocolVersion.CURRENT; +const INITIAL_PROTOCOL_VERSION = Proto.DataMessage.ProtocolVersion.INITIAL; + +export async function handleDataMessage( + message: MessageModel, + initialMessage: ProcessedDataMessage, + confirm: () => void, + options: { data?: SentEventData } = {} +): Promise { + const { data } = options; + const { upgradeMessageSchema } = window.Signal.Migrations; + + // This function is called from the background script in a few scenarios: + // 1. on an incoming message + // 2. on a sent message sync'd from another device + // 3. in rare cases, an incoming message can be retried, though it will + // still go through one of the previous two codepaths + const source = message.get('source'); + const sourceServiceId = message.get('sourceServiceId'); + const type = message.get('type'); + const conversationId = message.get('conversationId'); + + const fromContact = getAuthor(message.attributes); + if (fromContact) { + fromContact.setRegistered(); + } + + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + const conversation = window.ConversationController.get(conversationId)!; + const idLog = `handleDataMessage/${conversation.idForLogging()} ${getMessageIdForLogging(message.attributes)}`; + await conversation.queueJob(idLog, async () => { + log.info(`${idLog}: starting processing in queue`); + + // First, check for duplicates. If we find one, stop processing here. + const senderIdentifier = getSenderIdentifier(message.attributes); + const inMemoryMessage = window.MessageCache.findBySender(senderIdentifier); + if (inMemoryMessage) { + log.info(`${idLog}: cache hit`, senderIdentifier); + } else { + log.info(`${idLog}: duplicate check db lookup needed`, senderIdentifier); + } + let existingMessage = inMemoryMessage; + if (!existingMessage) { + const fromDb = await DataReader.getMessageBySender(message.attributes); + existingMessage = fromDb + ? window.MessageCache.register(new MessageModel(fromDb)) + : undefined; + } + + const isUpdate = Boolean(data && data.isRecipientUpdate); + + const isDuplicateMessage = + existingMessage && + (type === 'incoming' || + (type === 'story' && + existingMessage.get('storyDistributionListId') === + message.attributes.storyDistributionListId)); + + if (isDuplicateMessage) { + log.warn( + `${idLog}: Received duplicate message`, + getMessageIdForLogging(message.attributes) + ); + confirm(); + return; + } + if (type === 'outgoing') { + if (isUpdate && existingMessage) { + log.info( + `${idLog}: Updating message ${getMessageIdForLogging(message.attributes)} with received transcript` + ); + + const toUpdate = window.MessageCache.register(existingMessage); + + const unidentifiedDeliveriesSet = new Set( + toUpdate.get('unidentifiedDeliveries') ?? [] + ); + const sendStateByConversationId = { + ...(toUpdate.get('sendStateByConversationId') || {}), + }; + + const unidentifiedStatus: Array = + data && Array.isArray(data.unidentifiedStatus) + ? data.unidentifiedStatus + : []; + + unidentifiedStatus.forEach( + ({ destinationServiceId, destination, unidentified }) => { + const identifier = destinationServiceId || destination; + if (!identifier) { + return; + } + + const destinationConversation = + window.ConversationController.lookupOrCreate({ + serviceId: destinationServiceId, + e164: destination || undefined, + reason: `handleDataMessage(${initialMessage.timestamp})`, + }); + if (!destinationConversation) { + return; + } + + const updatedAt: number = + data && isNormalNumber(data.timestamp) + ? data.timestamp + : Date.now(); + + const previousSendState = getOwn( + sendStateByConversationId, + destinationConversation.id + ); + sendStateByConversationId[destinationConversation.id] = + previousSendState + ? sendStateReducer(previousSendState, { + type: SendActionType.Sent, + updatedAt, + }) + : { + status: SendStatus.Sent, + updatedAt, + }; + + if (unidentified) { + unidentifiedDeliveriesSet.add(identifier); + } + } + ); + + toUpdate.set({ + sendStateByConversationId, + unidentifiedDeliveries: [...unidentifiedDeliveriesSet], + }); + await DataWriter.saveMessage(toUpdate.attributes, { + ourAci: window.textsecure.storage.user.getCheckedAci(), + postSaveUpdates, + }); + + confirm(); + return; + } + if (isUpdate) { + log.warn( + `${idLog}: Received update transcript, but no existing entry for message ${getMessageIdForLogging(message.attributes)}. Dropping.` + ); + + confirm(); + return; + } + if (existingMessage) { + // TODO: (DESKTOP-7301): improve this check in case previous message is not yet + // registered in memory + log.warn( + `${idLog}: Received duplicate transcript for message ${getMessageIdForLogging(message.attributes)}, but it was not an update transcript. Dropping.` + ); + + confirm(); + return; + } + } + + // GroupV2 + + if (initialMessage.groupV2) { + if (isGroupV1(conversation.attributes)) { + // If we received a GroupV2 message in a GroupV1 group, we migrate! + + const { revision, groupChange } = initialMessage.groupV2; + await window.Signal.Groups.respondToGroupV2Migration({ + conversation, + groupChange: groupChange + ? { + base64: groupChange, + isTrusted: false, + } + : undefined, + newRevision: revision, + receivedAt: message.get('received_at'), + sentAt: message.get('sent_at'), + }); + } else if ( + initialMessage.groupV2.masterKey && + initialMessage.groupV2.secretParams && + initialMessage.groupV2.publicParams + ) { + // Repair core GroupV2 data if needed + await conversation.maybeRepairGroupV2({ + masterKey: initialMessage.groupV2.masterKey, + secretParams: initialMessage.groupV2.secretParams, + publicParams: initialMessage.groupV2.publicParams, + }); + + const existingRevision = conversation.get('revision'); + const isFirstUpdate = !isNumber(existingRevision); + + // Standard GroupV2 modification codepath + const isV2GroupUpdate = + initialMessage.groupV2 && + isNumber(initialMessage.groupV2.revision) && + (isFirstUpdate || initialMessage.groupV2.revision > existingRevision); + + if (isV2GroupUpdate && initialMessage.groupV2) { + const { revision, groupChange } = initialMessage.groupV2; + try { + await window.Signal.Groups.maybeUpdateGroup({ + conversation, + groupChange: groupChange + ? { + base64: groupChange, + isTrusted: false, + } + : undefined, + newRevision: revision, + receivedAt: message.get('received_at'), + sentAt: message.get('sent_at'), + }); + } catch (error) { + const errorText = Errors.toLogFormat(error); + log.error( + `${idLog}: Failed to process group update as part of message ${getMessageIdForLogging(message.attributes)}: ${errorText}` + ); + throw error; + } + } + } + } + + const ourAci = window.textsecure.storage.user.getCheckedAci(); + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + const sender = window.ConversationController.lookupOrCreate({ + e164: source, + serviceId: sourceServiceId, + reason: 'handleDataMessage', + })!; + const hasGroupV2Prop = Boolean(initialMessage.groupV2); + + // Drop if from blocked user. Only GroupV2 messages should need to be dropped here. + const isBlocked = + (source && window.storage.blocked.isBlocked(source)) || + (sourceServiceId && + window.storage.blocked.isServiceIdBlocked(sourceServiceId)); + if (isBlocked) { + log.info( + `${idLog}: Dropping message from blocked sender. hasGroupV2Prop: ${hasGroupV2Prop}` + ); + + confirm(); + return; + } + + const areWeMember = + !conversation.get('left') && conversation.hasMember(ourAci); + + // Drop an incoming GroupV2 message if we or the sender are not part of the group + // after applying the message's associated group changes. + if ( + type === 'incoming' && + !isDirectConversation(conversation.attributes) && + hasGroupV2Prop && + (!areWeMember || + (sourceServiceId && !conversation.hasMember(sourceServiceId))) + ) { + log.warn( + `${idLog}: Received message destined for group, which we or the sender are not a part of. Dropping.` + ); + confirm(); + return; + } + + // We drop incoming messages for v1 groups we already know about, which we're not + // a part of, except for group updates. Because group v1 updates haven't been + // applied by this point. + // Note: if we have no information about a group at all, we will accept those + // messages. We detect that via a missing 'members' field. + if ( + type === 'incoming' && + !isDirectConversation(conversation.attributes) && + !hasGroupV2Prop && + conversation.get('members') && + !areWeMember + ) { + log.warn( + `Received message destined for group ${conversation.idForLogging()}, which we're not a part of. Dropping.` + ); + confirm(); + return; + } + + // Drop incoming messages to announcement only groups where sender is not admin + if (conversation.get('announcementsOnly')) { + const senderServiceId = sender.getServiceId(); + if (!senderServiceId || !conversation.isAdmin(senderServiceId)) { + confirm(); + return; + } + } + + const messageId = + message.get('id') || generateMessageId(message.get('received_at')).id; + + // Send delivery receipts, but only for non-story sealed sender messages + // and not for messages from unaccepted conversations + if ( + type === 'incoming' && + message.get('unidentifiedDeliveryReceived') && + !hasErrors(message.attributes) && + conversation.getAccepted() + ) { + // Note: We both queue and batch because we want to wait until we are done + // processing incoming messages to start sending outgoing delivery receipts. + // The queue can be paused easily. + drop( + window.Whisper.deliveryReceiptQueue.add(() => { + strictAssert( + isAciString(sourceServiceId), + 'Incoming message must be from ACI' + ); + window.Whisper.deliveryReceiptBatcher.add({ + messageId, + conversationId, + senderE164: source, + senderAci: sourceServiceId, + timestamp: message.get('sent_at'), + isDirectConversation: isDirectConversation(conversation.attributes), + }); + }) + ); + } + + const { storyContext } = initialMessage; + let storyContextLogId = 'no storyContext'; + if (storyContext) { + storyContextLogId = + `storyContext(${storyContext.sentTimestamp}, ` + + `${storyContext.authorAci})`; + } + + // Ensure that quote author's conversation exist + if (initialMessage.quote) { + window.ConversationController.lookupOrCreate({ + serviceId: initialMessage.quote.authorAci, + reason: 'handleDataMessage.quote.author', + }); + } + + const [quote, storyQuotes] = await Promise.all([ + initialMessage.quote + ? copyFromQuotedMessage(initialMessage.quote, conversation.id) + : undefined, + findStoryMessages(conversation.id, storyContext), + ]); + + const storyQuote = storyQuotes.find(candidateQuote => { + const sendStateByConversationId = + candidateQuote.sendStateByConversationId || {}; + const sendState = sendStateByConversationId[sender.id]; + + const storyQuoteIsFromSelf = + candidateQuote.sourceServiceId === window.storage.user.getCheckedAci(); + + if (!storyQuoteIsFromSelf) { + return true; + } + + // The sender is not a recipient for this story + if (sendState === undefined) { + return false; + } + + // Group replies are always allowed + if (!isDirectConversation(conversation.attributes)) { + return true; + } + + // For 1:1 stories, we need to check if they can be replied to + return sendState.isAllowedToReplyToStory !== false; + }); + + if ( + storyContext && + !storyQuote && + !isDirectConversation(conversation.attributes) + ) { + log.warn( + `${idLog}: Received ${storyContextLogId} message in group but no matching story. Dropping.` + ); + + confirm(); + return; + } + + if (storyQuote) { + const { storyDistributionListId } = storyQuote; + + if (storyDistributionListId) { + const storyDistribution = + await DataReader.getStoryDistributionWithMembers( + storyDistributionListId + ); + + if (!storyDistribution) { + log.warn( + `${idLog}: Received ${storyContextLogId} message for story with no associated distribution list. Dropping.` + ); + + confirm(); + return; + } + + if (!storyDistribution.allowsReplies) { + log.warn( + `${idLog}: Received ${storyContextLogId} message but distribution list does not allow replies. Dropping.` + ); + + confirm(); + return; + } + } + } + + const withQuoteReference = { + ...message.attributes, + ...initialMessage, + quote, + storyId: storyQuote?.id, + }; + + // There are type conflicts between ModelAttributesType and protos passed in here + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const dataMessage = await upgradeMessageSchema(withQuoteReference as any); + + const isGroupStoryReply = + isGroup(conversation.attributes) && dataMessage.storyId; + + try { + const now = new Date().getTime(); + + const urls = LinkPreview.findLinks(dataMessage.body || ''); + const incomingPreview = dataMessage.preview || []; + const preview = incomingPreview + .map((item: LinkPreviewType) => { + if (LinkPreview.isCallLink(item.url)) { + return { + ...item, + isCallLink: true, + callLinkRoomId: getRoomIdFromCallLink(item.url), + }; + } + + if (!item.image && !item.title) { + return null; + } + // Story link previews don't have to correspond to links in the + // message body. + if (isStory(message.attributes)) { + return item; + } + if ( + !urls.includes(item.url) || + !LinkPreview.shouldPreviewHref(item.url) + ) { + return undefined; + } + + return item; + }) + .filter(isNotNil); + if (preview.length < incomingPreview.length) { + log.info( + `${getMessageIdForLogging(message.attributes)}: Eliminated ${ + incomingPreview.length - preview.length + } previews with invalid urls'` + ); + } + + const ourPni = window.textsecure.storage.user.getCheckedPni(); + const ourServiceIds: Set = new Set([ourAci, ourPni]); + + const [longMessageAttachments, normalAttachments] = partition( + dataMessage.attachments ?? [], + attachment => MIME.isLongMessage(attachment.contentType) + ); + + // eslint-disable-next-line no-param-reassign + message = window.MessageCache.register(message); + message.set({ + id: messageId, + attachments: normalAttachments, + body: dataMessage.body, + bodyAttachment: longMessageAttachments[0], + bodyRanges: dataMessage.bodyRanges, + contact: dataMessage.contact, + conversationId: conversation.id, + decrypted_at: now, + errors: [], + flags: dataMessage.flags, + giftBadge: initialMessage.giftBadge, + hasAttachments: dataMessage.hasAttachments, + hasFileAttachments: dataMessage.hasFileAttachments, + hasVisualMediaAttachments: dataMessage.hasVisualMediaAttachments, + isViewOnce: Boolean(dataMessage.isViewOnce), + mentionsMe: (dataMessage.bodyRanges ?? []).some(bodyRange => { + if (!BodyRange.isMention(bodyRange)) { + return false; + } + return ourServiceIds.has( + normalizeServiceId( + bodyRange.mentionAci, + 'handleDataMessage: mentionsMe check' + ) + ); + }), + preview, + requiredProtocolVersion: + dataMessage.requiredProtocolVersion || INITIAL_PROTOCOL_VERSION, + supportedVersionAtReceive: CURRENT_PROTOCOL_VERSION, + payment: dataMessage.payment, + quote: dataMessage.quote, + schemaVersion: dataMessage.schemaVersion, + sticker: dataMessage.sticker, + storyId: dataMessage.storyId, + }); + + if (storyQuote) { + await hydrateStoryContext(message.id, storyQuote, { + shouldSave: true, + }); + } + + const isSupported = !isUnsupportedMessage(message.attributes); + if (!isSupported) { + await eraseMessageContents(message); + } + + if (isSupported) { + const attributes = { + ...conversation.attributes, + }; + + // Drop empty messages after. This needs to happen after the initial + // message.set call and after GroupV1 processing to make sure all possible + // properties are set before we determine that a message is empty. + if (isMessageEmpty(message.attributes)) { + log.info(`${idLog}: Dropping empty message`); + confirm(); + return; + } + + if (isStory(message.attributes)) { + attributes.hasPostedStory = true; + } else { + attributes.active_at = now; + } + + conversation.set(attributes); + + // Sync group story reply expiration timers with the parent story's + // expiration timer + if (isGroupStoryReply && storyQuote) { + message.set({ + expireTimer: storyQuote.expireTimer, + expirationStartTimestamp: storyQuote.expirationStartTimestamp, + }); + } + + if (dataMessage.expireTimer && !isExpirationTimerUpdate(dataMessage)) { + message.set({ expireTimer: dataMessage.expireTimer }); + if (isStory(message.attributes)) { + log.info(`${idLog}: Starting story expiration`); + message.set({ + expirationStartTimestamp: dataMessage.timestamp, + }); + } + } + + if (!hasGroupV2Prop && !isStory(message.attributes)) { + if (isExpirationTimerUpdate(message.attributes)) { + message.set({ + expirationTimerUpdate: { + source, + sourceServiceId, + expireTimer: initialMessage.expireTimer, + }, + }); + + if (conversation.get('expireTimer') !== dataMessage.expireTimer) { + log.info('Incoming expirationTimerUpdate changed timer', { + id: conversation.idForLogging(), + expireTimer: dataMessage.expireTimer || 'disabled', + source: idLog, + }); + conversation.set({ + expireTimer: dataMessage.expireTimer, + }); + } + } + + // Note: For incoming expire timer updates (not normal messages that come + // along with an expireTimer), the conversation will be updated by this + // point and these calls will return early. + if (dataMessage.expireTimer) { + void conversation.updateExpirationTimer(dataMessage.expireTimer, { + source: sourceServiceId || source, + receivedAt: message.get('received_at'), + receivedAtMS: message.get('received_at_ms'), + sentAt: message.get('sent_at'), + reason: idLog, + version: initialMessage.expireTimerVersion, + }); + } else if ( + // We won't turn off timers for these kinds of messages: + !isGroupUpdate(message.attributes) && + !isEndSession(message.attributes) + ) { + void conversation.updateExpirationTimer(undefined, { + source: sourceServiceId || source, + receivedAt: message.get('received_at'), + receivedAtMS: message.get('received_at_ms'), + sentAt: message.get('sent_at'), + reason: idLog, + version: initialMessage.expireTimerVersion, + }); + } + } + + if (initialMessage.profileKey) { + const { profileKey } = initialMessage; + if ( + source === window.textsecure.storage.user.getNumber() || + sourceServiceId === window.textsecure.storage.user.getAci() + ) { + conversation.set({ profileSharing: true }); + } else if (isDirectConversation(conversation.attributes)) { + drop( + conversation.setProfileKey(profileKey, { + reason: 'handleDataMessage', + }) + ); + } else { + const local = window.ConversationController.lookupOrCreate({ + e164: source, + serviceId: sourceServiceId, + reason: 'handleDataMessage:setProfileKey', + }); + drop( + local?.setProfileKey(profileKey, { + reason: 'handleDataMessage', + }) + ); + } + } + + if (isTapToView(message.attributes) && type === 'outgoing') { + await eraseMessageContents(message); + } + + if ( + type === 'incoming' && + isTapToView(message.attributes) && + !isValidTapToView(message.attributes) + ) { + log.warn( + `${idLog}: Received tap to view message with invalid data. Erasing contents.` + ); + message.set({ + isTapToViewInvalid: true, + }); + await eraseMessageContents(message); + } + } + + const conversationTimestamp = conversation.get('timestamp'); + if ( + !isStory(message.attributes) && + !isGroupStoryReply && + (!conversationTimestamp || + message.get('sent_at') > conversationTimestamp) && + messageHasPaymentEvent(message.attributes) + ) { + conversation.set({ + lastMessage: getNotificationTextForMessage(message.attributes), + lastMessageAuthor: getMessageAuthorText(message.attributes), + timestamp: message.get('sent_at'), + }); + } + + // eslint-disable-next-line no-param-reassign + message = window.MessageCache.register(message); + conversation.incrementMessageCount(); + + // If we sent a message in a given conversation, unarchive it! + if (type === 'outgoing') { + conversation.setArchived(false); + } + + await DataWriter.updateConversation(conversation.attributes); + + const giftBadge = message.get('giftBadge'); + if (giftBadge && giftBadge.state !== GiftBadgeStates.Failed) { + const { level } = giftBadge; + const { updatesUrl } = window.SignalContext.config; + strictAssert( + typeof updatesUrl === 'string', + 'getProfile: expected updatesUrl to be a defined string' + ); + const userLanguages = getUserLanguages( + window.SignalContext.getPreferredSystemLocales(), + window.SignalContext.getResolvedMessagesLocale() + ); + const { messaging } = window.textsecure; + if (!messaging) { + throw new Error(`${idLog}: messaging is not available`); + } + const response = + await messaging.server.getSubscriptionConfiguration(userLanguages); + const boostBadgesByLevel = parseBoostBadgeListFromServer( + response, + updatesUrl + ); + const badge = boostBadgesByLevel[level]; + if (!badge) { + log.error( + `${idLog}: gift badge with level ${level} not found on server` + ); + } else { + await window.reduxActions.badges.updateOrCreate([badge]); + giftBadge.id = badge.id; + } + } + + const result = await modifyTargetMessage(message, conversation, { + isFirstRun: true, + skipEdits: false, + }); + if (result === ModifyTargetMessageResult.Deleted) { + confirm(); + return; + } + + log.info(`${idLog}: Batching save`); + drop(saveAndNotify(message, conversation, confirm)); + } catch (error) { + const errorForLog = Errors.toLogFormat(error); + log.error(`${idLog}: error:`, errorForLog); + throw error; + } + }); +} diff --git a/ts/messages/migrateMessageData.ts b/ts/messages/migrateMessageData.ts index 148e6be9e..59ff9d38f 100644 --- a/ts/messages/migrateMessageData.ts +++ b/ts/messages/migrateMessageData.ts @@ -12,6 +12,7 @@ import type { MessageAttributesType } from '../model-types.d'; import type { AciString } from '../types/ServiceId'; import * as Errors from '../types/errors'; import { DataReader, DataWriter } from '../sql/Client'; +import { postSaveUpdates } from '../util/cleanup'; const MAX_CONCURRENCY = 5; @@ -57,7 +58,7 @@ export async function _migrateMessageData({ ) => Promise>; saveMessagesIndividually: ( data: ReadonlyArray, - options: { ourAci: AciString } + options: { ourAci: AciString; postSaveUpdates: () => Promise } ) => Promise<{ failedIndices: Array }>; incrementMessagesMigrationAttempts: ( messageIds: ReadonlyArray @@ -122,6 +123,7 @@ export async function _migrateMessageData({ upgradedMessages, { ourAci, + postSaveUpdates, } ); diff --git a/ts/messages/saveAndNotify.ts b/ts/messages/saveAndNotify.ts new file mode 100644 index 000000000..6e3d5afb4 --- /dev/null +++ b/ts/messages/saveAndNotify.ts @@ -0,0 +1,72 @@ +// Copyright 2024 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +import * as log from '../logging/log'; + +import { explodePromise } from '../util/explodePromise'; + +import { saveNewMessageBatcher } from '../util/messageBatcher'; +import { handleAttachmentDownloadsForNewMessage } from '../util/queueAttachmentDownloads'; +import { + modifyTargetMessage, + ModifyTargetMessageResult, +} from '../util/modifyTargetMessage'; +import { shouldReplyNotifyUser } from '../util/shouldReplyNotifyUser'; +import { isStory } from './helpers'; +import { drop } from '../util/drop'; + +import type { ConversationModel } from '../models/conversations'; +import type { MessageModel } from '../models/messages'; + +export async function saveAndNotify( + message: MessageModel, + conversation: ConversationModel, + confirm: () => void +): Promise { + const { resolve, promise } = explodePromise(); + try { + conversation.addSavePromise(promise); + + await saveNewMessageBatcher.add(message.attributes); + + log.info('Message saved', message.get('sent_at')); + + // Once the message is saved to DB, we queue attachment downloads + await handleAttachmentDownloadsForNewMessage(message, conversation); + + // We'd like to check for deletions before scheduling downloads, but if an edit + // comes in, we want to have kicked off attachment downloads for the original + // message. + const result = await modifyTargetMessage(message, conversation, { + isFirstRun: false, + skipEdits: false, + }); + if (result === ModifyTargetMessageResult.Deleted) { + confirm(); + return; + } + + drop(conversation.onNewMessage(message)); + + if (await shouldReplyNotifyUser(message.attributes, conversation)) { + await conversation.notify(message.attributes); + } + + // Increment the sent message count if this is an outgoing message + if (message.get('type') === 'outgoing') { + conversation.incrementSentMessageCount(); + } + + window.Whisper.events.trigger('incrementProgress'); + confirm(); + + if (!isStory(message.attributes)) { + drop( + conversation.queueJob('updateUnread', () => conversation.updateUnread()) + ); + } + } finally { + resolve(); + conversation.removeSavePromise(promise); + } +} diff --git a/ts/messages/send.ts b/ts/messages/send.ts new file mode 100644 index 000000000..90f4efab4 --- /dev/null +++ b/ts/messages/send.ts @@ -0,0 +1,491 @@ +// Copyright 2024 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +import { noop, union } from 'lodash'; + +import { filter, map } from '../util/iterables'; +import { isNotNil } from '../util/isNotNil'; +import { SendMessageProtoError } from '../textsecure/Errors'; +import { getOwn } from '../util/getOwn'; +import { isGroup } from '../util/whatTypeOfConversation'; +import { handleMessageSend } from '../util/handleMessageSend'; +import { getSendOptions } from '../util/getSendOptions'; +import * as log from '../logging/log'; +import { DataWriter } from '../sql/Client'; +import { + getPropForTimestamp, + getChangesForPropAtTimestamp, +} from '../util/editHelpers'; +import { getMessageSentTimestamp } from '../util/getMessageSentTimestamp'; +import { + notifyStorySendFailed, + saveErrorsOnMessage, +} from '../test-node/util/messageFailures'; +import { postSaveUpdates } from '../util/cleanup'; +import { isCustomError } from './helpers'; +import { SendActionType, isSent, sendStateReducer } from './MessageSendState'; + +import type { CustomError, MessageAttributesType } from '../model-types.d'; +import type { CallbackResultType } from '../textsecure/Types.d'; +import type { MessageModel } from '../models/messages'; +import type { ServiceIdString } from '../types/ServiceId'; +import type { SendStateByConversationId } from './MessageSendState'; + +/* eslint-disable more/no-then */ + +export async function send( + message: MessageModel, + { + promise, + saveErrors, + targetTimestamp, + }: { + promise: Promise; + saveErrors?: (errors: Array) => void; + targetTimestamp: number; + } +): Promise { + const conversation = window.ConversationController.get( + message.attributes.conversationId + ); + const updateLeftPane = conversation?.debouncedUpdateLastMessage ?? noop; + + updateLeftPane(); + + let result: + | { success: true; value: CallbackResultType } + | { + success: false; + value: CustomError | SendMessageProtoError; + }; + try { + const value = await (promise as Promise); + result = { success: true, value }; + } catch (err) { + result = { success: false, value: err }; + } + + updateLeftPane(); + + const attributesToUpdate: Partial = {}; + + // This is used by sendSyncMessage, then set to null + if ('dataMessage' in result.value && result.value.dataMessage) { + attributesToUpdate.dataMessage = result.value.dataMessage; + } else if ('editMessage' in result.value && result.value.editMessage) { + attributesToUpdate.dataMessage = result.value.editMessage; + } + + if (!message.doNotSave) { + await DataWriter.saveMessage(message.attributes, { + ourAci: window.textsecure.storage.user.getCheckedAci(), + postSaveUpdates, + }); + } + + const sendStateByConversationId = { + ...(getPropForTimestamp({ + log, + message: message.attributes, + prop: 'sendStateByConversationId', + targetTimestamp, + }) || {}), + }; + + const sendIsNotFinal = + 'sendIsNotFinal' in result.value && result.value.sendIsNotFinal; + const sendIsFinal = !sendIsNotFinal; + + // Capture successful sends + const successfulServiceIds: Array = + sendIsFinal && + 'successfulServiceIds' in result.value && + Array.isArray(result.value.successfulServiceIds) + ? result.value.successfulServiceIds + : []; + const sentToAtLeastOneRecipient = + result.success || Boolean(successfulServiceIds.length); + + successfulServiceIds.forEach(serviceId => { + const targetConversation = window.ConversationController.get(serviceId); + if (!targetConversation) { + return; + } + + // If we successfully sent to a user, we can remove our unregistered flag. + if (targetConversation.isEverUnregistered()) { + targetConversation.setRegistered(); + } + + const previousSendState = getOwn( + sendStateByConversationId, + targetConversation.id + ); + if (previousSendState) { + sendStateByConversationId[targetConversation.id] = sendStateReducer( + previousSendState, + { + type: SendActionType.Sent, + updatedAt: Date.now(), + } + ); + } + }); + + // Integrate sends via sealed sender + const latestEditTimestamp = message.get('editMessageTimestamp'); + const sendIsLatest = + !latestEditTimestamp || targetTimestamp === latestEditTimestamp; + const previousUnidentifiedDeliveries = + message.get('unidentifiedDeliveries') || []; + const newUnidentifiedDeliveries = + sendIsLatest && + sendIsFinal && + 'unidentifiedDeliveries' in result.value && + Array.isArray(result.value.unidentifiedDeliveries) + ? result.value.unidentifiedDeliveries + : []; + + const promises: Array> = []; + + // Process errors + let errors: Array; + if (result.value instanceof SendMessageProtoError && result.value.errors) { + ({ errors } = result.value); + } else if (isCustomError(result.value)) { + errors = [result.value]; + } else if (Array.isArray(result.value.errors)) { + ({ errors } = result.value); + } else { + errors = []; + } + + // In groups, we don't treat unregistered users as a user-visible + // error. The message will look successful, but the details + // screen will show that we didn't send to these unregistered users. + const errorsToSave: Array = []; + + errors.forEach(error => { + const errorConversation = + window.ConversationController.get(error.serviceId) || + window.ConversationController.get(error.number); + + if (errorConversation && !saveErrors && sendIsFinal) { + const previousSendState = getOwn( + sendStateByConversationId, + errorConversation.id + ); + if (previousSendState) { + sendStateByConversationId[errorConversation.id] = sendStateReducer( + previousSendState, + { + type: SendActionType.Failed, + updatedAt: Date.now(), + } + ); + notifyStorySendFailed(message); + } + } + + let shouldSaveError = true; + switch (error.name) { + case 'OutgoingIdentityKeyError': { + if (conversation) { + promises.push( + conversation.getProfiles().catch(() => { + /* nothing to do here; logging already happened */ + }) + ); + } + break; + } + case 'UnregisteredUserError': + if (conversation && isGroup(conversation.attributes)) { + shouldSaveError = false; + } + // If we just found out that we couldn't send to a user because they are no + // longer registered, we will update our unregistered flag. In groups we + // will not event try to send to them for 6 hours. And we will never try + // to fetch them on startup again. + // + // The way to discover registration once more is: + // 1) any attempt to send to them in 1:1 conversation + // 2) the six-hour time period has passed and we send in a group again + conversation?.setUnregistered(); + break; + default: + break; + } + + if (shouldSaveError) { + errorsToSave.push(error); + } + }); + + // Only update the expirationStartTimestamp if we don't already have one set + if (!message.get('expirationStartTimestamp')) { + attributesToUpdate.expirationStartTimestamp = sentToAtLeastOneRecipient + ? Date.now() + : undefined; + } + attributesToUpdate.unidentifiedDeliveries = union( + previousUnidentifiedDeliveries, + newUnidentifiedDeliveries + ); + // We may overwrite this in the `saveErrors` call below. + attributesToUpdate.errors = []; + + const additionalProps = getChangesForPropAtTimestamp({ + log, + message: message.attributes, + prop: 'sendStateByConversationId', + targetTimestamp, + value: sendStateByConversationId, + }); + + message.set({ ...attributesToUpdate, ...additionalProps }); + if (saveErrors) { + saveErrors(errorsToSave); + } else { + // We skip save because we'll save in the next step. + await saveErrorsOnMessage(message, errorsToSave, { + skipSave: true, + }); + } + + if (!message.doNotSave) { + await window.MessageCache.saveMessage(message); + } + + updateLeftPane(); + + if (sentToAtLeastOneRecipient && !message.doNotSendSyncMessage) { + promises.push(sendSyncMessage(message, targetTimestamp)); + } + + await Promise.all(promises); + + updateLeftPane(); +} + +export async function sendSyncMessageOnly( + message: MessageModel, + { + targetTimestamp, + dataMessage, + saveErrors, + }: { + targetTimestamp: number; + dataMessage: Uint8Array; + saveErrors?: (errors: Array) => void; + } +): Promise { + const conv = window.ConversationController.get( + message.attributes.conversationId + ); + message.set({ dataMessage }); + + const updateLeftPane = conv?.debouncedUpdateLastMessage; + + try { + message.set({ + // This is the same as a normal send() + expirationStartTimestamp: Date.now(), + errors: [], + }); + const result = await sendSyncMessage(message, targetTimestamp); + message.set({ + // We have to do this afterward, since we didn't have a previous send! + unidentifiedDeliveries: + result && result.unidentifiedDeliveries + ? result.unidentifiedDeliveries + : undefined, + }); + return result; + } catch (error) { + const resultErrors = error?.errors; + const errors = Array.isArray(resultErrors) + ? resultErrors + : [new Error('Unknown error')]; + if (saveErrors) { + saveErrors(errors); + } else { + // We don't save because we're about to save below. + await saveErrorsOnMessage(message, errors, { + skipSave: true, + }); + } + throw error; + } finally { + await DataWriter.saveMessage(message.attributes, { + ourAci: window.textsecure.storage.user.getCheckedAci(), + postSaveUpdates, + }); + + if (updateLeftPane) { + updateLeftPane(); + } + } +} + +export async function sendSyncMessage( + message: MessageModel, + targetTimestamp: number +): Promise { + const ourConversation = + window.ConversationController.getOurConversationOrThrow(); + const sendOptions = await getSendOptions(ourConversation.attributes, { + syncMessage: true, + }); + + if (window.ConversationController.areWePrimaryDevice()) { + log.warn( + 'sendSyncMessage: We are primary device; not sending sync message' + ); + message.set({ dataMessage: undefined }); + return; + } + + const { messaging } = window.textsecure; + if (!messaging) { + throw new Error('sendSyncMessage: messaging not available!'); + } + + // eslint-disable-next-line no-param-reassign + message.syncPromise = message.syncPromise || Promise.resolve(); + const next = async () => { + const dataMessage = message.get('dataMessage'); + if (!dataMessage) { + return; + } + + const originalTimestamp = getMessageSentTimestamp(message.attributes, { + includeEdits: false, + log, + }); + const isSendingEdit = targetTimestamp !== originalTimestamp; + + const isUpdate = Boolean(message.get('synced')) && !isSendingEdit; + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + const conv = window.ConversationController.get( + message.attributes.conversationId + )!; + + const sendEntries = Object.entries( + getPropForTimestamp({ + log, + message: message.attributes, + prop: 'sendStateByConversationId', + targetTimestamp, + }) || {} + ); + const sentEntries = filter(sendEntries, ([_conversationId, { status }]) => + isSent(status) + ); + const allConversationIdsSentTo = map( + sentEntries, + ([conversationId]) => conversationId + ); + const conversationIdsSentTo = filter( + allConversationIdsSentTo, + conversationId => conversationId !== ourConversation.id + ); + + const unidentifiedDeliveries = message.get('unidentifiedDeliveries') || []; + const maybeConversationsWithSealedSender = map( + unidentifiedDeliveries, + identifier => window.ConversationController.get(identifier) + ); + const conversationsWithSealedSender = filter( + maybeConversationsWithSealedSender, + isNotNil + ); + const conversationIdsWithSealedSender = new Set( + map(conversationsWithSealedSender, c => c.id) + ); + + const encodedContent = isSendingEdit + ? { + encodedEditMessage: dataMessage, + } + : { + encodedDataMessage: dataMessage, + }; + + return handleMessageSend( + messaging.sendSyncMessage({ + ...encodedContent, + timestamp: targetTimestamp, + destination: conv.get('e164'), + destinationServiceId: conv.getServiceId(), + expirationStartTimestamp: + message.get('expirationStartTimestamp') || null, + conversationIdsSentTo, + conversationIdsWithSealedSender, + isUpdate, + options: sendOptions, + urgent: false, + }), + // Note: in some situations, for doNotSave messages, the message has no + // id, so we provide an empty array here. + { messageIds: message.id ? [message.id] : [], sendType: 'sentSync' } + ).then(async result => { + let newSendStateByConversationId: undefined | SendStateByConversationId; + const sendStateByConversationId = + getPropForTimestamp({ + log, + message: message.attributes, + prop: 'sendStateByConversationId', + targetTimestamp, + }) || {}; + const ourOldSendState = getOwn( + sendStateByConversationId, + ourConversation.id + ); + if (ourOldSendState) { + const ourNewSendState = sendStateReducer(ourOldSendState, { + type: SendActionType.Sent, + updatedAt: Date.now(), + }); + if (ourNewSendState !== ourOldSendState) { + newSendStateByConversationId = { + ...sendStateByConversationId, + [ourConversation.id]: ourNewSendState, + }; + } + } + + const attributesForUpdate = newSendStateByConversationId + ? getChangesForPropAtTimestamp({ + log, + message: message.attributes, + prop: 'sendStateByConversationId', + value: newSendStateByConversationId, + targetTimestamp, + }) + : null; + + message.set({ + synced: true, + dataMessage: null, + ...attributesForUpdate, + }); + + // Return early, skip the save + if (message.doNotSave) { + return result; + } + + await DataWriter.saveMessage(message.attributes, { + ourAci: window.textsecure.storage.user.getCheckedAci(), + postSaveUpdates, + }); + return result; + }); + }; + + // eslint-disable-next-line no-param-reassign + message.syncPromise = message.syncPromise.then(next, next); + + return message.syncPromise; +} diff --git a/ts/model-types.d.ts b/ts/model-types.d.ts index 809c8228c..8d96a17b0 100644 --- a/ts/model-types.d.ts +++ b/ts/model-types.d.ts @@ -71,8 +71,8 @@ export type CustomError = Error & { export type GroupMigrationType = { areWeInvited: boolean; - droppedMemberIds?: Array; - invitedMembers?: Array; + droppedMemberIds?: ReadonlyArray; + invitedMembers?: ReadonlyArray; // We don't generate data like this; these were added to support import/export droppedMemberCount?: number; @@ -113,7 +113,7 @@ type StoryReplyContextType = { export type GroupV1Update = { avatarUpdated?: boolean; - joined?: Array; + joined?: ReadonlyArray; left?: string | 'You'; name?: string; }; @@ -130,11 +130,11 @@ export type MessageReactionType = { // needs more usage of get/setPropForTimestamp. Also, these fields must match the fields // in MessageAttributesType. export type EditHistoryType = { - attachments?: Array; + attachments?: ReadonlyArray; body?: string; bodyAttachment?: AttachmentType; bodyRanges?: ReadonlyArray; - preview?: Array; + preview?: ReadonlyArray; quote?: QuotedMessageType; sendStateByConversationId?: SendStateByConversationId; timestamp: number; @@ -178,7 +178,7 @@ export type MessageAttributesType = { decrypted_at?: number; deletedForEveryone?: boolean; deletedForEveryoneTimestamp?: number; - errors?: Array; + errors?: ReadonlyArray; expirationStartTimestamp?: number | null; expireTimer?: DurationInSeconds; groupMigration?: GroupMigrationType; @@ -190,7 +190,7 @@ export type MessageAttributesType = { isErased?: boolean; isTapToViewInvalid?: boolean; isViewOnce?: boolean; - editHistory?: Array; + editHistory?: ReadonlyArray; editMessageTimestamp?: number; editMessageReceivedAt?: number; editMessageReceivedAtMs?: number; @@ -220,12 +220,12 @@ export type MessageAttributesType = { id: string; type: MessageType; body?: string; - attachments?: Array; - preview?: Array; + attachments?: ReadonlyArray; + preview?: ReadonlyArray; sticker?: StickerType; sent_at: number; - unidentifiedDeliveries?: Array; - contact?: Array; + unidentifiedDeliveries?: ReadonlyArray; + contact?: ReadonlyArray; conversationId: string; storyReaction?: { emoji: string; @@ -286,8 +286,8 @@ export type MessageAttributesType = { timestamp: number; // Backwards-compatibility with prerelease data schema - invitedGV2Members?: Array; - droppedGV2MemberIds?: Array; + invitedGV2Members?: ReadonlyArray; + droppedGV2MemberIds?: ReadonlyArray; sendHQImages?: boolean; diff --git a/ts/models/conversations.ts b/ts/models/conversations.ts index 0c866b6ac..db5de8a28 100644 --- a/ts/models/conversations.ts +++ b/ts/models/conversations.ts @@ -189,6 +189,9 @@ import { getCallHistorySelector } from '../state/selectors/callHistory'; import { migrateLegacyReadStatus } from '../messages/migrateLegacyReadStatus'; import { migrateLegacySendAttributes } from '../messages/migrateLegacySendAttributes'; import { getIsInitialSync } from '../services/contactSync'; +import { queueAttachmentDownloadsForMessage } from '../util/queueAttachmentDownloads'; +import { cleanupMessages, postSaveUpdates } from '../util/cleanup'; +import { MessageModel } from './messages'; /* eslint-disable more/no-then */ window.Whisper = window.Whisper || {}; @@ -385,7 +388,6 @@ export class ConversationModel extends window.Backbone ); } - this.on('newmessage', this.onNewMessage); this.on('change:profileKey', this.onChangeProfileKey); this.on( 'change:name change:profileName change:profileFamilyName change:e164 ' + @@ -464,8 +466,6 @@ export class ConversationModel extends window.Backbone SECOND ); - this.on('newmessage', this.throttledUpdateVerified); - const migratedColor = this.getColor(); if (this.get('color') !== migratedColor) { this.set('color', migratedColor); @@ -1442,8 +1442,14 @@ export class ConversationModel extends window.Backbone }); } - async onNewMessage(message: MessageAttributesType): Promise { - const { sourceServiceId: serviceId, source: e164, sourceDevice } = message; + async onNewMessage(message: MessageModel): Promise { + const { + sourceServiceId: serviceId, + source: e164, + sourceDevice, + storyId, + } = message.attributes; + this.throttledUpdateVerified?.(); const source = window.ConversationController.lookupOrCreate({ serviceId, @@ -1459,15 +1465,15 @@ export class ConversationModel extends window.Backbone // If it's a group story reply or a story message, we don't want to update // the last message or add new messages to redux. - const isGroupStoryReply = isGroup(this.attributes) && message.storyId; - if (isGroupStoryReply || isStory(message)) { + const isGroupStoryReply = isGroup(this.attributes) && storyId; + if (isGroupStoryReply || isStory(message.attributes)) { return; } // Change to message request state if contact was removed and sent message. if ( this.get('removalStage') === 'justNotification' && - isIncoming(message) + isIncoming(message.attributes) ) { this.set({ removalStage: 'messageRequest', @@ -1476,7 +1482,7 @@ export class ConversationModel extends window.Backbone await DataWriter.updateConversation(this.attributes); } - void this.addSingleMessage(message); + drop(this.addSingleMessage(message.attributes)); } // New messages might arrive while we're in the middle of a bulk fetch from the @@ -1966,65 +1972,64 @@ export class ConversationModel extends window.Backbone const hydrated = await Promise.all( present.map(async message => { - let migratedMessage = message; + const model = window.MessageCache.register(new MessageModel(message)); + let updated = false; - const readStatus = migrateLegacyReadStatus(migratedMessage); + const readStatus = migrateLegacyReadStatus(model.attributes); if (readStatus !== undefined) { - migratedMessage = { - ...migratedMessage, + updated = true; + model.set({ readStatus, seenStatus: readStatus === ReadStatus.Unread ? SeenStatus.Unseen : SeenStatus.Seen, - }; + }); } if (ourConversationId) { const sendStateByConversationId = migrateLegacySendAttributes( - migratedMessage, + model.attributes, window.ConversationController.get.bind( window.ConversationController ), ourConversationId ); if (sendStateByConversationId) { - migratedMessage = { - ...migratedMessage, + updated = true; + model.set({ sendStateByConversationId, - }; + }); } } - const upgradedMessage = await window.MessageCache.upgradeSchema( - migratedMessage, + const startingAttributes = model.attributes; + await window.MessageCache.upgradeSchema( + model, Message.VERSION_NEEDED_FOR_DISPLAY ); + if (startingAttributes !== model.attributes) { + updated = true; + } const patch = await hydrateStoryContext(message.id, undefined, { shouldSave: true, }); - - const didMigrate = migratedMessage !== message; - const didUpgrade = upgradedMessage !== migratedMessage; - const didPatch = Boolean(patch); - - if (didMigrate || didUpgrade || didPatch) { - upgraded += 1; + if (patch) { + updated = true; + model.set(patch); } - if (didMigrate && !didUpgrade && !didPatch) { - await window.MessageCache.setAttributes({ - messageId: message.id, - messageAttributes: migratedMessage, - skipSaveToDatabase: false, + + if (updated) { + upgraded += 1; + const ourAci = window.textsecure.storage.user.getCheckedAci(); + await DataWriter.saveMessage(model.attributes, { + ourAci, + postSaveUpdates, }); } - if (patch) { - return { ...upgradedMessage, ...patch }; - } - - return upgradedMessage; + return model.attributes; }) ); if (upgraded > 0) { @@ -2322,15 +2327,13 @@ export class ConversationModel extends window.Backbone // eslint-disable-next-line no-await-in-loop await Promise.all( readMessages.map(async m => { - const registered = window.MessageCache.__DEPRECATED$register( - m.id, - m, - 'handleReadAndDownloadAttachments' - ); - const shouldSave = await registered.queueAttachmentDownloads(); + const registered = window.MessageCache.register(new MessageModel(m)); + const shouldSave = + await queueAttachmentDownloadsForMessage(registered); if (shouldSave) { await DataWriter.saveMessage(registered.attributes, { ourAci, + postSaveUpdates, }); } }) @@ -2354,7 +2357,7 @@ export class ConversationModel extends window.Backbone ? timestamp : lastMessageTimestamp; - const message: MessageAttributesType = { + const message = new MessageModel({ ...generateMessageId(incrementMessageCounter()), conversationId: this.id, type: 'message-request-response-event', @@ -2364,18 +2367,17 @@ export class ConversationModel extends window.Backbone seenStatus: SeenStatus.NotApplicable, timestamp, messageRequestResponseEvent: event, - }; + }); - await DataWriter.saveMessage(message, { - ourAci: window.textsecure.storage.user.getCheckedAci(), + await window.MessageCache.saveMessage(message, { forceSave: true, }); if (!getIsInitialSync() && !this.get('active_at')) { this.set({ active_at: Date.now() }); await DataWriter.updateConversation(this.attributes); } - window.MessageCache.toMessageAttributes(message); - this.trigger('newmessage', message); + window.MessageCache.register(message); + drop(this.onNewMessage(message)); drop(this.updateLastMessage()); } @@ -3120,7 +3122,7 @@ export class ConversationModel extends window.Backbone receivedAt, }); - const message: MessageAttributesType = { + const message = new MessageModel({ ...generateMessageId(receivedAtCounter), conversationId: this.id, type: 'chat-session-refreshed', @@ -3129,19 +3131,15 @@ export class ConversationModel extends window.Backbone received_at_ms: receivedAt, readStatus: ReadStatus.Unread, seenStatus: SeenStatus.Unseen, - }; - - await DataWriter.saveMessage(message, { - ourAci: window.textsecure.storage.user.getCheckedAci(), }); - window.MessageCache.__DEPRECATED$register( - message.id, - message, - 'addChatSessionRefreshed' - ); - this.trigger('newmessage', message); - void this.updateUnread(); + await window.MessageCache.saveMessage(message, { + forceSave: true, + }); + window.MessageCache.register(message); + + drop(this.onNewMessage(message)); + drop(this.updateUnread()); } async addDeliveryIssue({ @@ -3167,7 +3165,7 @@ export class ConversationModel extends window.Backbone return; } - const message: MessageAttributesType = { + const message = new MessageModel({ ...generateMessageId(receivedAtCounter), conversationId: this.id, type: 'delivery-issue', @@ -3177,21 +3175,17 @@ export class ConversationModel extends window.Backbone timestamp: receivedAt, readStatus: ReadStatus.Unread, seenStatus: SeenStatus.Unseen, - }; - - await DataWriter.saveMessage(message, { - ourAci: window.textsecure.storage.user.getCheckedAci(), }); - window.MessageCache.__DEPRECATED$register( - message.id, - message, - 'addDeliveryIssue' - ); - this.trigger('newmessage', message); + await window.MessageCache.saveMessage(message, { + forceSave: true, + }); + window.MessageCache.register(message); - await this.notify(message); - void this.updateUnread(); + drop(this.onNewMessage(message)); + drop(this.updateUnread()); + + await this.notify(message.attributes); } async addKeyChange( @@ -3216,7 +3210,7 @@ export class ConversationModel extends window.Backbone } const timestamp = Date.now(); - const message: MessageAttributesType = { + const message = new MessageModel({ ...generateMessageId(incrementMessageCounter()), conversationId: this.id, type: 'keychange', @@ -3227,19 +3221,14 @@ export class ConversationModel extends window.Backbone readStatus: ReadStatus.Read, seenStatus: SeenStatus.Unseen, schemaVersion: Message.VERSION_NEEDED_FOR_DISPLAY, - }; + }); - await DataWriter.saveMessage(message, { - ourAci: window.textsecure.storage.user.getCheckedAci(), + await window.MessageCache.saveMessage(message, { forceSave: true, }); - window.MessageCache.__DEPRECATED$register( - message.id, - message, - 'addKeyChange' - ); + window.MessageCache.register(message); - this.trigger('newmessage', message); + drop(this.onNewMessage(message)); const serviceId = this.getServiceId(); @@ -3277,7 +3266,7 @@ export class ConversationModel extends window.Backbone ); const timestamp = Date.now(); - const message: MessageAttributesType = { + const message = new MessageModel({ ...generateMessageId(incrementMessageCounter()), conversationId: this.id, type: 'conversation-merge', @@ -3290,19 +3279,12 @@ export class ConversationModel extends window.Backbone readStatus: ReadStatus.Read, seenStatus: SeenStatus.Unseen, schemaVersion: Message.VERSION_NEEDED_FOR_DISPLAY, - }; - - await DataWriter.saveMessage(message, { - ourAci: window.textsecure.storage.user.getCheckedAci(), - forceSave: true, }); - window.MessageCache.__DEPRECATED$register( - message.id, - message, - 'addConversationMerge' - ); - this.trigger('newmessage', message); + await window.MessageCache.saveMessage(message, { forceSave: true }); + window.MessageCache.register(message); + + drop(this.onNewMessage(message)); } async addPhoneNumberDiscoveryIfNeeded(originalPni: PniString): Promise { @@ -3325,7 +3307,7 @@ export class ConversationModel extends window.Backbone log.info(`${logId}: adding notification`); const timestamp = Date.now(); - const message: MessageAttributesType = { + const message = new MessageModel({ ...generateMessageId(incrementMessageCounter()), conversationId: this.id, type: 'phone-number-discovery', @@ -3338,19 +3320,12 @@ export class ConversationModel extends window.Backbone readStatus: ReadStatus.Read, seenStatus: SeenStatus.Unseen, schemaVersion: Message.VERSION_NEEDED_FOR_DISPLAY, - }; - - await DataWriter.saveMessage(message, { - ourAci: window.textsecure.storage.user.getCheckedAci(), - forceSave: true, }); - window.MessageCache.__DEPRECATED$register( - message.id, - message, - 'addPhoneNumberDiscoveryIfNeeded' - ); - this.trigger('newmessage', message); + await window.MessageCache.saveMessage(message, { forceSave: true }); + window.MessageCache.register(message); + + drop(this.onNewMessage(message)); } async addVerifiedChange( @@ -3373,7 +3348,7 @@ export class ConversationModel extends window.Backbone ); const timestamp = Date.now(); - const message: MessageAttributesType = { + const message = new MessageModel({ ...generateMessageId(incrementMessageCounter()), conversationId: this.id, local: Boolean(options.local), @@ -3385,19 +3360,12 @@ export class ConversationModel extends window.Backbone type: 'verified-change', verified, verifiedChanged: verifiedChangeId, - }; - - await DataWriter.saveMessage(message, { - ourAci: window.textsecure.storage.user.getCheckedAci(), - forceSave: true, }); - window.MessageCache.__DEPRECATED$register( - message.id, - message, - 'addVerifiedChange' - ); - this.trigger('newmessage', message); + await window.MessageCache.saveMessage(message, { forceSave: true }); + window.MessageCache.register(message); + + drop(this.onNewMessage(message)); drop(this.updateUnread()); const serviceId = this.getServiceId(); @@ -3417,7 +3385,7 @@ export class ConversationModel extends window.Backbone conversationId?: string ): Promise { const now = Date.now(); - const message: MessageAttributesType = { + const message = new MessageModel({ ...generateMessageId(incrementMessageCounter()), conversationId: this.id, type: 'profile-change', @@ -3428,18 +3396,12 @@ export class ConversationModel extends window.Backbone timestamp: now, changedId: conversationId || this.id, profileChange, - }; - - await DataWriter.saveMessage(message, { - ourAci: window.textsecure.storage.user.getCheckedAci(), }); - window.MessageCache.__DEPRECATED$register( - message.id, - message, - 'addProfileChange' - ); - this.trigger('newmessage', message); + await window.MessageCache.saveMessage(message, { forceSave: true }); + window.MessageCache.register(message); + + drop(this.onNewMessage(message)); const serviceId = this.getServiceId(); if (isDirectConversation(this.attributes) && serviceId) { @@ -3460,7 +3422,7 @@ export class ConversationModel extends window.Backbone extra: Partial = {} ): Promise { const now = Date.now(); - const message: MessageAttributesType = { + const message = new MessageModel({ ...generateMessageId(incrementMessageCounter()), conversationId: this.id, type, @@ -3472,18 +3434,12 @@ export class ConversationModel extends window.Backbone seenStatus: SeenStatus.NotApplicable, ...extra, - }; - - await DataWriter.saveMessage(message, { - ourAci: window.textsecure.storage.user.getCheckedAci(), }); - window.MessageCache.__DEPRECATED$register( - message.id, - message as MessageAttributesType, - 'addNotification' - ); - this.trigger('newmessage', message); + await window.MessageCache.saveMessage(message, { forceSave: true }); + window.MessageCache.register(message); + + drop(this.onNewMessage(message)); return message.id; } @@ -3561,13 +3517,10 @@ export class ConversationModel extends window.Backbone `maybeRemoveUniversalTimer(${this.idForLogging()}): removed notification` ); - const message = window.MessageCache.__DEPRECATED$getById( - notificationId, - 'maybeRemoveUniversalTimer' - ); + const message = window.MessageCache.getById(notificationId); if (message) { await DataWriter.removeMessage(message.id, { - singleProtoJobQueue, + cleanupMessages, }); } return true; @@ -3607,13 +3560,10 @@ export class ConversationModel extends window.Backbone `maybeClearContactRemoved(${this.idForLogging()}): removed notification` ); - const message = window.MessageCache.__DEPRECATED$getById( - notificationId, - 'maybeClearContactRemoved' - ); + const message = window.MessageCache.getById(notificationId); if (message) { await DataWriter.removeMessage(message.id, { - singleProtoJobQueue, + cleanupMessages, }); } @@ -4164,16 +4114,12 @@ export class ConversationModel extends window.Backbone storyId, }); - window.MessageCache.__DEPRECATED$register( - attributes.id, - attributes, - 'enqueueMessageForSend' - ); + const model = window.MessageCache.register(new MessageModel(attributes)); const dbStart = Date.now(); strictAssert( - typeof attributes.timestamp === 'number', + typeof model.get('timestamp') === 'number', 'Expected a timestamp' ); @@ -4186,17 +4132,16 @@ export class ConversationModel extends window.Backbone { type: conversationQueueJobEnum.enum.NormalMessage, conversationId: this.id, - messageId: attributes.id, + messageId: model.id, revision: this.get('revision'), }, async jobToInsert => { log.info( - `enqueueMessageForSend: saving message ${attributes.id} and job ${jobToInsert.id}` + `enqueueMessageForSend: saving message ${model.id} and job ${jobToInsert.id}` ); - await DataWriter.saveMessage(attributes, { + await window.MessageCache.saveMessage(model, { jobToInsert, forceSave: true, - ourAci: window.textsecure.storage.user.getCheckedAci(), }); } ); @@ -4212,14 +4157,14 @@ export class ConversationModel extends window.Backbone const renderStart = Date.now(); // Perform asynchronous tasks before entering the batching mode - await this.beforeAddSingleMessage(attributes); + await this.beforeAddSingleMessage(model.attributes); if (sticker) { - await addStickerPackReference(attributes.id, sticker.packId); + await addStickerPackReference(model.id, sticker.packId); } this.beforeMessageSend({ - message: attributes, + message: model.attributes, dontClearDraft, dontAddMessage: false, now, @@ -4364,21 +4309,27 @@ export class ConversationModel extends window.Backbone ) ); - let { preview, activity } = stats; + const { preview: previewAttributes, activity: activityAttributes } = stats; + let preview: MessageModel | undefined; + let activity: MessageModel | undefined; // Get the in-memory message from MessageCache so that if it already exists // in memory we use that data instead of the data from the db which may // be out of date. - if (preview) { - const inMemory = window.MessageCache.accessAttributes(preview.id); - preview = inMemory || preview; - preview = (await this.cleanAttributes([preview]))?.[0] || preview; + if (previewAttributes) { + preview = window.MessageCache.register( + new MessageModel(previewAttributes) + ); + const updates = (await this.cleanAttributes([preview.attributes]))?.[0]; + preview.set(updates); } - if (activity) { - const inMemory = window.MessageCache.accessAttributes(activity.id); - activity = inMemory || activity; - activity = (await this.cleanAttributes([activity]))?.[0] || activity; + if (activityAttributes) { + activity = window.MessageCache.register( + new MessageModel(activityAttributes) + ); + const updates = (await this.cleanAttributes([activity.attributes]))?.[0]; + activity.set(updates); } if ( @@ -4386,7 +4337,7 @@ export class ConversationModel extends window.Backbone this.get('draftTimestamp') && (!preview || // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - preview.sent_at < this.get('draftTimestamp')!) + preview.get('sent_at') < this.get('draftTimestamp')!) ) { return; } @@ -4395,36 +4346,43 @@ export class ConversationModel extends window.Backbone let lastMessageReceivedAt = this.get('lastMessageReceivedAt'); let lastMessageReceivedAtMs = this.get('lastMessageReceivedAtMs'); if (activity) { - const { callId } = activity; + const { callId } = activity.attributes; const callHistory = callId ? getCallHistorySelector(window.reduxStore.getState())(callId) : undefined; - timestamp = callHistory?.timestamp || activity.sent_at || timestamp; - lastMessageReceivedAt = activity.received_at || lastMessageReceivedAt; + timestamp = + callHistory?.timestamp || activity.get('sent_at') || timestamp; + lastMessageReceivedAt = + activity.get('received_at') || lastMessageReceivedAt; lastMessageReceivedAtMs = - activity.received_at_ms || lastMessageReceivedAtMs; + activity.get('received_at_ms') || lastMessageReceivedAtMs; } const notificationData = preview - ? getNotificationDataForMessage(preview) + ? getNotificationDataForMessage(preview.attributes) : undefined; this.set({ lastMessage: notificationData?.text || - (preview ? getNotificationTextForMessage(preview) : undefined) || + (preview + ? getNotificationTextForMessage(preview.attributes) + : undefined) || '', lastMessageBodyRanges: notificationData?.bodyRanges, lastMessagePrefix: notificationData?.emoji, - lastMessageAuthor: getMessageAuthorText(preview), - lastMessageStatus: - (preview ? getMessagePropStatus(preview, ourConversationId) : null) || - null, + lastMessageAuthor: preview + ? getMessageAuthorText(preview.attributes) + : undefined, + lastMessageStatus: preview + ? getMessagePropStatus(preview.attributes, ourConversationId) + : undefined, lastMessageReceivedAt, lastMessageReceivedAtMs, timestamp, - lastMessageDeletedForEveryone: preview?.deletedForEveryone || false, + lastMessageDeletedForEveryone: + preview?.get('deletedForEveryone') || false, }); await DataWriter.updateConversation(this.attributes); @@ -4785,7 +4743,7 @@ export class ConversationModel extends window.Backbone (isInitialSync && isFromSyncOperation) || isFromMe || isNoteToSelf; const counter = receivedAt ?? incrementMessageCounter(); - const attributes = { + const message = new MessageModel({ ...generateMessageId(counter), conversationId: this.id, expirationTimerUpdate: { @@ -4801,24 +4759,18 @@ export class ConversationModel extends window.Backbone sent_at: sentAt, timestamp: sentAt, type: 'timer-notification' as const, - }; - - await DataWriter.saveMessage(attributes, { - ourAci: window.textsecure.storage.user.getCheckedAci(), - forceSave: true, }); - window.MessageCache.__DEPRECATED$register( - attributes.id, - attributes, - 'updateExpirationTimer' - ); + await window.MessageCache.saveMessage(message, { + forceSave: true, + }); + window.MessageCache.register(message); - void this.addSingleMessage(attributes); + void this.addSingleMessage(message.attributes); void this.updateUnread(); log.info( - `${logId}: added a notification received_at=${attributes.received_at}` + `${logId}: added a notification received_at=${message.get('received_at')}` ); } @@ -5306,9 +5258,9 @@ export class ConversationModel extends window.Backbone log.info(`${logId}: Starting delete`); await DataWriter.removeMessagesInConversation(this.id, { + cleanupMessages, fromSync: source !== 'local-delete-sync', logId: this.idForLogging(), - singleProtoJobQueue, }); log.info(`${logId}: Delete complete`); } diff --git a/ts/models/messages.ts b/ts/models/messages.ts index 9f435f625..c1f782dec 100644 --- a/ts/models/messages.ts +++ b/ts/models/messages.ts @@ -1,172 +1,56 @@ // Copyright 2020 Signal Messenger, LLC // SPDX-License-Identifier: AGPL-3.0-only -import { - isNumber, - isObject, - mapValues, - maxBy, - noop, - partition, - pick, - union, -} from 'lodash'; - -import type { - CustomError, - MessageAttributesType, - MessageReactionType, -} from '../model-types.d'; -import { filter, map, repeat, zipObject } from '../util/iterables'; -import * as GoogleChrome from '../util/GoogleChrome'; -import type { DeleteAttributesType } from '../messageModifiers/Deletes'; -import type { SentEventData } from '../textsecure/messageReceiverEvents'; -import { isNotNil } from '../util/isNotNil'; -import { isNormalNumber } from '../util/isNormalNumber'; -import { strictAssert } from '../util/assert'; -import { hydrateStoryContext } from '../util/hydrateStoryContext'; -import { generateMessageId } from '../util/generateMessageId'; -import { drop } from '../util/drop'; -import type { ConversationModel } from './conversations'; -import type { - ProcessedDataMessage, - ProcessedUnidentifiedDeliveryStatus, - CallbackResultType, -} from '../textsecure/Types.d'; -import { SendMessageProtoError } from '../textsecure/Errors'; -import { getUserLanguages } from '../util/userLanguages'; - -import type { ReactionType } from '../types/Reactions'; -import { ReactionReadStatus } from '../types/Reactions'; -import type { ServiceIdString } from '../types/ServiceId'; -import { normalizeServiceId } from '../types/ServiceId'; -import { isAciString } from '../util/isAciString'; -import * as reactionUtil from '../reactions/util'; -import * as Errors from '../types/errors'; -import { type AttachmentType } from '../types/Attachment'; -import * as MIME from '../types/MIME'; -import { ReadStatus } from '../messages/MessageReadStatus'; -import type { SendStateByConversationId } from '../messages/MessageSendState'; -import { - SendActionType, - SendStatus, - isSent, - sendStateReducer, - someRecipientSendStatus, -} from '../messages/MessageSendState'; -import { getOwn } from '../util/getOwn'; -import { markRead, markViewed } from '../services/MessageUpdater'; -import { - isDirectConversation, - isGroup, - isGroupV1, - isMe, -} from '../util/whatTypeOfConversation'; -import { handleMessageSend } from '../util/handleMessageSend'; -import { getSendOptions } from '../util/getSendOptions'; -import { - modifyTargetMessage, - ModifyTargetMessageResult, -} from '../util/modifyTargetMessage'; - -import { - getMessagePropStatus, - hasErrors, - isCallHistory, - isChatSessionRefreshed, - isDeliveryIssue, - isEndSession, - isExpirationTimerUpdate, - isGiftBadge, - isGroupUpdate, - isGroupV2Change, - isIncoming, - isKeyChange, - isOutgoing, - isStory, - isProfileChange, - isTapToView, - isUniversalTimerNotification, - isUnsupportedMessage, - isVerifiedChange, - isConversationMerge, - isPhoneNumberDiscovery, - isTitleTransitionNotification, -} from '../state/selectors/message'; -import type { ReactionAttributesType } from '../messageModifiers/Reactions'; -import { ReactionSource } from '../reactions/ReactionSource'; -import * as LinkPreview from '../types/LinkPreview'; -import { SignalService as Proto } from '../protobuf'; -import { - conversationJobQueue, - conversationQueueJobEnum, -} from '../jobs/conversationJobQueue'; -import { - NotificationType, - notificationService, -} from '../services/notifications'; -import type { LinkPreviewType } from '../types/message/LinkPreviews'; +import type { MessageAttributesType } from '../model-types.d'; +import type { CallbackResultType } from '../textsecure/Types.d'; import * as log from '../logging/log'; -import { deleteMessageData } from '../util/cleanup'; -import { - getSource, - getSourceServiceId, - isCustomError, - messageHasPaymentEvent, - isQuoteAMatch, - getAuthor, - shouldTryToCopyFromQuotedMessage, -} from '../messages/helpers'; -import { viewOnceOpenJobQueue } from '../jobs/viewOnceOpenJobQueue'; -import { getMessageIdForLogging } from '../util/idForLogging'; -import { hasAttachmentDownloads } from '../util/hasAttachmentDownloads'; -import { queueAttachmentDownloads } from '../util/queueAttachmentDownloads'; -import { findStoryMessages } from '../util/findStoryMessage'; -import type { ConversationQueueJobData } from '../jobs/conversationJobQueue'; -import { shouldDownloadStory } from '../util/shouldDownloadStory'; -import { isNewReactionReplacingPrevious } from '../reactions/util'; -import { parseBoostBadgeListFromServer } from '../badges/parseBadgesFromServer'; -import { - addToAttachmentDownloadQueue, - shouldUseAttachmentDownloadQueue, -} from '../util/attachmentDownloadQueue'; -import { DataReader, DataWriter } from '../sql/Client'; -import { shouldReplyNotifyUser } from '../util/shouldReplyNotifyUser'; -import type { RawBodyRange } from '../types/BodyRange'; -import { BodyRange } from '../types/BodyRange'; -import { - queueUpdateMessage, - saveNewMessageBatcher, -} from '../util/messageBatcher'; -import { getSenderIdentifier } from '../util/getSenderIdentifier'; -import { getNotificationDataForMessage } from '../util/getNotificationDataForMessage'; -import { getNotificationTextForMessage } from '../util/getNotificationTextForMessage'; -import { getMessageAuthorText } from '../util/getMessageAuthorText'; -import { - getPropForTimestamp, - getChangesForPropAtTimestamp, -} from '../util/editHelpers'; -import { getMessageSentTimestamp } from '../util/getMessageSentTimestamp'; -import type { AttachmentDownloadUrgency } from '../jobs/AttachmentDownloadManager'; -import { - copyFromQuotedMessage, - copyQuoteContentFromOriginal, -} from '../messages/copyQuote'; -import { getRoomIdFromCallLink } from '../util/callLinksRingrtc'; -import { explodePromise } from '../util/explodePromise'; -import { GiftBadgeStates } from '../components/conversation/Message'; +type StringKey = keyof T & string; -/* eslint-disable more/no-then */ +export class MessageModel { + public get id(): string { + return this._attributes.id; + } -window.Whisper = window.Whisper || {}; + public get>( + key: keyName + ): MessageAttributesType[keyName] { + return this.attributes[key]; + } + public set( + attributes: Partial, + { noTrigger }: { noTrigger?: boolean } = {} + ): void { + this._attributes = { + ...this.attributes, + ...attributes, + }; -const { Message: TypedMessage } = window.Signal.Types; -const { upgradeMessageSchema } = window.Signal.Migrations; -const { getMessageBySender } = DataReader; + if (noTrigger) { + return; + } -export class MessageModel extends window.Backbone.Model { - CURRENT_PROTOCOL_VERSION?: number; + window.MessageCache._updateCaches(this); + } + + public get attributes(): Readonly { + return this._attributes; + } + private _attributes: MessageAttributesType; + + constructor(attributes: MessageAttributesType) { + this._attributes = attributes; + + this.set( + window.Signal.Types.Message.initializeSchemaVersion({ + message: attributes, + logger: log, + }), + { noTrigger: true } + ); + } + + // --- Other housekeeping: // Set when sending some sync messages, so we get the functionality of // send(), without zombie messages going into the database. @@ -175,2405 +59,9 @@ export class MessageModel extends window.Backbone.Model { // able to send the sync message elsewhere. doNotSendSyncMessage?: boolean; - INITIAL_PROTOCOL_VERSION?: number; - deletingForEveryone?: boolean; - isSelected?: boolean; - - private pendingMarkRead?: number; + pendingMarkRead?: number; syncPromise?: Promise; - - public registerLocations: Set; - - constructor(attributes: MessageAttributesType) { - super(attributes); - - if (!this.id && attributes.id) { - this.id = attributes.id; - } - - this.registerLocations = new Set(); - - // Note that we intentionally don't use `initialize()` method because it - // isn't compatible with esnext output of esbuild. - if (isObject(attributes)) { - this.set( - TypedMessage.initializeSchemaVersion({ - message: attributes as MessageAttributesType, - logger: log, - }) - ); - } - - this.CURRENT_PROTOCOL_VERSION = Proto.DataMessage.ProtocolVersion.CURRENT; - this.INITIAL_PROTOCOL_VERSION = Proto.DataMessage.ProtocolVersion.INITIAL; - - this.on('change', this.updateMessageCache); - } - - updateMessageCache(): void { - window.MessageCache.setAttributes({ - messageId: this.id, - messageAttributes: this.attributes, - skipSaveToDatabase: true, - }); - } - - getSenderIdentifier(): string { - return getSenderIdentifier(this.attributes); - } - - getReceivedAt(): number { - // We would like to get the received_at_ms ideally since received_at is - // now an incrementing counter for messages and not the actual time that - // the message was received. If this field doesn't exist on the message - // then we can trust received_at. - return Number(this.get('received_at_ms') || this.get('received_at')); - } - - async hydrateStoryContext( - inMemoryMessage?: MessageAttributesType, - options: { - shouldSave?: boolean; - isStoryErased?: boolean; - } = {} - ): Promise { - await hydrateStoryContext(this.id, inMemoryMessage, options); - } - - // Dependencies of prop-generation functions - getConversation(): ConversationModel | undefined { - return window.ConversationController.get(this.get('conversationId')); - } - - getNotificationData(): { - emoji?: string; - text: string; - bodyRanges?: ReadonlyArray; - } { - return getNotificationDataForMessage(this.attributes); - } - - getNotificationText(): string { - return getNotificationTextForMessage(this.attributes); - } - - // General - idForLogging(): string { - return getMessageIdForLogging(this.attributes); - } - - override defaults(): Partial { - return { - timestamp: new Date().getTime(), - attachments: [], - }; - } - - override validate(attributes: Record): void { - const required = ['conversationId', 'received_at', 'sent_at']; - const missing = required.filter(attr => !attributes[attr]); - if (missing.length) { - log.warn(`Message missing attributes: ${missing}`); - } - } - - merge(model: MessageModel): void { - const attributes = model.attributes || model; - this.set(attributes); - } - - async deleteData(): Promise { - await deleteMessageData(this.attributes); - } - - isValidTapToView(): boolean { - const body = this.get('body'); - if (body) { - return false; - } - - const attachments = this.get('attachments'); - if (!attachments || attachments.length !== 1) { - return false; - } - - const firstAttachment = attachments[0]; - if ( - !GoogleChrome.isImageTypeSupported(firstAttachment.contentType) && - !GoogleChrome.isVideoTypeSupported(firstAttachment.contentType) - ) { - return false; - } - - const quote = this.get('quote'); - const sticker = this.get('sticker'); - const contact = this.get('contact'); - const preview = this.get('preview'); - - if ( - quote || - sticker || - (contact && contact.length > 0) || - (preview && preview.length > 0) - ) { - return false; - } - - return true; - } - - async markViewOnceMessageViewed(options?: { - fromSync?: boolean; - }): Promise { - const { fromSync } = options || {}; - - if (!this.isValidTapToView()) { - log.warn( - `markViewOnceMessageViewed: Message ${this.idForLogging()} is not a valid tap to view message!` - ); - return; - } - if (this.isErased()) { - log.warn( - `markViewOnceMessageViewed: Message ${this.idForLogging()} is already erased!` - ); - return; - } - - if (this.get('readStatus') !== ReadStatus.Viewed) { - this.set(markViewed(this.attributes)); - } - - await this.eraseContents(); - - if (!fromSync) { - const senderE164 = getSource(this.attributes); - const senderAci = getSourceServiceId(this.attributes); - const timestamp = this.get('sent_at'); - - if (senderAci === undefined || !isAciString(senderAci)) { - throw new Error('markViewOnceMessageViewed: senderAci is undefined'); - } - - if (window.ConversationController.areWePrimaryDevice()) { - log.warn( - 'markViewOnceMessageViewed: We are primary device; not sending view once open sync' - ); - return; - } - - try { - await viewOnceOpenJobQueue.add({ - viewOnceOpens: [ - { - senderE164, - senderAci, - timestamp, - }, - ], - }); - } catch (error) { - log.error( - 'markViewOnceMessageViewed: Failed to queue view once open sync', - Errors.toLogFormat(error) - ); - } - } - } - - async doubleCheckMissingQuoteReference(): Promise { - const logId = this.idForLogging(); - - const storyId = this.get('storyId'); - if (storyId) { - log.warn( - `doubleCheckMissingQuoteReference/${logId}: missing story reference` - ); - - const message = window.MessageCache.__DEPRECATED$getById( - storyId, - 'doubleCheckMissingQuoteReference' - ); - if (!message) { - return; - } - - if (this.get('storyReplyContext')) { - this.set('storyReplyContext', undefined); - } - await this.hydrateStoryContext(message.attributes, { shouldSave: true }); - return; - } - - const quote = this.get('quote'); - if (!quote) { - log.warn(`doubleCheckMissingQuoteReference/${logId}: Missing quote!`); - return; - } - - const { authorAci, author, id: sentAt, referencedMessageNotFound } = quote; - const contact = window.ConversationController.get(authorAci || author); - - // Is the quote really without a reference? Check with our in memory store - // first to make sure it's not there. - if ( - contact && - shouldTryToCopyFromQuotedMessage({ - referencedMessageNotFound, - quoteAttachment: quote.attachments.at(0), - }) - ) { - const matchingMessage = await window.MessageCache.findBySentAt( - Number(sentAt), - attributes => - isQuoteAMatch(attributes, this.get('conversationId'), quote) - ); - - if (!matchingMessage) { - log.info( - `doubleCheckMissingQuoteReference/${logId}: No match for ${sentAt}.` - ); - return; - } - - this.set({ - quote: { - ...quote, - referencedMessageNotFound: false, - }, - }); - - log.info( - `doubleCheckMissingQuoteReference/${logId}: Found match for ${sentAt}, updating.` - ); - - await copyQuoteContentFromOriginal(matchingMessage, quote); - this.set({ - quote: { - ...quote, - referencedMessageNotFound: false, - }, - }); - queueUpdateMessage(this.attributes); - } - } - - isErased(): boolean { - return Boolean(this.get('isErased')); - } - - async eraseContents( - additionalProperties = {}, - shouldPersist = true - ): Promise { - log.info(`Erasing data for message ${this.idForLogging()}`); - - // Note: There are cases where we want to re-erase a given message. For example, when - // a viewed (or outgoing) View-Once message is deleted for everyone. - - try { - await this.deleteData(); - } catch (error) { - log.error( - `Error erasing data for message ${this.idForLogging()}:`, - Errors.toLogFormat(error) - ); - } - - this.set({ - attachments: [], - body: '', - bodyRanges: undefined, - contact: [], - editHistory: undefined, - isErased: true, - preview: [], - quote: undefined, - sticker: undefined, - ...additionalProperties, - }); - this.getConversation()?.debouncedUpdateLastMessage(); - - if (shouldPersist) { - await DataWriter.saveMessage(this.attributes, { - ourAci: window.textsecure.storage.user.getCheckedAci(), - }); - } - - await DataWriter.deleteSentProtoByMessageId(this.id); - } - - override isEmpty(): boolean { - const { attributes } = this; - - // Core message types - we check for all four because they can each stand alone - const hasBody = Boolean(this.get('body')); - const hasAttachment = (this.get('attachments') || []).length > 0; - const hasEmbeddedContact = (this.get('contact') || []).length > 0; - const isSticker = Boolean(this.get('sticker')); - - // Rendered sync messages - const isCallHistoryValue = isCallHistory(attributes); - const isChatSessionRefreshedValue = isChatSessionRefreshed(attributes); - const isDeliveryIssueValue = isDeliveryIssue(attributes); - const isGiftBadgeValue = isGiftBadge(attributes); - const isGroupUpdateValue = isGroupUpdate(attributes); - const isGroupV2ChangeValue = isGroupV2Change(attributes); - const isEndSessionValue = isEndSession(attributes); - const isExpirationTimerUpdateValue = isExpirationTimerUpdate(attributes); - const isVerifiedChangeValue = isVerifiedChange(attributes); - - // Placeholder messages - const isUnsupportedMessageValue = isUnsupportedMessage(attributes); - const isTapToViewValue = isTapToView(attributes); - - // Errors - const hasErrorsValue = hasErrors(attributes); - - // Locally-generated notifications - const isKeyChangeValue = isKeyChange(attributes); - const isProfileChangeValue = isProfileChange(attributes); - const isUniversalTimerNotificationValue = - isUniversalTimerNotification(attributes); - const isConversationMergeValue = isConversationMerge(attributes); - const isPhoneNumberDiscoveryValue = isPhoneNumberDiscovery(attributes); - const isTitleTransitionNotificationValue = - isTitleTransitionNotification(attributes); - - const isPayment = messageHasPaymentEvent(attributes); - - // Note: not all of these message types go through message.handleDataMessage - - const hasSomethingToDisplay = - // Core message types - hasBody || - hasAttachment || - hasEmbeddedContact || - isSticker || - isPayment || - // Rendered sync messages - isCallHistoryValue || - isChatSessionRefreshedValue || - isDeliveryIssueValue || - isGiftBadgeValue || - isGroupUpdateValue || - isGroupV2ChangeValue || - isEndSessionValue || - isExpirationTimerUpdateValue || - isVerifiedChangeValue || - // Placeholder messages - isUnsupportedMessageValue || - isTapToViewValue || - // Errors - hasErrorsValue || - // Locally-generated notifications - isKeyChangeValue || - isProfileChangeValue || - isUniversalTimerNotificationValue || - isConversationMergeValue || - isPhoneNumberDiscoveryValue || - isTitleTransitionNotificationValue; - - return !hasSomethingToDisplay; - } - - isUnidentifiedDelivery( - contactId: string, - unidentifiedDeliveriesSet: Readonly> - ): boolean { - if (isIncoming(this.attributes)) { - return Boolean(this.get('unidentifiedDeliveryReceived')); - } - - return unidentifiedDeliveriesSet.has(contactId); - } - - async saveErrors( - providedErrors: Error | Array, - options: { skipSave?: boolean } = {} - ): Promise { - const { skipSave } = options; - - let errors: Array; - - if (!(providedErrors instanceof Array)) { - errors = [providedErrors]; - } else { - errors = providedErrors; - } - - errors.forEach(e => { - log.error('Message.saveErrors:', Errors.toLogFormat(e)); - }); - errors = errors.map(e => { - // Note: in our environment, instanceof can be scary, so we have a backup check - // (Node.js vs Browser context). - // We check instanceof second because typescript believes that anything that comes - // through here must be an instance of Error, so e is 'never' after that check. - if ((e.message && e.stack) || e instanceof Error) { - return pick( - e, - 'name', - 'message', - 'code', - 'number', - 'identifier', - 'retryAfter', - 'data', - 'reason' - ) as Required; - } - return e; - }); - errors = errors.concat(this.get('errors') || []); - - this.set({ errors }); - - if (!skipSave && !this.doNotSave) { - await DataWriter.saveMessage(this.attributes, { - ourAci: window.textsecure.storage.user.getCheckedAci(), - }); - } - } - - markRead(readAt?: number, options = {}): void { - this.set(markRead(this.attributes, readAt, options)); - } - - async retrySend(): Promise { - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - const conversation = this.getConversation()!; - - let currentConversationRecipients: Set | undefined; - - const { storyDistributionListId } = this.attributes; - - if (storyDistributionListId) { - const storyDistribution = - await DataReader.getStoryDistributionWithMembers( - storyDistributionListId - ); - - if (!storyDistribution) { - this.markFailed(); - return; - } - - currentConversationRecipients = new Set( - storyDistribution.members - .map(serviceId => window.ConversationController.get(serviceId)?.id) - .filter(isNotNil) - ); - } else { - currentConversationRecipients = conversation.getMemberConversationIds(); - } - - // Determine retry recipients and get their most up-to-date addressing information - const oldSendStateByConversationId = - this.get('sendStateByConversationId') || {}; - - const newSendStateByConversationId = { ...oldSendStateByConversationId }; - for (const [conversationId, sendState] of Object.entries( - oldSendStateByConversationId - )) { - if (isSent(sendState.status)) { - continue; - } - - const recipient = window.ConversationController.get(conversationId); - if ( - !recipient || - (!currentConversationRecipients.has(conversationId) && - !isMe(recipient.attributes)) - ) { - continue; - } - - newSendStateByConversationId[conversationId] = sendStateReducer( - sendState, - { - type: SendActionType.ManuallyRetried, - updatedAt: Date.now(), - } - ); - } - - this.set('sendStateByConversationId', newSendStateByConversationId); - - if (isStory(this.attributes)) { - await conversationJobQueue.add( - { - type: conversationQueueJobEnum.enum.Story, - conversationId: conversation.id, - messageIds: [this.id], - // using the group timestamp, which will differ from the 1:1 timestamp - timestamp: this.attributes.timestamp, - }, - async jobToInsert => { - await DataWriter.saveMessage(this.attributes, { - jobToInsert, - ourAci: window.textsecure.storage.user.getCheckedAci(), - }); - } - ); - } else { - await conversationJobQueue.add( - { - type: conversationQueueJobEnum.enum.NormalMessage, - conversationId: conversation.id, - messageId: this.id, - revision: conversation.get('revision'), - }, - async jobToInsert => { - await DataWriter.saveMessage(this.attributes, { - jobToInsert, - ourAci: window.textsecure.storage.user.getCheckedAci(), - }); - } - ); - } - } - - isReplayableError(e: Error): boolean { - return ( - e.name === 'MessageError' || - e.name === 'OutgoingMessageError' || - e.name === 'SendMessageNetworkError' || - e.name === 'SendMessageChallengeError' || - e.name === 'OutgoingIdentityKeyError' - ); - } - - public hasSuccessfulDelivery(): boolean { - const sendStateByConversationId = this.get('sendStateByConversationId'); - const ourConversationId = - window.ConversationController.getOurConversationIdOrThrow(); - - return someRecipientSendStatus( - sendStateByConversationId ?? {}, - ourConversationId, - isSent - ); - } - - /** - * Change any Pending send state to Failed. Note that this will not mark successful - * sends failed. - */ - public markFailed(editMessageTimestamp?: number): void { - const now = Date.now(); - - const targetTimestamp = editMessageTimestamp || this.get('timestamp'); - const sendStateByConversationId = getPropForTimestamp({ - log, - message: this.attributes, - prop: 'sendStateByConversationId', - targetTimestamp, - }); - - const newSendStateByConversationId = mapValues( - sendStateByConversationId || {}, - sendState => - sendStateReducer(sendState, { - type: SendActionType.Failed, - updatedAt: now, - }) - ); - - const attributesToUpdate = getChangesForPropAtTimestamp({ - log, - message: this.attributes, - prop: 'sendStateByConversationId', - targetTimestamp, - value: newSendStateByConversationId, - }); - if (attributesToUpdate) { - this.set(attributesToUpdate); - } - - this.notifyStorySendFailed(); - } - - public notifyStorySendFailed(): void { - if (!isStory(this.attributes)) { - return; - } - - notificationService.add({ - conversationId: this.get('conversationId'), - storyId: this.id, - messageId: this.id, - senderTitle: - this.getConversation()?.getTitle() ?? window.i18n('icu:Stories__mine'), - message: this.hasSuccessfulDelivery() - ? window.i18n('icu:Stories__failed-send--partial') - : window.i18n('icu:Stories__failed-send--full'), - isExpiringMessage: false, - sentAt: this.get('timestamp'), - type: NotificationType.Message, - }); - } - - removeOutgoingErrors(incomingIdentifier: string): CustomError { - const incomingConversationId = - window.ConversationController.getConversationId(incomingIdentifier); - const errors = partition( - this.get('errors'), - e => - window.ConversationController.getConversationId( - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - e.serviceId || e.number! - ) === incomingConversationId && - (e.name === 'MessageError' || - e.name === 'OutgoingMessageError' || - e.name === 'SendMessageNetworkError' || - e.name === 'SendMessageChallengeError' || - e.name === 'OutgoingIdentityKeyError') - ); - this.set({ errors: errors[1] }); - return errors[0][0]; - } - - async send({ - promise, - saveErrors, - targetTimestamp, - }: { - promise: Promise; - saveErrors?: (errors: Array) => void; - targetTimestamp: number; - }): Promise { - const updateLeftPane = - this.getConversation()?.debouncedUpdateLastMessage ?? noop; - - updateLeftPane(); - - let result: - | { success: true; value: CallbackResultType } - | { - success: false; - value: CustomError | SendMessageProtoError; - }; - try { - const value = await (promise as Promise); - result = { success: true, value }; - } catch (err) { - result = { success: false, value: err }; - } - - updateLeftPane(); - - const attributesToUpdate: Partial = {}; - - // This is used by sendSyncMessage, then set to null - if ('dataMessage' in result.value && result.value.dataMessage) { - attributesToUpdate.dataMessage = result.value.dataMessage; - } else if ('editMessage' in result.value && result.value.editMessage) { - attributesToUpdate.dataMessage = result.value.editMessage; - } - - if (!this.doNotSave) { - await DataWriter.saveMessage(this.attributes, { - ourAci: window.textsecure.storage.user.getCheckedAci(), - }); - } - - const sendStateByConversationId = { - ...(getPropForTimestamp({ - log, - message: this.attributes, - prop: 'sendStateByConversationId', - targetTimestamp, - }) || {}), - }; - - const sendIsNotFinal = - 'sendIsNotFinal' in result.value && result.value.sendIsNotFinal; - const sendIsFinal = !sendIsNotFinal; - - // Capture successful sends - const successfulServiceIds: Array = - sendIsFinal && - 'successfulServiceIds' in result.value && - Array.isArray(result.value.successfulServiceIds) - ? result.value.successfulServiceIds - : []; - const sentToAtLeastOneRecipient = - result.success || Boolean(successfulServiceIds.length); - - successfulServiceIds.forEach(serviceId => { - const conversation = window.ConversationController.get(serviceId); - if (!conversation) { - return; - } - - // If we successfully sent to a user, we can remove our unregistered flag. - if (conversation.isEverUnregistered()) { - conversation.setRegistered(); - } - - const previousSendState = getOwn( - sendStateByConversationId, - conversation.id - ); - if (previousSendState) { - sendStateByConversationId[conversation.id] = sendStateReducer( - previousSendState, - { - type: SendActionType.Sent, - updatedAt: Date.now(), - } - ); - } - }); - - // Integrate sends via sealed sender - const latestEditTimestamp = this.get('editMessageTimestamp'); - const sendIsLatest = - !latestEditTimestamp || targetTimestamp === latestEditTimestamp; - const previousUnidentifiedDeliveries = - this.get('unidentifiedDeliveries') || []; - const newUnidentifiedDeliveries = - sendIsLatest && - sendIsFinal && - 'unidentifiedDeliveries' in result.value && - Array.isArray(result.value.unidentifiedDeliveries) - ? result.value.unidentifiedDeliveries - : []; - - const promises: Array> = []; - - // Process errors - let errors: Array; - if (result.value instanceof SendMessageProtoError && result.value.errors) { - ({ errors } = result.value); - } else if (isCustomError(result.value)) { - errors = [result.value]; - } else if (Array.isArray(result.value.errors)) { - ({ errors } = result.value); - } else { - errors = []; - } - - // In groups, we don't treat unregistered users as a user-visible - // error. The message will look successful, but the details - // screen will show that we didn't send to these unregistered users. - const errorsToSave: Array = []; - - errors.forEach(error => { - const conversation = - window.ConversationController.get(error.serviceId) || - window.ConversationController.get(error.number); - - if (conversation && !saveErrors && sendIsFinal) { - const previousSendState = getOwn( - sendStateByConversationId, - conversation.id - ); - if (previousSendState) { - sendStateByConversationId[conversation.id] = sendStateReducer( - previousSendState, - { - type: SendActionType.Failed, - updatedAt: Date.now(), - } - ); - this.notifyStorySendFailed(); - } - } - - let shouldSaveError = true; - switch (error.name) { - case 'OutgoingIdentityKeyError': { - if (conversation) { - promises.push( - conversation.getProfiles().catch(() => { - /* nothing to do here; logging already happened */ - }) - ); - } - break; - } - case 'UnregisteredUserError': - if (conversation && isGroup(conversation.attributes)) { - shouldSaveError = false; - } - // If we just found out that we couldn't send to a user because they are no - // longer registered, we will update our unregistered flag. In groups we - // will not event try to send to them for 6 hours. And we will never try - // to fetch them on startup again. - // - // The way to discover registration once more is: - // 1) any attempt to send to them in 1:1 conversation - // 2) the six-hour time period has passed and we send in a group again - conversation?.setUnregistered(); - break; - default: - break; - } - - if (shouldSaveError) { - errorsToSave.push(error); - } - }); - - // Only update the expirationStartTimestamp if we don't already have one set - if (!this.get('expirationStartTimestamp')) { - attributesToUpdate.expirationStartTimestamp = sentToAtLeastOneRecipient - ? Date.now() - : undefined; - } - attributesToUpdate.unidentifiedDeliveries = union( - previousUnidentifiedDeliveries, - newUnidentifiedDeliveries - ); - // We may overwrite this in the `saveErrors` call below. - attributesToUpdate.errors = []; - - const additionalProps = getChangesForPropAtTimestamp({ - log, - message: this.attributes, - prop: 'sendStateByConversationId', - targetTimestamp, - value: sendStateByConversationId, - }); - - this.set({ ...attributesToUpdate, ...additionalProps }); - if (saveErrors) { - saveErrors(errorsToSave); - } else { - // We skip save because we'll save in the next step. - void this.saveErrors(errorsToSave, { skipSave: true }); - } - - if (!this.doNotSave) { - await DataWriter.saveMessage(this.attributes, { - ourAci: window.textsecure.storage.user.getCheckedAci(), - }); - } - - updateLeftPane(); - - if (sentToAtLeastOneRecipient && !this.doNotSendSyncMessage) { - promises.push(this.sendSyncMessage(targetTimestamp)); - } - - await Promise.all(promises); - - updateLeftPane(); - } - - async sendSyncMessageOnly({ - targetTimestamp, - dataMessage, - saveErrors, - }: { - targetTimestamp: number; - dataMessage: Uint8Array; - saveErrors?: (errors: Array) => void; - }): Promise { - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - const conv = this.getConversation()!; - this.set({ dataMessage }); - - const updateLeftPane = conv?.debouncedUpdateLastMessage; - - try { - this.set({ - // This is the same as a normal send() - expirationStartTimestamp: Date.now(), - errors: [], - }); - const result = await this.sendSyncMessage(targetTimestamp); - this.set({ - // We have to do this afterward, since we didn't have a previous send! - unidentifiedDeliveries: - result && result.unidentifiedDeliveries - ? result.unidentifiedDeliveries - : undefined, - }); - return result; - } catch (error) { - const resultErrors = error?.errors; - const errors = Array.isArray(resultErrors) - ? resultErrors - : [new Error('Unknown error')]; - if (saveErrors) { - saveErrors(errors); - } else { - // We don't save because we're about to save below. - void this.saveErrors(errors, { skipSave: true }); - } - throw error; - } finally { - await DataWriter.saveMessage(this.attributes, { - ourAci: window.textsecure.storage.user.getCheckedAci(), - }); - - if (updateLeftPane) { - updateLeftPane(); - } - } - } - - async sendSyncMessage( - targetTimestamp: number - ): Promise { - const ourConversation = - window.ConversationController.getOurConversationOrThrow(); - const sendOptions = await getSendOptions(ourConversation.attributes, { - syncMessage: true, - }); - - if (window.ConversationController.areWePrimaryDevice()) { - log.warn( - 'sendSyncMessage: We are primary device; not sending sync message' - ); - this.set({ dataMessage: undefined }); - return; - } - - const { messaging } = window.textsecure; - if (!messaging) { - throw new Error('sendSyncMessage: messaging not available!'); - } - - this.syncPromise = this.syncPromise || Promise.resolve(); - const next = async () => { - const dataMessage = this.get('dataMessage'); - if (!dataMessage) { - return; - } - - const originalTimestamp = getMessageSentTimestamp(this.attributes, { - includeEdits: false, - log, - }); - const isSendingEdit = targetTimestamp !== originalTimestamp; - - const isUpdate = Boolean(this.get('synced')) && !isSendingEdit; - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - const conv = this.getConversation()!; - - const sendEntries = Object.entries( - getPropForTimestamp({ - log, - message: this.attributes, - prop: 'sendStateByConversationId', - targetTimestamp, - }) || {} - ); - const sentEntries = filter(sendEntries, ([_conversationId, { status }]) => - isSent(status) - ); - const allConversationIdsSentTo = map( - sentEntries, - ([conversationId]) => conversationId - ); - const conversationIdsSentTo = filter( - allConversationIdsSentTo, - conversationId => conversationId !== ourConversation.id - ); - - const unidentifiedDeliveries = this.get('unidentifiedDeliveries') || []; - const maybeConversationsWithSealedSender = map( - unidentifiedDeliveries, - identifier => window.ConversationController.get(identifier) - ); - const conversationsWithSealedSender = filter( - maybeConversationsWithSealedSender, - isNotNil - ); - const conversationIdsWithSealedSender = new Set( - map(conversationsWithSealedSender, c => c.id) - ); - - const encodedContent = isSendingEdit - ? { - encodedEditMessage: dataMessage, - } - : { - encodedDataMessage: dataMessage, - }; - - return handleMessageSend( - messaging.sendSyncMessage({ - ...encodedContent, - timestamp: targetTimestamp, - destination: conv.get('e164'), - destinationServiceId: conv.getServiceId(), - expirationStartTimestamp: - this.get('expirationStartTimestamp') || null, - conversationIdsSentTo, - conversationIdsWithSealedSender, - isUpdate, - options: sendOptions, - urgent: false, - }), - // Note: in some situations, for doNotSave messages, the message has no - // id, so we provide an empty array here. - { messageIds: this.id ? [this.id] : [], sendType: 'sentSync' } - ).then(async result => { - let newSendStateByConversationId: undefined | SendStateByConversationId; - const sendStateByConversationId = - getPropForTimestamp({ - log, - message: this.attributes, - prop: 'sendStateByConversationId', - targetTimestamp, - }) || {}; - const ourOldSendState = getOwn( - sendStateByConversationId, - ourConversation.id - ); - if (ourOldSendState) { - const ourNewSendState = sendStateReducer(ourOldSendState, { - type: SendActionType.Sent, - updatedAt: Date.now(), - }); - if (ourNewSendState !== ourOldSendState) { - newSendStateByConversationId = { - ...sendStateByConversationId, - [ourConversation.id]: ourNewSendState, - }; - } - } - - const attributesForUpdate = newSendStateByConversationId - ? getChangesForPropAtTimestamp({ - log, - message: this.attributes, - prop: 'sendStateByConversationId', - value: newSendStateByConversationId, - targetTimestamp, - }) - : null; - - this.set({ - synced: true, - dataMessage: null, - ...attributesForUpdate, - }); - - // Return early, skip the save - if (this.doNotSave) { - return result; - } - - await DataWriter.saveMessage(this.attributes, { - ourAci: window.textsecure.storage.user.getCheckedAci(), - }); - return result; - }); - }; - - this.syncPromise = this.syncPromise.then(next, next); - - return this.syncPromise; - } - - hasRequiredAttachmentDownloads(): boolean { - const attachments: ReadonlyArray = - this.get('attachments') || []; - - const hasLongMessageAttachments = attachments.some(attachment => { - return MIME.isLongMessage(attachment.contentType); - }); - - if (hasLongMessageAttachments) { - return true; - } - - const sticker = this.get('sticker'); - if (sticker) { - return !sticker.data || !sticker.data.path; - } - - return false; - } - - hasAttachmentDownloads(): boolean { - return hasAttachmentDownloads(this.attributes); - } - - async queueAttachmentDownloads( - urgency?: AttachmentDownloadUrgency - ): Promise { - const value = await queueAttachmentDownloads(this.attributes, { urgency }); - if (!value) { - return false; - } - - this.set(value); - queueUpdateMessage(this.attributes); - - return true; - } - - markAttachmentAsCorrupted(attachment: AttachmentType): void { - if (!attachment.path) { - throw new Error( - "Attachment can't be marked as corrupted because it wasn't loaded" - ); - } - - // We intentionally don't check in quotes/stickers/contacts/... here, - // because this function should be called only for something that can - // be displayed as a generic attachment. - const attachments: ReadonlyArray = - this.get('attachments') || []; - - let changed = false; - const newAttachments = attachments.map(existing => { - if (existing.path !== attachment.path) { - return existing; - } - changed = true; - - return { - ...existing, - isCorrupted: true, - }; - }); - - if (!changed) { - throw new Error( - "Attachment can't be marked as corrupted because it wasn't found" - ); - } - - log.info('markAttachmentAsCorrupted: marking an attachment as corrupted'); - - this.set({ - attachments: newAttachments, - }); - } - - async handleDataMessage( - initialMessage: ProcessedDataMessage, - confirm: () => void, - options: { data?: SentEventData } = {} - ): Promise { - const { data } = options; - - // This function is called from the background script in a few scenarios: - // 1. on an incoming message - // 2. on a sent message sync'd from another device - // 3. in rare cases, an incoming message can be retried, though it will - // still go through one of the previous two codepaths - // eslint-disable-next-line @typescript-eslint/no-this-alias - let message: MessageModel = this; - const source = message.get('source'); - const sourceServiceId = message.get('sourceServiceId'); - const type = message.get('type'); - const conversationId = message.get('conversationId'); - - const fromContact = getAuthor(this.attributes); - if (fromContact) { - fromContact.setRegistered(); - } - - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - const conversation = window.ConversationController.get(conversationId)!; - const idLog = `handleDataMessage/${conversation.idForLogging()} ${message.idForLogging()}`; - await conversation.queueJob(idLog, async () => { - log.info(`${idLog}: starting processing in queue`); - - // First, check for duplicates. If we find one, stop processing here. - const inMemoryMessage = window.MessageCache.findBySender( - this.getSenderIdentifier() - ); - if (inMemoryMessage) { - log.info(`${idLog}: cache hit`, this.getSenderIdentifier()); - } else { - log.info( - `${idLog}: duplicate check db lookup needed`, - this.getSenderIdentifier() - ); - } - const existingMessage = - inMemoryMessage || (await getMessageBySender(this.attributes)); - const isUpdate = Boolean(data && data.isRecipientUpdate); - - const isDuplicateMessage = - existingMessage && - (type === 'incoming' || - (type === 'story' && - existingMessage.storyDistributionListId === - this.attributes.storyDistributionListId)); - - if (isDuplicateMessage) { - log.warn(`${idLog}: Received duplicate message`, this.idForLogging()); - confirm(); - return; - } - if (type === 'outgoing') { - if (isUpdate && existingMessage) { - log.info( - `${idLog}: Updating message ${message.idForLogging()} with received transcript` - ); - - const toUpdate = window.MessageCache.__DEPRECATED$register( - existingMessage.id, - existingMessage, - 'handleDataMessage/outgoing/toUpdate' - ); - - const unidentifiedDeliveriesSet = new Set( - toUpdate.get('unidentifiedDeliveries') ?? [] - ); - const sendStateByConversationId = { - ...(toUpdate.get('sendStateByConversationId') || {}), - }; - - const unidentifiedStatus: Array = - data && Array.isArray(data.unidentifiedStatus) - ? data.unidentifiedStatus - : []; - - unidentifiedStatus.forEach( - ({ destinationServiceId, destination, unidentified }) => { - const identifier = destinationServiceId || destination; - if (!identifier) { - return; - } - - const destinationConversation = - window.ConversationController.lookupOrCreate({ - serviceId: destinationServiceId, - e164: destination || undefined, - reason: `handleDataMessage(${initialMessage.timestamp})`, - }); - if (!destinationConversation) { - return; - } - - const updatedAt: number = - data && isNormalNumber(data.timestamp) - ? data.timestamp - : Date.now(); - - const previousSendState = getOwn( - sendStateByConversationId, - destinationConversation.id - ); - sendStateByConversationId[destinationConversation.id] = - previousSendState - ? sendStateReducer(previousSendState, { - type: SendActionType.Sent, - updatedAt, - }) - : { - status: SendStatus.Sent, - updatedAt, - }; - - if (unidentified) { - unidentifiedDeliveriesSet.add(identifier); - } - } - ); - - toUpdate.set({ - sendStateByConversationId, - unidentifiedDeliveries: [...unidentifiedDeliveriesSet], - }); - await DataWriter.saveMessage(toUpdate.attributes, { - ourAci: window.textsecure.storage.user.getCheckedAci(), - }); - - confirm(); - return; - } - if (isUpdate) { - log.warn( - `${idLog}: Received update transcript, but no existing entry for message ${message.idForLogging()}. Dropping.` - ); - - confirm(); - return; - } - if (existingMessage) { - // TODO: (DESKTOP-7301): improve this check in case previous message is not yet - // registered in memory - log.warn( - `${idLog}: Received duplicate transcript for message ${message.idForLogging()}, but it was not an update transcript. Dropping.` - ); - - confirm(); - return; - } - } - - // GroupV2 - - if (initialMessage.groupV2) { - if (isGroupV1(conversation.attributes)) { - // If we received a GroupV2 message in a GroupV1 group, we migrate! - - const { revision, groupChange } = initialMessage.groupV2; - await window.Signal.Groups.respondToGroupV2Migration({ - conversation, - groupChange: groupChange - ? { - base64: groupChange, - isTrusted: false, - } - : undefined, - newRevision: revision, - receivedAt: message.get('received_at'), - sentAt: message.get('sent_at'), - }); - } else if ( - initialMessage.groupV2.masterKey && - initialMessage.groupV2.secretParams && - initialMessage.groupV2.publicParams - ) { - // Repair core GroupV2 data if needed - await conversation.maybeRepairGroupV2({ - masterKey: initialMessage.groupV2.masterKey, - secretParams: initialMessage.groupV2.secretParams, - publicParams: initialMessage.groupV2.publicParams, - }); - - const existingRevision = conversation.get('revision'); - const isFirstUpdate = !isNumber(existingRevision); - - // Standard GroupV2 modification codepath - const isV2GroupUpdate = - initialMessage.groupV2 && - isNumber(initialMessage.groupV2.revision) && - (isFirstUpdate || - initialMessage.groupV2.revision > existingRevision); - - if (isV2GroupUpdate && initialMessage.groupV2) { - const { revision, groupChange } = initialMessage.groupV2; - try { - await window.Signal.Groups.maybeUpdateGroup({ - conversation, - groupChange: groupChange - ? { - base64: groupChange, - isTrusted: false, - } - : undefined, - newRevision: revision, - receivedAt: message.get('received_at'), - sentAt: message.get('sent_at'), - }); - } catch (error) { - const errorText = Errors.toLogFormat(error); - log.error( - `${idLog}: Failed to process group update as part of message ${message.idForLogging()}: ${errorText}` - ); - throw error; - } - } - } - } - - const ourAci = window.textsecure.storage.user.getCheckedAci(); - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - const sender = window.ConversationController.lookupOrCreate({ - e164: source, - serviceId: sourceServiceId, - reason: 'handleDataMessage', - })!; - const hasGroupV2Prop = Boolean(initialMessage.groupV2); - - // Drop if from blocked user. Only GroupV2 messages should need to be dropped here. - const isBlocked = - (source && window.storage.blocked.isBlocked(source)) || - (sourceServiceId && - window.storage.blocked.isServiceIdBlocked(sourceServiceId)); - if (isBlocked) { - log.info( - `${idLog}: Dropping message from blocked sender. hasGroupV2Prop: ${hasGroupV2Prop}` - ); - - confirm(); - return; - } - - const areWeMember = - !conversation.get('left') && conversation.hasMember(ourAci); - - // Drop an incoming GroupV2 message if we or the sender are not part of the group - // after applying the message's associated group changes. - if ( - type === 'incoming' && - !isDirectConversation(conversation.attributes) && - hasGroupV2Prop && - (!areWeMember || - (sourceServiceId && !conversation.hasMember(sourceServiceId))) - ) { - log.warn( - `${idLog}: Received message destined for group, which we or the sender are not a part of. Dropping.` - ); - confirm(); - return; - } - - // We drop incoming messages for v1 groups we already know about, which we're not - // a part of, except for group updates. Because group v1 updates haven't been - // applied by this point. - // Note: if we have no information about a group at all, we will accept those - // messages. We detect that via a missing 'members' field. - if ( - type === 'incoming' && - !isDirectConversation(conversation.attributes) && - !hasGroupV2Prop && - conversation.get('members') && - !areWeMember - ) { - log.warn( - `Received message destined for group ${conversation.idForLogging()}, which we're not a part of. Dropping.` - ); - confirm(); - return; - } - - // Drop incoming messages to announcement only groups where sender is not admin - if (conversation.get('announcementsOnly')) { - const senderServiceId = sender.getServiceId(); - if (!senderServiceId || !conversation.isAdmin(senderServiceId)) { - confirm(); - return; - } - } - - const messageId = - message.get('id') || generateMessageId(this.get('received_at')).id; - - // Send delivery receipts, but only for non-story sealed sender messages - // and not for messages from unaccepted conversations - if ( - type === 'incoming' && - this.get('unidentifiedDeliveryReceived') && - !hasErrors(this.attributes) && - conversation.getAccepted() - ) { - // Note: We both queue and batch because we want to wait until we are done - // processing incoming messages to start sending outgoing delivery receipts. - // The queue can be paused easily. - drop( - window.Whisper.deliveryReceiptQueue.add(() => { - strictAssert( - isAciString(sourceServiceId), - 'Incoming message must be from ACI' - ); - window.Whisper.deliveryReceiptBatcher.add({ - messageId, - conversationId, - senderE164: source, - senderAci: sourceServiceId, - timestamp: this.get('sent_at'), - isDirectConversation: isDirectConversation( - conversation.attributes - ), - }); - }) - ); - } - - const { storyContext } = initialMessage; - let storyContextLogId = 'no storyContext'; - if (storyContext) { - storyContextLogId = - `storyContext(${storyContext.sentTimestamp}, ` + - `${storyContext.authorAci})`; - } - - // Ensure that quote author's conversation exist - if (initialMessage.quote) { - window.ConversationController.lookupOrCreate({ - serviceId: initialMessage.quote.authorAci, - reason: 'handleDataMessage.quote.author', - }); - } - - const [quote, storyQuotes] = await Promise.all([ - initialMessage.quote - ? copyFromQuotedMessage(initialMessage.quote, conversation.id) - : undefined, - findStoryMessages(conversation.id, storyContext), - ]); - - const storyQuote = storyQuotes.find(candidateQuote => { - const sendStateByConversationId = - candidateQuote.sendStateByConversationId || {}; - const sendState = sendStateByConversationId[sender.id]; - - const storyQuoteIsFromSelf = - candidateQuote.sourceServiceId === - window.storage.user.getCheckedAci(); - - if (!storyQuoteIsFromSelf) { - return true; - } - - // The sender is not a recipient for this story - if (sendState === undefined) { - return false; - } - - // Group replies are always allowed - if (!isDirectConversation(conversation.attributes)) { - return true; - } - - // For 1:1 stories, we need to check if they can be replied to - return sendState.isAllowedToReplyToStory !== false; - }); - - if ( - storyContext && - !storyQuote && - !isDirectConversation(conversation.attributes) - ) { - log.warn( - `${idLog}: Received ${storyContextLogId} message in group but no matching story. Dropping.` - ); - - confirm(); - return; - } - - if (storyQuote) { - const { storyDistributionListId } = storyQuote; - - if (storyDistributionListId) { - const storyDistribution = - await DataReader.getStoryDistributionWithMembers( - storyDistributionListId - ); - - if (!storyDistribution) { - log.warn( - `${idLog}: Received ${storyContextLogId} message for story with no associated distribution list. Dropping.` - ); - - confirm(); - return; - } - - if (!storyDistribution.allowsReplies) { - log.warn( - `${idLog}: Received ${storyContextLogId} message but distribution list does not allow replies. Dropping.` - ); - - confirm(); - return; - } - } - } - - const withQuoteReference = { - ...message.attributes, - ...initialMessage, - quote, - storyId: storyQuote?.id, - }; - - // There are type conflicts between ModelAttributesType and protos passed in here - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const dataMessage = await upgradeMessageSchema(withQuoteReference as any); - - const isGroupStoryReply = - isGroup(conversation.attributes) && dataMessage.storyId; - - try { - const now = new Date().getTime(); - - const urls = LinkPreview.findLinks(dataMessage.body || ''); - const incomingPreview = dataMessage.preview || []; - const preview = incomingPreview - .map((item: LinkPreviewType) => { - if (LinkPreview.isCallLink(item.url)) { - return { - ...item, - isCallLink: true, - callLinkRoomId: getRoomIdFromCallLink(item.url), - }; - } - - if (!item.image && !item.title) { - return null; - } - // Story link previews don't have to correspond to links in the - // message body. - if (isStory(message.attributes)) { - return item; - } - if ( - !urls.includes(item.url) || - !LinkPreview.shouldPreviewHref(item.url) - ) { - return undefined; - } - - return item; - }) - .filter(isNotNil); - if (preview.length < incomingPreview.length) { - log.info( - `${message.idForLogging()}: Eliminated ${ - incomingPreview.length - preview.length - } previews with invalid urls'` - ); - } - - const ourPni = window.textsecure.storage.user.getCheckedPni(); - const ourServiceIds: Set = new Set([ourAci, ourPni]); - - const [longMessageAttachments, normalAttachments] = partition( - dataMessage.attachments ?? [], - attachment => MIME.isLongMessage(attachment.contentType) - ); - - window.MessageCache.toMessageAttributes(this.attributes); - message.set({ - id: messageId, - attachments: normalAttachments, - body: dataMessage.body, - bodyAttachment: longMessageAttachments[0], - bodyRanges: dataMessage.bodyRanges, - contact: dataMessage.contact, - conversationId: conversation.id, - decrypted_at: now, - errors: [], - flags: dataMessage.flags, - giftBadge: initialMessage.giftBadge, - hasAttachments: dataMessage.hasAttachments, - hasFileAttachments: dataMessage.hasFileAttachments, - hasVisualMediaAttachments: dataMessage.hasVisualMediaAttachments, - isViewOnce: Boolean(dataMessage.isViewOnce), - mentionsMe: (dataMessage.bodyRanges ?? []).some(bodyRange => { - if (!BodyRange.isMention(bodyRange)) { - return false; - } - return ourServiceIds.has( - normalizeServiceId( - bodyRange.mentionAci, - 'handleDataMessage: mentionsMe check' - ) - ); - }), - preview, - requiredProtocolVersion: - dataMessage.requiredProtocolVersion || - this.INITIAL_PROTOCOL_VERSION, - supportedVersionAtReceive: this.CURRENT_PROTOCOL_VERSION, - payment: dataMessage.payment, - quote: dataMessage.quote, - schemaVersion: dataMessage.schemaVersion, - sticker: dataMessage.sticker, - storyId: dataMessage.storyId, - }); - - if (storyQuote) { - await this.hydrateStoryContext(storyQuote, { - shouldSave: true, - }); - } - - const isSupported = !isUnsupportedMessage(message.attributes); - if (!isSupported) { - await message.eraseContents(); - } - - if (isSupported) { - const attributes = { - ...conversation.attributes, - }; - - // Drop empty messages after. This needs to happen after the initial - // message.set call and after GroupV1 processing to make sure all possible - // properties are set before we determine that a message is empty. - if (message.isEmpty()) { - log.info(`${idLog}: Dropping empty message`); - confirm(); - return; - } - - if (isStory(message.attributes)) { - attributes.hasPostedStory = true; - } else { - attributes.active_at = now; - } - - conversation.set(attributes); - - // Sync group story reply expiration timers with the parent story's - // expiration timer - if (isGroupStoryReply && storyQuote) { - message.set({ - expireTimer: storyQuote.expireTimer, - expirationStartTimestamp: storyQuote.expirationStartTimestamp, - }); - } - - if ( - dataMessage.expireTimer && - !isExpirationTimerUpdate(dataMessage) - ) { - message.set({ expireTimer: dataMessage.expireTimer }); - if (isStory(message.attributes)) { - log.info(`${idLog}: Starting story expiration`); - message.set({ - expirationStartTimestamp: dataMessage.timestamp, - }); - } - } - - if (!hasGroupV2Prop && !isStory(message.attributes)) { - if (isExpirationTimerUpdate(message.attributes)) { - message.set({ - expirationTimerUpdate: { - source, - sourceServiceId, - expireTimer: initialMessage.expireTimer, - }, - }); - - if (conversation.get('expireTimer') !== dataMessage.expireTimer) { - log.info('Incoming expirationTimerUpdate changed timer', { - id: conversation.idForLogging(), - expireTimer: dataMessage.expireTimer || 'disabled', - source: idLog, - }); - conversation.set({ - expireTimer: dataMessage.expireTimer, - }); - } - } - - // Note: For incoming expire timer updates (not normal messages that come - // along with an expireTimer), the conversation will be updated by this - // point and these calls will return early. - if (dataMessage.expireTimer) { - void conversation.updateExpirationTimer(dataMessage.expireTimer, { - source: sourceServiceId || source, - receivedAt: message.get('received_at'), - receivedAtMS: message.get('received_at_ms'), - sentAt: message.get('sent_at'), - reason: idLog, - version: initialMessage.expireTimerVersion, - }); - } else if ( - // We won't turn off timers for these kinds of messages: - !isGroupUpdate(message.attributes) && - !isEndSession(message.attributes) - ) { - void conversation.updateExpirationTimer(undefined, { - source: sourceServiceId || source, - receivedAt: message.get('received_at'), - receivedAtMS: message.get('received_at_ms'), - sentAt: message.get('sent_at'), - reason: idLog, - version: initialMessage.expireTimerVersion, - }); - } - } - - if (initialMessage.profileKey) { - const { profileKey } = initialMessage; - if ( - source === window.textsecure.storage.user.getNumber() || - sourceServiceId === window.textsecure.storage.user.getAci() - ) { - conversation.set({ profileSharing: true }); - } else if (isDirectConversation(conversation.attributes)) { - drop( - conversation.setProfileKey(profileKey, { - reason: 'handleDataMessage', - }) - ); - } else { - const local = window.ConversationController.lookupOrCreate({ - e164: source, - serviceId: sourceServiceId, - reason: 'handleDataMessage:setProfileKey', - }); - drop( - local?.setProfileKey(profileKey, { - reason: 'handleDataMessage', - }) - ); - } - } - - if (isTapToView(message.attributes) && type === 'outgoing') { - await message.eraseContents(); - } - - if ( - type === 'incoming' && - isTapToView(message.attributes) && - !message.isValidTapToView() - ) { - log.warn( - `${idLog}: Received tap to view message with invalid data. Erasing contents.` - ); - message.set({ - isTapToViewInvalid: true, - }); - await message.eraseContents(); - } - } - - const conversationTimestamp = conversation.get('timestamp'); - if ( - !isStory(message.attributes) && - !isGroupStoryReply && - (!conversationTimestamp || - message.get('sent_at') > conversationTimestamp) && - messageHasPaymentEvent(message.attributes) - ) { - conversation.set({ - lastMessage: message.getNotificationText(), - lastMessageAuthor: getMessageAuthorText(message.attributes), - timestamp: message.get('sent_at'), - }); - } - - message = window.MessageCache.__DEPRECATED$register( - message.id, - message, - 'handleDataMessage/message' - ); - conversation.incrementMessageCount(); - - // If we sent a message in a given conversation, unarchive it! - if (type === 'outgoing') { - conversation.setArchived(false); - } - - await DataWriter.updateConversation(conversation.attributes); - - const giftBadge = message.get('giftBadge'); - if (giftBadge && giftBadge.state !== GiftBadgeStates.Failed) { - const { level } = giftBadge; - const { updatesUrl } = window.SignalContext.config; - strictAssert( - typeof updatesUrl === 'string', - 'getProfile: expected updatesUrl to be a defined string' - ); - const userLanguages = getUserLanguages( - window.SignalContext.getPreferredSystemLocales(), - window.SignalContext.getResolvedMessagesLocale() - ); - const { messaging } = window.textsecure; - if (!messaging) { - throw new Error(`${idLog}: messaging is not available`); - } - const response = - await messaging.server.getSubscriptionConfiguration(userLanguages); - const boostBadgesByLevel = parseBoostBadgeListFromServer( - response, - updatesUrl - ); - const badge = boostBadgesByLevel[level]; - if (!badge) { - log.error( - `${idLog}: gift badge with level ${level} not found on server` - ); - } else { - await window.reduxActions.badges.updateOrCreate([badge]); - giftBadge.id = badge.id; - } - } - - const isFirstRun = true; - const result = await this.modifyTargetMessage(conversation, isFirstRun); - if (result === ModifyTargetMessageResult.Deleted) { - confirm(); - return; - } - - log.info(`${idLog}: Batching save`); - drop(this.saveAndNotify(conversation, confirm)); - } catch (error) { - const errorForLog = Errors.toLogFormat(error); - log.error(`${idLog}: error:`, errorForLog); - throw error; - } - }); - } - - async saveAndNotify( - conversation: ConversationModel, - confirm: () => void - ): Promise { - const { resolve, promise } = explodePromise(); - try { - conversation.addSavePromise(promise); - - await saveNewMessageBatcher.add(this.attributes); - - log.info('Message saved', this.get('sent_at')); - - // Once the message is saved to DB, we queue attachment downloads - await this.handleAttachmentDownloadsForNewMessage(conversation); - - // We'd like to check for deletions before scheduling downloads, but if an edit - // comes in, we want to have kicked off attachment downloads for the original - // message. - const isFirstRun = false; - const result = await this.modifyTargetMessage(conversation, isFirstRun); - if (result === ModifyTargetMessageResult.Deleted) { - confirm(); - return; - } - - conversation.trigger('newmessage', this.attributes); - - if (await shouldReplyNotifyUser(this.attributes, conversation)) { - await conversation.notify(this.attributes); - } - - // Increment the sent message count if this is an outgoing message - if (this.get('type') === 'outgoing') { - conversation.incrementSentMessageCount(); - } - - window.Whisper.events.trigger('incrementProgress'); - confirm(); - - if (!isStory(this.attributes)) { - drop( - conversation.queueJob('updateUnread', () => - conversation.updateUnread() - ) - ); - } - } finally { - resolve(); - conversation.removeSavePromise(promise); - } - } - - private async handleAttachmentDownloadsForNewMessage( - conversation: ConversationModel - ) { - const idLog = `handleAttachmentDownloadsForNewMessage/${conversation.idForLogging()} ${this.idForLogging()}`; - - // Only queue attachments for downloads if this is a story (with additional logic), or - // if it's either an outgoing message or we've accepted the conversation - let shouldQueueForDownload = false; - if (isStory(this.attributes)) { - shouldQueueForDownload = await shouldDownloadStory( - conversation.attributes - ); - } else { - shouldQueueForDownload = - this.hasAttachmentDownloads() && - (conversation.getAccepted() || isOutgoing(this.attributes)); - } - - if (shouldQueueForDownload) { - if (shouldUseAttachmentDownloadQueue()) { - addToAttachmentDownloadQueue(idLog, this); - } else { - await this.queueAttachmentDownloads(); - } - } - } - - // This function is called twice - once from handleDataMessage, and then again from - // saveAndNotify, a function called at the end of handleDataMessage as a cleanup for - // any missed out-of-order events. - async modifyTargetMessage( - conversation: ConversationModel, - isFirstRun: boolean - ): Promise { - return modifyTargetMessage(this, conversation, { - isFirstRun, - skipEdits: false, - }); - } - - async handleReaction( - reaction: ReactionAttributesType, - { - storyMessage, - shouldPersist = true, - }: { - storyMessage?: MessageAttributesType; - shouldPersist?: boolean; - } = {} - ): Promise { - const { attributes } = this; - - if (this.get('deletedForEveryone')) { - return; - } - - // We allow you to react to messages with outgoing errors only if it has sent - // successfully to at least one person. - if ( - hasErrors(attributes) && - (isIncoming(attributes) || - getMessagePropStatus( - attributes, - window.ConversationController.getOurConversationIdOrThrow() - ) !== 'partial-sent') - ) { - return; - } - - const conversation = this.getConversation(); - if (!conversation) { - return; - } - - const isFromThisDevice = reaction.source === ReactionSource.FromThisDevice; - const isFromSync = reaction.source === ReactionSource.FromSync; - const isFromSomeoneElse = - reaction.source === ReactionSource.FromSomeoneElse; - strictAssert( - isFromThisDevice || isFromSync || isFromSomeoneElse, - 'Reaction can only be from this device, from sync, or from someone else' - ); - - const newReaction: MessageReactionType = { - emoji: reaction.remove ? undefined : reaction.emoji, - fromId: reaction.fromId, - targetTimestamp: reaction.targetTimestamp, - timestamp: reaction.timestamp, - isSentByConversationId: isFromThisDevice - ? zipObject(conversation.getMemberConversationIds(), repeat(false)) - : undefined, - }; - - // Reactions to stories are saved as separate messages, and so require a totally - // different codepath. - if (storyMessage) { - if (isFromThisDevice) { - log.info( - 'handleReaction: sending story reaction to ' + - `${getMessageIdForLogging(storyMessage)} from this device` - ); - } else { - if (isFromSomeoneElse) { - log.info( - 'handleReaction: receiving story reaction to ' + - `${getMessageIdForLogging(storyMessage)} from someone else` - ); - } else if (isFromSync) { - log.info( - 'handleReaction: receiving story reaction to ' + - `${getMessageIdForLogging(storyMessage)} from another device` - ); - } - - const generatedMessage = reaction.generatedMessageForStoryReaction; - strictAssert( - generatedMessage, - 'Story reactions must provide storyReactionMessage' - ); - const targetConversation = window.ConversationController.get( - generatedMessage.get('conversationId') - ); - strictAssert( - targetConversation, - 'handleReaction: targetConversation not found' - ); - - window.MessageCache.toMessageAttributes(generatedMessage.attributes); - generatedMessage.set({ - expireTimer: isDirectConversation(targetConversation.attributes) - ? targetConversation.get('expireTimer') - : undefined, - storyId: storyMessage.id, - storyReaction: { - emoji: reaction.emoji, - targetAuthorAci: reaction.targetAuthorAci, - targetTimestamp: reaction.targetTimestamp, - }, - }); - - await generatedMessage.hydrateStoryContext(storyMessage, { - shouldSave: false, - }); - // Note: generatedMessage comes with an id, so we have to force this save - await DataWriter.saveMessage(generatedMessage.attributes, { - ourAci: window.textsecure.storage.user.getCheckedAci(), - forceSave: true, - }); - - log.info('Reactions.onReaction adding reaction to story', { - reactionMessageId: getMessageIdForLogging( - generatedMessage.attributes - ), - storyId: getMessageIdForLogging(storyMessage), - targetTimestamp: reaction.targetTimestamp, - timestamp: reaction.timestamp, - }); - - window.MessageCache.__DEPRECATED$register( - generatedMessage.id, - generatedMessage, - 'generatedMessage' - ); - if (isDirectConversation(targetConversation.attributes)) { - await targetConversation.addSingleMessage( - generatedMessage.attributes - ); - if (!targetConversation.get('active_at')) { - targetConversation.set({ - active_at: generatedMessage.attributes.timestamp, - }); - await DataWriter.updateConversation(targetConversation.attributes); - } - } - - if (isFromSomeoneElse) { - log.info( - 'handleReaction: notifying for story reaction to ' + - `${getMessageIdForLogging(storyMessage)} from someone else` - ); - if ( - await shouldReplyNotifyUser( - generatedMessage.attributes, - targetConversation - ) - ) { - drop(targetConversation.notify(generatedMessage.attributes)); - } - } - } - } else { - // Reactions to all messages other than stories will update the target message - const previousLength = (this.get('reactions') || []).length; - - if (isFromThisDevice) { - log.info( - `handleReaction: sending reaction to ${this.idForLogging()} ` + - 'from this device' - ); - - const reactions = reactionUtil.addOutgoingReaction( - this.get('reactions') || [], - newReaction - ); - this.set({ reactions }); - } else { - const oldReactions = this.get('reactions') || []; - let reactions: Array; - const oldReaction = oldReactions.find(re => - isNewReactionReplacingPrevious(re, newReaction) - ); - if (oldReaction) { - this.clearNotifications(oldReaction); - } - - if (reaction.remove) { - log.info( - 'handleReaction: removing reaction for message', - this.idForLogging() - ); - - if (isFromSync) { - reactions = oldReactions.filter( - re => - !isNewReactionReplacingPrevious(re, newReaction) || - re.timestamp > reaction.timestamp - ); - } else { - reactions = oldReactions.filter( - re => !isNewReactionReplacingPrevious(re, newReaction) - ); - } - this.set({ reactions }); - } else { - log.info( - 'handleReaction: adding reaction for message', - this.idForLogging() - ); - - let reactionToAdd: MessageReactionType; - if (isFromSync) { - const ourReactions = [ - newReaction, - ...oldReactions.filter(re => re.fromId === reaction.fromId), - ]; - reactionToAdd = maxBy(ourReactions, 'timestamp') || newReaction; - } else { - reactionToAdd = newReaction; - } - - reactions = oldReactions.filter( - re => !isNewReactionReplacingPrevious(re, reaction) - ); - reactions.push(reactionToAdd); - this.set({ reactions }); - - if (isOutgoing(this.attributes) && isFromSomeoneElse) { - void conversation.notify(this.attributes, reaction); - } - } - } - - if (reaction.remove) { - await DataWriter.removeReactionFromConversation({ - emoji: reaction.emoji, - fromId: reaction.fromId, - targetAuthorServiceId: reaction.targetAuthorAci, - targetTimestamp: reaction.targetTimestamp, - }); - } else { - await DataWriter.addReaction( - { - conversationId: this.get('conversationId'), - emoji: reaction.emoji, - fromId: reaction.fromId, - messageId: this.id, - messageReceivedAt: this.get('received_at'), - targetAuthorAci: reaction.targetAuthorAci, - targetTimestamp: reaction.targetTimestamp, - timestamp: reaction.timestamp, - }, - { - readStatus: isFromThisDevice - ? ReactionReadStatus.Read - : ReactionReadStatus.Unread, - } - ); - } - - const currentLength = (this.get('reactions') || []).length; - log.info( - 'handleReaction:', - `Done processing reaction for message ${this.idForLogging()}.`, - `Went from ${previousLength} to ${currentLength} reactions.` - ); - } - - if (isFromThisDevice) { - let jobData: ConversationQueueJobData; - if (storyMessage) { - strictAssert( - newReaction.emoji !== undefined, - 'New story reaction must have an emoji' - ); - - const generatedMessage = reaction.generatedMessageForStoryReaction; - strictAssert( - generatedMessage, - 'Story reactions must provide storyReactionmessage' - ); - - await generatedMessage.hydrateStoryContext(this.attributes, { - shouldSave: false, - }); - await DataWriter.saveMessage(generatedMessage.attributes, { - ourAci: window.textsecure.storage.user.getCheckedAci(), - forceSave: true, - }); - - window.MessageCache.__DEPRECATED$register( - generatedMessage.id, - generatedMessage, - 'generatedMessage2' - ); - - void conversation.addSingleMessage(generatedMessage.attributes); - - jobData = { - type: conversationQueueJobEnum.enum.NormalMessage, - conversationId: conversation.id, - messageId: generatedMessage.id, - revision: conversation.get('revision'), - }; - } else { - jobData = { - type: conversationQueueJobEnum.enum.Reaction, - conversationId: conversation.id, - messageId: this.id, - revision: conversation.get('revision'), - }; - } - if (shouldPersist) { - await conversationJobQueue.add(jobData, async jobToInsert => { - log.info( - `enqueueReactionForSend: saving message ${this.idForLogging()} and job ${ - jobToInsert.id - }` - ); - await DataWriter.saveMessage(this.attributes, { - jobToInsert, - ourAci: window.textsecure.storage.user.getCheckedAci(), - }); - }); - } else { - await conversationJobQueue.add(jobData); - } - } else if (shouldPersist && !isStory(this.attributes)) { - await DataWriter.saveMessage(this.attributes, { - ourAci: window.textsecure.storage.user.getCheckedAci(), - }); - window.reduxActions.conversations.markOpenConversationRead( - conversation.id - ); - } - } - - async handleDeleteForEveryone( - del: Pick< - DeleteAttributesType, - 'fromId' | 'targetSentTimestamp' | 'serverTimestamp' - >, - shouldPersist = true - ): Promise { - if (this.deletingForEveryone || this.get('deletedForEveryone')) { - return; - } - - log.info('Handling DOE.', { - messageId: this.id, - fromId: del.fromId, - targetSentTimestamp: del.targetSentTimestamp, - messageServerTimestamp: this.get('serverTimestamp'), - deleteServerTimestamp: del.serverTimestamp, - }); - - try { - this.deletingForEveryone = true; - - // Remove any notifications for this message - notificationService.removeBy({ messageId: this.get('id') }); - - // Erase the contents of this message - await this.eraseContents( - { deletedForEveryone: true, reactions: [] }, - shouldPersist - ); - - // Update the conversation's last message in case this was the last message - void this.getConversation()?.updateLastMessage(); - } finally { - this.deletingForEveryone = undefined; - } - } - - clearNotifications(reaction: Partial = {}): void { - notificationService.removeBy({ - ...reaction, - messageId: this.id, - }); - } - - getPendingMarkRead(): number | undefined { - return this.pendingMarkRead; - } - - setPendingMarkRead(value: number | undefined): void { - this.pendingMarkRead = value; - } } - -window.Whisper.Message = MessageModel; diff --git a/ts/reactions/enqueueReactionForSend.ts b/ts/reactions/enqueueReactionForSend.ts index 0fbc3c561..683f51234 100644 --- a/ts/reactions/enqueueReactionForSend.ts +++ b/ts/reactions/enqueueReactionForSend.ts @@ -5,10 +5,13 @@ import noop from 'lodash/noop'; import { v7 as generateUuid } from 'uuid'; import { DataWriter } from '../sql/Client'; -import type { MessageModel } from '../models/messages'; -import type { ReactionAttributesType } from '../messageModifiers/Reactions'; +import { MessageModel } from '../models/messages'; +import { + handleReaction, + type ReactionAttributesType, +} from '../messageModifiers/Reactions'; import { ReactionSource } from './ReactionSource'; -import { __DEPRECATED$getMessageById } from '../messages/getMessageById'; +import { getMessageById } from '../messages/getMessageById'; import { getSourceServiceId, isStory } from '../messages/helpers'; import { strictAssert } from '../util/assert'; import { isDirectConversation } from '../util/whatTypeOfConversation'; @@ -19,6 +22,7 @@ import { getMessageSentTimestamp } from '../util/getMessageSentTimestamp'; import { isAciString } from '../util/isAciString'; import { SendStatus } from '../messages/MessageSendState'; import * as log from '../logging/log'; +import { getMessageIdForLogging } from '../util/idForLogging'; export async function enqueueReactionForSend({ emoji, @@ -29,20 +33,17 @@ export async function enqueueReactionForSend({ messageId: string; remove: boolean; }>): Promise { - const message = await __DEPRECATED$getMessageById( - messageId, - 'enqueueReactionForSend' - ); + const message = await getMessageById(messageId); strictAssert(message, 'enqueueReactionForSend: no message found'); const targetAuthorAci = getSourceServiceId(message.attributes); strictAssert( targetAuthorAci, - `enqueueReactionForSend: message ${message.idForLogging()} had no source UUID` + `enqueueReactionForSend: message ${getMessageIdForLogging(message.attributes)} had no source UUID` ); strictAssert( isAciString(targetAuthorAci), - `enqueueReactionForSend: message ${message.idForLogging()} had no source ACI` + `enqueueReactionForSend: message ${getMessageIdForLogging(message.attributes)} had no source ACI` ); const targetTimestamp = getMessageSentTimestamp(message.attributes, { @@ -50,11 +51,13 @@ export async function enqueueReactionForSend({ }); strictAssert( targetTimestamp, - `enqueueReactionForSend: message ${message.idForLogging()} had no timestamp` + `enqueueReactionForSend: message ${getMessageIdForLogging(message.attributes)} had no timestamp` ); const timestamp = Date.now(); - const messageConversation = message.getConversation(); + const messageConversation = window.ConversationController.get( + message.get('conversationId') + ); strictAssert( messageConversation, 'enqueueReactionForSend: No conversation extracted from target message' @@ -94,7 +97,7 @@ export async function enqueueReactionForSend({ // Only used in story scenarios, where we use a whole message to represent the reaction let storyReactionMessage: MessageModel | undefined; if (storyMessage) { - storyReactionMessage = new window.Whisper.Message({ + storyReactionMessage = new MessageModel({ ...generateMessageId(incrementMessageCounter()), type: 'outgoing', conversationId: targetConversation.id, @@ -132,5 +135,5 @@ export async function enqueueReactionForSend({ timestamp, }; - await message.handleReaction(reaction, { storyMessage }); + await handleReaction(message, reaction, { storyMessage }); } diff --git a/ts/services/MessageCache.ts b/ts/services/MessageCache.ts index df7f6ffe7..611883665 100644 --- a/ts/services/MessageCache.ts +++ b/ts/services/MessageCache.ts @@ -1,63 +1,124 @@ // Copyright 2019 Signal Messenger, LLC // SPDX-License-Identifier: AGPL-3.0-only -import cloneDeep from 'lodash/cloneDeep'; import { throttle } from 'lodash'; import { LRUCache } from 'lru-cache'; -import type { - MessageAttributesType, - ReadonlyMessageAttributesType, -} from '../model-types.d'; -import type { MessageModel } from '../models/messages'; -import { DataReader, DataWriter } from '../sql/Client'; -import * as Errors from '../types/errors'; + import * as log from '../logging/log'; -import { getEnvironment, Environment } from '../environment'; +import { MessageModel } from '../models/messages'; +import { DataReader, DataWriter } from '../sql/Client'; import { getMessageConversation } from '../util/getMessageConversation'; -import { getMessageModelLogger } from '../util/MessageModelLogger'; import { getSenderIdentifier } from '../util/getSenderIdentifier'; import { isNotNil } from '../util/isNotNil'; -import { softAssert, strictAssert } from '../util/assert'; import { isStory } from '../messages/helpers'; -import type { SendStateByConversationId } from '../messages/MessageSendState'; import { getStoryDataFromMessageAttributes } from './storyLoader'; +import { postSaveUpdates } from '../util/cleanup'; + +import type { MessageAttributesType } from '../model-types.d'; +import type { SendStateByConversationId } from '../messages/MessageSendState'; +import type { StoredJob } from '../jobs/types'; const MAX_THROTTLED_REDUX_UPDATERS = 200; export class MessageCache { + static install(): MessageCache { + const instance = new MessageCache(); + window.MessageCache = instance; + return instance; + } + private state = { - messages: new Map(), + messages: new Map(), messageIdsBySender: new Map(), messageIdsBySentAt: new Map>(), lastAccessedAt: new Map(), }; - // Stores the models so that __DEPRECATED$register always returns the existing - // copy instead of a new model. - private modelCache = new Map(); + public saveMessage( + message: MessageAttributesType | MessageModel, + options?: { + forceSave?: boolean; + jobToInsert?: Readonly; + } + ): Promise { + const attributes = + message instanceof MessageModel ? message.attributes : message; - // Synchronously access a message's attributes from internal cache. Will - // return undefined if the message does not exist in memory. - public accessAttributes( - messageId: string - ): Readonly | undefined { - const messageAttributes = this.state.messages.get(messageId); - return messageAttributes - ? this.freezeAttributes(messageAttributes) - : undefined; + return DataWriter.saveMessage(attributes, { + ourAci: window.textsecure.storage.user.getCheckedAci(), + postSaveUpdates, + ...options, + }); } - // Synchronously access a message's attributes from internal cache. Throws - // if the message does not exist in memory. - public accessAttributesOrThrow( - source: string, - messageId: string - ): Readonly { - const messageAttributes = this.accessAttributes(messageId); - strictAssert( - messageAttributes, - `MessageCache.accessAttributesOrThrow/${source}: no message for id ${messageId}` - ); - return messageAttributes; + public register(message: MessageModel): MessageModel { + if (!message || !message.id) { + throw new Error('MessageCache.register: Got falsey id or message'); + } + + const existing = this.getById(message.id); + if (existing) { + return existing; + } + + this.addMessageToCache(message); + + return message; + } + + // Finds a message in the cache by sender identifier + public findBySender(senderIdentifier: string): MessageModel | undefined { + const id = this.state.messageIdsBySender.get(senderIdentifier); + if (!id) { + return undefined; + } + + return this.getById(id); + } + + // Finds a message in the cache by Id + public getById(id: string): MessageModel | undefined { + const message = this.state.messages.get(id); + if (!message) { + return undefined; + } + + this.state.lastAccessedAt.set(id, Date.now()); + + return message; + } + + // Finds a message in the cache by sentAt/timestamp + public async findBySentAt( + sentAt: number, + predicate: (model: MessageModel) => boolean + ): Promise { + const items = this.state.messageIdsBySentAt.get(sentAt) ?? []; + const inMemory = items + .map(id => this.getById(id)) + .filter(isNotNil) + .find(predicate); + + if (inMemory != null) { + return inMemory; + } + + log.info(`findBySentAt(${sentAt}): db lookup needed`); + const allOnDisk = await DataReader.getMessagesBySentAt(sentAt); + const onDisk = allOnDisk + .map(message => this.register(new MessageModel(message))) + .find(predicate); + + return onDisk; + } + + // Deletes the message from our cache + public unregister(id: string): void { + const message = this.state.messages.get(id); + if (!message) { + return; + } + + this.removeMessage(id); } // Evicts messages from the message cache if they have not been accessed past @@ -65,9 +126,9 @@ export class MessageCache { public deleteExpiredMessages(expiryTime: number): void { const now = Date.now(); - for (const [messageId, messageAttributes] of this.state.messages) { + for (const [messageId, message] of this.state.messages) { const timeLastAccessed = this.state.lastAccessedAt.get(messageId) ?? 0; - const conversation = getMessageConversation(messageAttributes); + const conversation = getMessageConversation(message.attributes); const state = window.reduxStore.getState(); const selectedId = state?.conversations?.selectedConversationId; @@ -75,21 +136,25 @@ export class MessageCache { conversation && selectedId && conversation.id === selectedId; if (now - timeLastAccessed > expiryTime && !inActiveConversation) { - this.__DEPRECATED$unregister(messageId); + this.unregister(messageId); } } } - // Finds a message in the cache by sender identifier - public findBySender( - senderIdentifier: string - ): Readonly | undefined { - const id = this.state.messageIdsBySender.get(senderIdentifier); - if (!id) { - return undefined; + public async upgradeSchema( + message: MessageModel, + minSchemaVersion: number + ): Promise { + const { schemaVersion } = message.attributes; + if (!schemaVersion || schemaVersion >= minSchemaVersion) { + return; + } + const startingAttributes = message.attributes; + const upgradedAttributes = + await window.Signal.Migrations.upgradeMessageSchema(startingAttributes); + if (startingAttributes !== upgradedAttributes) { + message.set(upgradedAttributes); } - - return this.accessAttributes(id); } public replaceAllObsoleteConversationIds({ @@ -112,12 +177,12 @@ export class MessageCache { }; }; - for (const [messageId, messageAttributes] of this.state.messages) { - if (messageAttributes.conversationId !== obsoleteId) { + for (const [, message] of this.state.messages) { + if (message.get('conversationId') !== obsoleteId) { continue; } - const editHistory = messageAttributes.editHistory?.map(history => { + const editHistory = message.get('editHistory')?.map(history => { return { ...history, sendStateByConversationId: updateSendState( @@ -126,117 +191,33 @@ export class MessageCache { }; }); - this.setAttributes({ - messageId, - messageAttributes: { - conversationId, - sendStateByConversationId: updateSendState( - messageAttributes.sendStateByConversationId - ), - editHistory, - }, - skipSaveToDatabase: true, + message.set({ + conversationId, + sendStateByConversationId: updateSendState( + message.get('sendStateByConversationId') + ), + editHistory, }); } } - // Find the message's attributes whether in memory or in the database. - // Refresh the attributes in the cache if they exist. Throw if we cannot find - // a matching message. - public async resolveAttributes( - source: string, - messageId: string - ): Promise> { - const inMemoryMessageAttributes = this.accessAttributes(messageId); + // Semi-public API - if (inMemoryMessageAttributes) { - return inMemoryMessageAttributes; - } + // Should only be called by MessageModel's set() function + public _updateCaches(message: MessageModel): undefined { + const existing = this.getById(message.id); - let messageAttributesFromDatabase: MessageAttributesType | undefined; - try { - messageAttributesFromDatabase = - await DataReader.getMessageById(messageId); - } catch (err: unknown) { - log.error( - `MessageCache.resolveAttributes(${messageId}): db error ${Errors.toLogFormat( - err - )}` - ); - } - - strictAssert( - messageAttributesFromDatabase, - `MessageCache.resolveAttributes/${source}: no message for id ${messageId}` - ); - - return this.freezeAttributes(messageAttributesFromDatabase); - } - - // Updates a message's attributes and saves the message to cache and to the - // database. Option to skip the save to the database. - - // Overload #1: if skipSaveToDatabase = true, returns void - public setAttributes({ - messageId, - messageAttributes, - skipSaveToDatabase, - }: { - messageId: string; - messageAttributes: Partial; - skipSaveToDatabase: true; - }): void; - - // Overload #2: if skipSaveToDatabase = false, returns DB save promise - public setAttributes({ - messageId, - messageAttributes, - skipSaveToDatabase, - }: { - messageId: string; - messageAttributes: Partial; - skipSaveToDatabase: false; - }): Promise; - - // Implementation - public setAttributes({ - messageId, - messageAttributes: partialMessageAttributes, - skipSaveToDatabase, - }: { - messageId: string; - messageAttributes: Partial; - skipSaveToDatabase: boolean; - }): Promise | undefined { - let messageAttributes = this.accessAttributes(messageId); - - softAssert(messageAttributes, 'could not find message attributes'); - if (!messageAttributes) { - // We expect message attributes to be defined in cache if one is trying to - // set new attributes. In the case that the attributes are missing in cache - // we'll add whatever we currently have to cache as a defensive measure so - // that the code continues to work properly downstream. The softAssert above - // that logs/debugger should be addressed upstream immediately by ensuring - // that message is in cache. - const partiallyCachedMessage = { - id: messageId, - ...partialMessageAttributes, - } as MessageAttributesType; - - this.addMessageToCache(partiallyCachedMessage); - messageAttributes = partiallyCachedMessage; + // If this model hasn't been registered yet, we can't add to cache because we don't + // want to force `message` to be the primary MessageModel for this message. + if (!existing) { + return; } this.state.messageIdsBySender.delete( - getSenderIdentifier(messageAttributes) + getSenderIdentifier(message.attributes) ); - const nextMessageAttributes = { - ...messageAttributes, - ...partialMessageAttributes, - }; - - const { id, sent_at: sentAt } = nextMessageAttributes; + const { id, sent_at: sentAt } = message.attributes; const previousIdsBySentAt = this.state.messageIdsBySentAt.get(sentAt); let nextIdsBySentAtSet: Set; @@ -247,44 +228,70 @@ export class MessageCache { nextIdsBySentAtSet = new Set([id]); } - this.state.messages.set(id, nextMessageAttributes); this.state.lastAccessedAt.set(id, Date.now()); this.state.messageIdsBySender.set( - getSenderIdentifier(messageAttributes), + getSenderIdentifier(message.attributes), id ); - this.markModelStale(nextMessageAttributes); + this.throttledUpdateRedux(message.attributes); + } - this.throttledUpdateRedux(nextMessageAttributes); + // Helpers - if (skipSaveToDatabase) { + private addMessageToCache(message: MessageModel): void { + if (!message.id) { return; } - return DataWriter.saveMessage(nextMessageAttributes, { - ourAci: window.textsecure.storage.user.getCheckedAci(), - }); - } - - private throttledReduxUpdaters = new LRUCache< - string, - typeof this.updateRedux - >({ - max: MAX_THROTTLED_REDUX_UPDATERS, - }); - - private throttledUpdateRedux(attributes: MessageAttributesType) { - let updater = this.throttledReduxUpdaters.get(attributes.id); - if (!updater) { - updater = throttle(this.updateRedux.bind(this), 200, { - leading: true, - trailing: true, - }); - this.throttledReduxUpdaters.set(attributes.id, updater); + if (this.state.messages.has(message.id)) { + this.state.lastAccessedAt.set(message.id, Date.now()); + return; } - updater(attributes); + const { id, sent_at: sentAt } = message.attributes; + const previousIdsBySentAt = this.state.messageIdsBySentAt.get(sentAt); + + let nextIdsBySentAtSet: Set; + if (previousIdsBySentAt) { + nextIdsBySentAtSet = new Set(previousIdsBySentAt); + nextIdsBySentAtSet.add(id); + } else { + nextIdsBySentAtSet = new Set([id]); + } + + this.state.messages.set(message.id, message); + this.state.lastAccessedAt.set(message.id, Date.now()); + this.state.messageIdsBySentAt.set(sentAt, Array.from(nextIdsBySentAtSet)); + this.state.messageIdsBySender.set( + getSenderIdentifier(message.attributes), + id + ); + } + + private removeMessage(messageId: string): void { + const message = this.state.messages.get(messageId); + if (!message) { + return; + } + + const { id, sent_at: sentAt } = message.attributes; + const nextIdsBySentAtSet = + new Set(this.state.messageIdsBySentAt.get(sentAt)) || new Set(); + + nextIdsBySentAtSet.delete(id); + + if (nextIdsBySentAtSet.size) { + this.state.messageIdsBySentAt.set(sentAt, Array.from(nextIdsBySentAtSet)); + } else { + this.state.messageIdsBySentAt.delete(sentAt); + } + + this.state.messages.delete(messageId); + this.state.lastAccessedAt.delete(messageId); + this.state.messageIdsBySender.delete( + getSenderIdentifier(message.attributes) + ); } private updateRedux(attributes: MessageAttributesType) { @@ -313,238 +320,23 @@ export class MessageCache { ); } - // When you already have the message attributes from the db and want to - // ensure that they're added to the cache. The latest attributes from cache - // are returned if they exist, if not the attributes passed in are returned. - public toMessageAttributes( - messageAttributes: MessageAttributesType - ): Readonly { - this.addMessageToCache(messageAttributes); + private throttledReduxUpdaters = new LRUCache< + string, + typeof this.updateRedux + >({ + max: MAX_THROTTLED_REDUX_UPDATERS, + }); - const nextMessageAttributes = this.state.messages.get(messageAttributes.id); - strictAssert( - nextMessageAttributes, - `MessageCache.toMessageAttributes: no message for id ${messageAttributes.id}` - ); - - if (getEnvironment() === Environment.Development) { - return Object.freeze(cloneDeep(nextMessageAttributes)); - } - return nextMessageAttributes; - } - - static install(): MessageCache { - const instance = new MessageCache(); - window.MessageCache = instance; - return instance; - } - - private addMessageToCache(messageAttributes: MessageAttributesType): void { - if (!messageAttributes.id) { - return; - } - - if (this.state.messages.has(messageAttributes.id)) { - this.state.lastAccessedAt.set(messageAttributes.id, Date.now()); - return; - } - - const { id, sent_at: sentAt } = messageAttributes; - const previousIdsBySentAt = this.state.messageIdsBySentAt.get(sentAt); - - let nextIdsBySentAtSet: Set; - if (previousIdsBySentAt) { - nextIdsBySentAtSet = new Set(previousIdsBySentAt); - nextIdsBySentAtSet.add(id); - } else { - nextIdsBySentAtSet = new Set([id]); - } - - this.state.messages.set(messageAttributes.id, { ...messageAttributes }); - this.state.lastAccessedAt.set(messageAttributes.id, Date.now()); - this.state.messageIdsBySentAt.set(sentAt, Array.from(nextIdsBySentAtSet)); - this.state.messageIdsBySender.set( - getSenderIdentifier(messageAttributes), - id - ); - } - - private freezeAttributes( - messageAttributes: MessageAttributesType - ): Readonly { - this.addMessageToCache(messageAttributes); - - if (getEnvironment() === Environment.Development) { - return Object.freeze(cloneDeep(messageAttributes)); - } - return messageAttributes; - } - - private removeMessage(messageId: string): void { - const messageAttributes = this.state.messages.get(messageId); - if (!messageAttributes) { - return; - } - - const { id, sent_at: sentAt } = messageAttributes; - const nextIdsBySentAtSet = - new Set(this.state.messageIdsBySentAt.get(sentAt)) || new Set(); - - nextIdsBySentAtSet.delete(id); - - if (nextIdsBySentAtSet.size) { - this.state.messageIdsBySentAt.set(sentAt, Array.from(nextIdsBySentAtSet)); - } else { - this.state.messageIdsBySentAt.delete(sentAt); - } - - this.state.messages.delete(messageId); - this.state.lastAccessedAt.delete(messageId); - this.state.messageIdsBySender.delete( - getSenderIdentifier(messageAttributes) - ); - } - - // Deprecated methods below - - // Adds the message into the cache and eturns a Proxy that resembles - // a MessageModel - public __DEPRECATED$register( - id: string, - data: MessageModel | MessageAttributesType, - location: string - ): MessageModel { - if (!id || !data) { - throw new Error( - 'MessageCache.__DEPRECATED$register: Got falsey id or message' - ); - } - - const existing = this.__DEPRECATED$getById(id, location); - - if (existing) { - this.addMessageToCache(existing.attributes); - return existing; - } - - const modelProxy = this.toModel(data); - const messageAttributes = 'attributes' in data ? data.attributes : data; - this.addMessageToCache(messageAttributes); - modelProxy.registerLocations.add(location); - - return modelProxy; - } - - // Deletes the message from our cache - public __DEPRECATED$unregister(id: string): void { - const model = this.modelCache.get(id); - if (!model) { - return; - } - - this.removeMessage(id); - this.modelCache.delete(id); - } - - // Finds a message in the cache by Id - public __DEPRECATED$getById( - id: string, - location: string - ): MessageModel | undefined { - const data = this.state.messages.get(id); - if (!data) { - return undefined; - } - - const model = this.toModel(data); - model.registerLocations.add(location); - return model; - } - - public async upgradeSchema( - attributes: MessageAttributesType, - minSchemaVersion: number - ): Promise { - const { schemaVersion } = attributes; - if (!schemaVersion || schemaVersion >= minSchemaVersion) { - return attributes; - } - const upgradedAttributes = - await window.Signal.Migrations.upgradeMessageSchema(attributes); - await this.setAttributes({ - messageId: upgradedAttributes.id, - messageAttributes: upgradedAttributes, - skipSaveToDatabase: false, - }); - return upgradedAttributes; - } - - // Finds a message in the cache by sentAt/timestamp - public async findBySentAt( - sentAt: number, - predicate: (attributes: ReadonlyMessageAttributesType) => boolean - ): Promise { - const items = this.state.messageIdsBySentAt.get(sentAt) ?? []; - const inMemory = items - .map(id => this.accessAttributes(id)) - .filter(isNotNil) - .find(predicate); - - if (inMemory != null) { - return inMemory; - } - - log.info(`findBySentAt(${sentAt}): db lookup needed`); - const allOnDisk = await DataReader.getMessagesBySentAt(sentAt); - const onDisk = allOnDisk.find(predicate); - - if (onDisk != null) { - this.addMessageToCache(onDisk); - } - return onDisk; - } - - // Marks cached model as "should be stale" to discourage continued use. - // The model's attributes are directly updated so that the model is in sync - // with the in-memory attributes. - private markModelStale(messageAttributes: MessageAttributesType): void { - const { id } = messageAttributes; - const model = this.modelCache.get(id); - - if (!model) { - return; - } - - model.attributes = { ...messageAttributes }; - - if (getEnvironment() === Environment.Development) { - log.warn('MessageCache: updating cached backbone model', { - cid: model.cid, - locations: Array.from(model.registerLocations).join(', '), + private throttledUpdateRedux(attributes: MessageAttributesType) { + let updater = this.throttledReduxUpdaters.get(attributes.id); + if (!updater) { + updater = throttle(this.updateRedux.bind(this), 200, { + leading: true, + trailing: true, }); - } - } - - // Creates a proxy object for MessageModel which logs usage in development - // so that we're able to migrate off of models - private toModel( - messageAttributes: MessageAttributesType | MessageModel - ): MessageModel { - const existingModel = this.modelCache.get(messageAttributes.id); - - if (existingModel) { - return existingModel; + this.throttledReduxUpdaters.set(attributes.id, updater); } - const model = - 'attributes' in messageAttributes - ? messageAttributes - : new window.Whisper.Message(messageAttributes); - - const proxy = getMessageModelLogger(model); - - this.modelCache.set(messageAttributes.id, proxy); - - return proxy; + updater(attributes); } } diff --git a/ts/services/MessageUpdater.ts b/ts/services/MessageUpdater.ts index 4839228da..bc526f79d 100644 --- a/ts/services/MessageUpdater.ts +++ b/ts/services/MessageUpdater.ts @@ -2,10 +2,19 @@ // SPDX-License-Identifier: AGPL-3.0-only import type { MessageAttributesType } from '../model-types.d'; +import type { MessageModel } from '../models/messages'; import { ReadStatus, maxReadStatus } from '../messages/MessageReadStatus'; import { notificationService } from './notifications'; import { SeenStatus } from '../MessageSeenStatus'; import { queueUpdateMessage } from '../util/messageBatcher'; +import * as Errors from '../types/errors'; +import * as log from '../logging/log'; +import { isValidTapToView } from '../util/isValidTapToView'; +import { getMessageIdForLogging } from '../util/idForLogging'; +import { eraseMessageContents } from '../util/cleanup'; +import { getSource, getSourceServiceId } from '../messages/helpers'; +import { isAciString } from '../util/isAciString'; +import { viewOnceOpenJobQueue } from '../jobs/viewOnceOpenJobQueue'; function markReadOrViewed( messageAttrs: Readonly, @@ -54,3 +63,65 @@ export const markViewed = ( { skipSave = false } = {} ): MessageAttributesType => markReadOrViewed(messageAttrs, ReadStatus.Viewed, viewedAt, skipSave); + +export async function markViewOnceMessageViewed( + message: MessageModel, + options?: { + fromSync?: boolean; + } +): Promise { + const { fromSync } = options || {}; + + if (!isValidTapToView(message.attributes)) { + log.warn( + `markViewOnceMessageViewed: Message ${getMessageIdForLogging(message.attributes)} is not a valid tap to view message!` + ); + return; + } + if (message.attributes.isErased) { + log.warn( + `markViewOnceMessageViewed: Message ${getMessageIdForLogging(message.attributes)} is already erased!` + ); + return; + } + + if (message.get('readStatus') !== ReadStatus.Viewed) { + message.set(markViewed(message.attributes)); + } + + await eraseMessageContents(message); + + if (!fromSync) { + const senderE164 = getSource(message.attributes); + const senderAci = getSourceServiceId(message.attributes); + const timestamp = message.get('sent_at'); + + if (senderAci === undefined || !isAciString(senderAci)) { + throw new Error('markViewOnceMessageViewed: senderAci is undefined'); + } + + if (window.ConversationController.areWePrimaryDevice()) { + log.warn( + 'markViewOnceMessageViewed: We are primary device; not sending view once open sync' + ); + return; + } + + try { + await viewOnceOpenJobQueue.add({ + viewOnceOpens: [ + { + senderE164, + senderAci, + timestamp, + }, + ], + }); + } catch (error) { + log.error( + 'markViewOnceMessageViewed: Failed to queue view once open sync', + Errors.toLogFormat(error) + ); + } + } +} diff --git a/ts/services/backups/import.ts b/ts/services/backups/import.ts index 71abe075a..e100d8ebf 100644 --- a/ts/services/backups/import.ts +++ b/ts/services/backups/import.ts @@ -128,6 +128,7 @@ import { isAdhoc, isNightly } from '../../util/version'; import { ToastType } from '../../types/Toast'; import { isConversationAccepted } from '../../util/isConversationAccepted'; import { saveBackupsSubscriberData } from '../../util/backupSubscriptionData'; +import { postSaveUpdates } from '../../util/cleanup'; const MAX_CONCURRENCY = 10; @@ -609,6 +610,7 @@ export class BackupImportStream extends Writable { await DataWriter.saveMessages(batch, { forceSave: true, ourAci, + postSaveUpdates, }); const attachmentDownloadJobPromises: Array> = []; diff --git a/ts/services/expiringMessagesDeletion.ts b/ts/services/expiringMessagesDeletion.ts index f868705cb..422b49f81 100644 --- a/ts/services/expiringMessagesDeletion.ts +++ b/ts/services/expiringMessagesDeletion.ts @@ -4,22 +4,21 @@ import { batch } from 'react-redux'; import { debounce } from 'lodash'; +import * as Errors from '../types/errors'; +import * as log from '../logging/log'; import { DataReader, DataWriter } from '../sql/Client'; import { clearTimeoutIfNecessary } from '../util/clearTimeoutIfNecessary'; import { sleep } from '../util/sleep'; import { SECOND } from '../util/durations'; -import * as Errors from '../types/errors'; -import * as log from '../logging/log'; - -import type { MessageModel } from '../models/messages'; -import type { SingleProtoJobQueue } from '../jobs/singleProtoJobQueue'; +import { MessageModel } from '../models/messages'; +import { cleanupMessages } from '../util/cleanup'; class ExpiringMessagesDeletionService { public update: typeof this.checkExpiringMessages; private timeout?: ReturnType; - constructor(private readonly singleProtoJobQueue: SingleProtoJobQueue) { + constructor() { this.update = debounce(this.checkExpiringMessages, 1000); } @@ -37,17 +36,15 @@ class ExpiringMessagesDeletionService { const inMemoryMessages: Array = []; messages.forEach(dbMessage => { - const message = window.MessageCache.__DEPRECATED$register( - dbMessage.id, - dbMessage, - 'destroyExpiredMessages' + const message = window.MessageCache.register( + new MessageModel(dbMessage) ); messageIds.push(message.id); inMemoryMessages.push(message); }); await DataWriter.removeMessages(messageIds, { - singleProtoJobQueue: this.singleProtoJobQueue, + cleanupMessages, }); batch(() => { @@ -57,7 +54,6 @@ class ExpiringMessagesDeletionService { }); // We do this to update the UI, if this message is being displayed somewhere - message.trigger('expired'); window.reduxActions.conversations.messageExpired(message.id); }); }); @@ -114,14 +110,12 @@ class ExpiringMessagesDeletionService { } } -// Because this service is used inside of Client.ts, it can't directly reference -// SingleProtoJobQueue. Instead of direct access, it is provided once on startup. -export function initialize(singleProtoJobQueue: SingleProtoJobQueue): void { +export function initialize(): void { if (instance) { log.warn('Expiring Messages Deletion service is already initialized!'); return; } - instance = new ExpiringMessagesDeletionService(singleProtoJobQueue); + instance = new ExpiringMessagesDeletionService(); } export async function update(): Promise { diff --git a/ts/services/releaseNotesFetcher.ts b/ts/services/releaseNotesFetcher.ts index 882c00cb5..f7efab250 100644 --- a/ts/services/releaseNotesFetcher.ts +++ b/ts/services/releaseNotesFetcher.ts @@ -26,6 +26,7 @@ import type { ReleaseNoteResponseType, } from '../textsecure/WebAPI'; import type { WithRequiredProperties } from '../types/Util'; +import { MessageModel } from '../models/messages'; const FETCH_INTERVAL = 3 * durations.DAY; const ERROR_RETRY_DELAY = 3 * durations.HOUR; @@ -187,7 +188,7 @@ export class ReleaseNotesFetcher { ]; const timestamp = Date.now() + index; - const message: MessageAttributesType = { + const message = new MessageModel({ ...generateMessageId(incrementMessageCounter()), body: messageBody, bodyRanges, @@ -201,12 +202,12 @@ export class ReleaseNotesFetcher { sourceServiceId: signalConversation.getServiceId(), timestamp, type: 'incoming', - }; + }); - window.MessageCache.toMessageAttributes(message); - signalConversation.trigger('newmessage', message); + window.MessageCache.register(message); + drop(signalConversation.onNewMessage(message)); - messages.push(message); + messages.push(message.attributes); }); await Promise.all( diff --git a/ts/services/storyLoader.ts b/ts/services/storyLoader.ts index faee925c8..9f82639f0 100644 --- a/ts/services/storyLoader.ts +++ b/ts/services/storyLoader.ts @@ -18,6 +18,7 @@ import { strictAssert } from '../util/assert'; import { dropNull } from '../util/dropNull'; import { DurationInSeconds } from '../util/durations'; import { SIGNAL_ACI } from '../types/SignalConversation'; +import { postSaveUpdates } from '../util/cleanup'; let storyData: GetAllStoriesResultType | undefined; @@ -174,6 +175,7 @@ async function repairUnexpiredStories(): Promise { storiesWithExpiry.map(messageAttributes => { return DataWriter.saveMessage(messageAttributes, { ourAci: window.textsecure.storage.user.getCheckedAci(), + postSaveUpdates, }); }) ); diff --git a/ts/services/tapToViewMessagesDeletionService.ts b/ts/services/tapToViewMessagesDeletionService.ts index cdc6a2cdd..92c0a5ab7 100644 --- a/ts/services/tapToViewMessagesDeletionService.ts +++ b/ts/services/tapToViewMessagesDeletionService.ts @@ -8,6 +8,9 @@ import { getMessageQueueTime } from '../util/getMessageQueueTime'; import * as Errors from '../types/errors'; import { strictAssert } from '../util/assert'; import { toBoundedDate } from '../util/timestamp'; +import { getMessageIdForLogging } from '../util/idForLogging'; +import { eraseMessageContents } from '../util/cleanup'; +import { MessageModel } from '../models/messages'; async function eraseTapToViewMessages() { try { @@ -26,22 +29,17 @@ async function eraseTapToViewMessages() { 'Must be older than maxTimestamp' ); - const message = window.MessageCache.__DEPRECATED$register( - fromDB.id, - fromDB, - 'eraseTapToViewMessages' - ); + const message = window.MessageCache.register(new MessageModel(fromDB)); window.SignalContext.log.info( 'eraseTapToViewMessages: erasing message contents', - message.idForLogging() + getMessageIdForLogging(message.attributes) ); // We do this to update the UI, if this message is being displayed somewhere - message.trigger('expired'); window.reduxActions.conversations.messageExpired(message.id); - await message.eraseContents(); + await eraseMessageContents(message); }) ); } catch (error) { diff --git a/ts/sql/Client.ts b/ts/sql/Client.ts index 9e0f1eac2..2b1faa049 100644 --- a/ts/sql/Client.ts +++ b/ts/sql/Client.ts @@ -2,30 +2,34 @@ // SPDX-License-Identifier: AGPL-3.0-only import { ipcRenderer as ipc } from 'electron'; - import { groupBy, isTypedArray, last, map, omit } from 'lodash'; + import type { ReadonlyDeep } from 'type-fest'; -import { deleteExternalFiles } from '../types/Conversation'; -import { update as updateExpiringMessagesService } from '../services/expiringMessagesDeletion'; -import { tapToViewMessagesDeletionService } from '../services/tapToViewMessagesDeletionService'; +// Note: nothing imported here can come back and require Client.ts, and that includes +// their imports too. That circularity causes problems. Anything that would do that needs +// to be passed in, like cleanupMessages below. import * as Bytes from '../Bytes'; +import * as log from '../logging/log'; +import * as Errors from '../types/errors'; + +import { deleteExternalFiles } from '../types/Conversation'; import { createBatcher } from '../util/batcher'; import { assertDev, softAssert } from '../util/assert'; import { mapObjectWithSpec } from '../util/mapObjectWithSpec'; -import type { ObjectMappingSpecType } from '../util/mapObjectWithSpec'; import { cleanDataForIpc } from './cleanDataForIpc'; -import type { AciString, ServiceIdString } from '../types/ServiceId'; import createTaskWithTimeout from '../textsecure/TaskWithTimeout'; -import * as log from '../logging/log'; import { isValidUuid, isValidUuidV7 } from '../util/isValidUuid'; -import * as Errors from '../types/errors'; - -import type { StoredJob } from '../jobs/types'; import { formatJobForInsert } from '../jobs/formatJobForInsert'; -import { cleanupMessages } from '../util/cleanup'; import { AccessType, ipcInvoke, doShutdown, removeDB } from './channels'; +import { getMessageIdForLogging } from '../util/idForLogging'; +import { incrementMessageCounter } from '../util/incrementMessageCounter'; +import { generateSnippetAroundMention } from '../util/search'; +import { drop } from '../util/drop'; +import type { ObjectMappingSpecType } from '../util/mapObjectWithSpec'; +import type { AciString, ServiceIdString } from '../types/ServiceId'; +import type { StoredJob } from '../jobs/types'; import type { ClientInterfaceWrap, AdjacentMessagesByConversationOptionsType, @@ -58,12 +62,8 @@ import type { ClientOnlyReadableInterface, ClientOnlyWritableInterface, } from './Interface'; -import { getMessageIdForLogging } from '../util/idForLogging'; import type { MessageAttributesType } from '../model-types'; -import { incrementMessageCounter } from '../util/incrementMessageCounter'; -import { generateSnippetAroundMention } from '../util/search'; import type { AttachmentDownloadJobType } from '../types/AttachmentDownload'; -import type { SingleProtoJobQueue } from '../jobs/singleProtoJobQueue'; const ERASE_SQL_KEY = 'erase-sql-key'; const ERASE_ATTACHMENTS_KEY = 'erase-attachments'; @@ -121,6 +121,10 @@ const clientOnlyWritable: ClientOnlyWritableInterface = { removeMessage, removeMessages, + saveMessage, + saveMessages, + saveMessagesIndividually, + // Client-side only flushUpdateConversationBatcher, @@ -137,17 +141,12 @@ const clientOnlyWritable: ClientOnlyWritableInterface = { type ClientOverridesType = ClientOnlyWritableInterface & Pick< ClientInterfaceWrap, - | 'saveAttachmentDownloadJob' - | 'saveMessage' - | 'saveMessages' - | 'updateConversations' + 'saveAttachmentDownloadJob' | 'updateConversations' >; const clientOnlyWritableOverrides: ClientOverridesType = { ...clientOnlyWritable, saveAttachmentDownloadJob, - saveMessage, - saveMessages, updateConversations, }; @@ -595,41 +594,76 @@ async function searchMessages({ async function saveMessage( data: ReadonlyDeep, - options: { - jobToInsert?: Readonly; + { + forceSave, + jobToInsert, + ourAci, + postSaveUpdates, + }: { forceSave?: boolean; + jobToInsert?: Readonly; ourAci: AciString; + postSaveUpdates: () => Promise; } ): Promise { const id = await writableChannel.saveMessage(_cleanMessageData(data), { - ...options, - jobToInsert: options.jobToInsert && formatJobForInsert(options.jobToInsert), + forceSave, + jobToInsert: jobToInsert && formatJobForInsert(jobToInsert), + ourAci, }); softAssert( // Older messages still have `UUIDv4` so don't log errors when encountering // it. - (!options.forceSave && isValidUuid(id)) || isValidUuidV7(id), + (!forceSave && isValidUuid(id)) || isValidUuidV7(id), 'saveMessage: messageId is not a UUID' ); - void updateExpiringMessagesService(); - void tapToViewMessagesDeletionService.update(); + drop(postSaveUpdates?.()); return id; } async function saveMessages( arrayOfMessages: ReadonlyArray>, - options: { forceSave?: boolean; ourAci: AciString } + { + forceSave, + ourAci, + postSaveUpdates, + }: { + forceSave?: boolean; + ourAci: AciString; + postSaveUpdates: () => Promise; + } ): Promise> { const result = await writableChannel.saveMessages( arrayOfMessages.map(message => _cleanMessageData(message)), - options + { forceSave, ourAci } ); - void updateExpiringMessagesService(); - void tapToViewMessagesDeletionService.update(); + drop(postSaveUpdates?.()); + + return result; +} + +async function saveMessagesIndividually( + arrayOfMessages: ReadonlyArray>, + { + forceSave, + ourAci, + postSaveUpdates, + }: { + forceSave?: boolean; + ourAci: AciString; + postSaveUpdates: () => Promise; + } +): Promise<{ failedIndices: Array }> { + const result = await writableChannel.saveMessagesIndividually( + arrayOfMessages, + { forceSave, ourAci } + ); + + drop(postSaveUpdates?.()); return result; } @@ -637,7 +671,10 @@ async function saveMessages( async function removeMessage( id: string, options: { - singleProtoJobQueue: SingleProtoJobQueue; + cleanupMessages: ( + messages: ReadonlyArray, + options: { fromSync?: boolean } + ) => Promise; fromSync?: boolean; } ): Promise { @@ -647,9 +684,8 @@ async function removeMessage( // it needs to delete all associated on-disk files along with the database delete. if (message) { await writableChannel.removeMessage(id); - await cleanupMessages([message], { - ...options, - markCallHistoryDeleted: DataWriter.markCallHistoryDeleted, + await options.cleanupMessages([message], { + fromSync: options.fromSync, }); } } @@ -659,7 +695,10 @@ export async function deleteAndCleanup( logId: string, options: { fromSync?: boolean; - singleProtoJobQueue: SingleProtoJobQueue; + cleanupMessages: ( + messages: ReadonlyArray, + options: { fromSync?: boolean } + ) => Promise; } ): Promise { const ids = messages.map(message => message.id); @@ -668,9 +707,8 @@ export async function deleteAndCleanup( await writableChannel.removeMessages(ids); log.info(`deleteAndCleanup/${logId}: Cleanup for ${ids.length} messages...`); - await cleanupMessages(messages, { - ...options, - markCallHistoryDeleted: DataWriter.markCallHistoryDeleted, + await options.cleanupMessages(messages, { + fromSync: Boolean(options.fromSync), }); log.info(`deleteAndCleanup/${logId}: Complete`); @@ -680,13 +718,15 @@ async function removeMessages( messageIds: ReadonlyArray, options: { fromSync?: boolean; - singleProtoJobQueue: SingleProtoJobQueue; + cleanupMessages: ( + messages: ReadonlyArray, + options: { fromSync?: boolean } + ) => Promise; } ): Promise { const messages = await readableChannel.getMessagesById(messageIds); - await cleanupMessages(messages, { - ...options, - markCallHistoryDeleted: DataWriter.markCallHistoryDeleted, + await options.cleanupMessages(messages, { + fromSync: Boolean(options.fromSync), }); await writableChannel.removeMessages(messageIds); } @@ -743,15 +783,18 @@ async function getConversationRangeCenteredOnMessage( async function removeMessagesInConversation( conversationId: string, { + cleanupMessages, + fromSync, logId, receivedAt, - singleProtoJobQueue, - fromSync, }: { + cleanupMessages: ( + messages: ReadonlyArray, + options: { fromSync?: boolean | undefined } + ) => Promise; fromSync?: boolean; logId: string; receivedAt?: number; - singleProtoJobQueue: SingleProtoJobQueue; } ): Promise { let messages; @@ -776,7 +819,7 @@ async function removeMessagesInConversation( } // eslint-disable-next-line no-await-in-loop - await deleteAndCleanup(messages, logId, { fromSync, singleProtoJobQueue }); + await deleteAndCleanup(messages, logId, { fromSync, cleanupMessages }); } while (messages.length > 0); } diff --git a/ts/sql/Interface.ts b/ts/sql/Interface.ts index 450cd147f..3894d3191 100644 --- a/ts/sql/Interface.ts +++ b/ts/sql/Interface.ts @@ -44,7 +44,6 @@ import type { } from '../types/GroupSendEndorsements'; import type { SyncTaskType } from '../util/syncTasks'; import type { AttachmentBackupJobType } from '../types/AttachmentBackup'; -import type { SingleProtoJobQueue } from '../jobs/singleProtoJobQueue'; export type ReadableDB = Database & { __readable_db: never }; export type WritableDB = ReadableDB & { __writable_db: never }; @@ -749,23 +748,6 @@ type WritableInterface = { replaceAllEndorsementsForGroup: (data: GroupSendEndorsementsData) => void; deleteAllEndorsementsForGroup: (groupId: string) => void; - saveMessage: ( - data: ReadonlyDeep, - options: { - jobToInsert?: StoredJob; - forceSave?: boolean; - ourAci: AciString; - } - ) => string; - saveMessages: ( - arrayOfMessages: ReadonlyArray>, - options: { forceSave?: boolean; ourAci: AciString } - ) => Array; - saveMessagesIndividually: ( - arrayOfMessages: ReadonlyArray>, - options: { forceSave?: boolean; ourAci: AciString } - ) => { failedIndices: Array }; - getUnreadByConversationAndMarkRead: (options: { conversationId: string; includeStoryReplies: boolean; @@ -1047,6 +1029,22 @@ export type ServerWritableDirectInterface = WritableInterface & { updateConversation: (data: ConversationType) => void; removeConversation: (id: Array | string) => void; + saveMessage: ( + data: ReadonlyDeep, + options: { + jobToInsert?: StoredJob; + forceSave?: boolean; + ourAci: AciString; + } + ) => string; + saveMessages: ( + arrayOfMessages: ReadonlyArray>, + options: { forceSave?: boolean; ourAci: AciString } + ) => Array; + saveMessagesIndividually: ( + arrayOfMessages: ReadonlyArray>, + options: { forceSave?: boolean; ourAci: AciString } + ) => { failedIndices: Array }; removeMessage: (id: string) => void; removeMessages: (ids: ReadonlyArray) => void; @@ -1134,18 +1132,49 @@ export type ClientOnlyWritableInterface = ClientInterfaceWrap<{ removeConversation: (id: string) => void; flushUpdateConversationBatcher: () => void; + saveMessage: ( + data: ReadonlyDeep, + options: { + jobToInsert?: StoredJob; + forceSave?: boolean; + ourAci: AciString; + postSaveUpdates: () => Promise; + } + ) => string; + saveMessages: ( + arrayOfMessages: ReadonlyArray>, + options: { + forceSave?: boolean; + ourAci: AciString; + postSaveUpdates: () => Promise; + } + ) => Array; + saveMessagesIndividually: ( + arrayOfMessages: ReadonlyArray>, + options: { + forceSave?: boolean; + ourAci: AciString; + postSaveUpdates: () => Promise; + } + ) => { failedIndices: Array }; removeMessage: ( id: string, options: { fromSync?: boolean; - singleProtoJobQueue: SingleProtoJobQueue; + cleanupMessages: ( + messages: ReadonlyArray, + options: { fromSync?: boolean | undefined } + ) => Promise; } ) => void; removeMessages: ( ids: ReadonlyArray, options: { fromSync?: boolean; - singleProtoJobQueue: SingleProtoJobQueue; + cleanupMessages: ( + messages: ReadonlyArray, + options: { fromSync?: boolean | undefined } + ) => Promise; } ) => void; @@ -1170,10 +1199,13 @@ export type ClientOnlyWritableInterface = ClientInterfaceWrap<{ removeMessagesInConversation: ( conversationId: string, options: { + cleanupMessages: ( + messages: ReadonlyArray, + options: { fromSync?: boolean | undefined } + ) => Promise; fromSync?: boolean; logId: string; receivedAt?: number; - singleProtoJobQueue: SingleProtoJobQueue; } ) => void; removeOtherData: () => void; diff --git a/ts/state/ducks/composer.ts b/ts/state/ducks/composer.ts index 1a611145f..3a8226f5e 100644 --- a/ts/state/ducks/composer.ts +++ b/ts/state/ducks/composer.ts @@ -69,7 +69,7 @@ import { resolveAttachmentDraftData } from '../../util/resolveAttachmentDraftDat import { resolveDraftAttachmentOnDisk } from '../../util/resolveDraftAttachmentOnDisk'; import { shouldShowInvalidMessageToast } from '../../util/shouldShowInvalidMessageToast'; import { writeDraftAttachment } from '../../util/writeDraftAttachment'; -import { __DEPRECATED$getMessageById } from '../../messages/getMessageById'; +import { getMessageById } from '../../messages/getMessageById'; import { canReply, isNormalBubble } from '../selectors/message'; import { getAuthorId } from '../../messages/helpers'; import { getConversationSelector } from '../selectors/conversations'; @@ -730,9 +730,7 @@ export function setQuoteByMessageId( return; } - const message = messageId - ? await __DEPRECATED$getMessageById(messageId, 'setQuoteByMessageId') - : undefined; + const message = messageId ? await getMessageById(messageId) : undefined; const state = getState(); if ( diff --git a/ts/state/ducks/conversations.ts b/ts/state/ducks/conversations.ts index b2cb2cbf7..b5414de0a 100644 --- a/ts/state/ducks/conversations.ts +++ b/ts/state/ducks/conversations.ts @@ -126,11 +126,12 @@ import { isDirectConversation, isGroup, isGroupV2, + isMe, } from '../../util/whatTypeOfConversation'; import { missingCaseError } from '../../util/missingCaseError'; import { viewSyncJobQueue } from '../../jobs/viewSyncJobQueue'; import { ReadStatus } from '../../messages/MessageReadStatus'; -import { isIncoming, processBodyRanges } from '../selectors/message'; +import { isIncoming, isStory, processBodyRanges } from '../selectors/message'; import { getActiveCall, getActiveCallState } from '../selectors/calling'; import { sendDeleteForEveryoneMessage } from '../../util/sendDeleteForEveryoneMessage'; import type { ShowToastActionType } from './toast'; @@ -149,7 +150,7 @@ import { buildUpdateAttributesChange, initiateMigrationToGroupV2 as doInitiateMigrationToGroupV2, } from '../../groups'; -import { __DEPRECATED$getMessageById } from '../../messages/getMessageById'; +import { getMessageById } from '../../messages/getMessageById'; import type { PanelRenderType, PanelRequestType } from '../../types/Panels'; import type { ConversationQueueJobData } from '../../jobs/conversationJobQueue'; import { isOlderThan } from '../../util/timestamp'; @@ -184,7 +185,10 @@ import type { ChangeNavTabActionType } from './nav'; import { CHANGE_NAV_TAB, NavTab, actions as navActions } from './nav'; import { sortByMessageOrder } from '../../types/ForwardDraft'; import { getAddedByForOurPendingInvitation } from '../../util/getAddedByForOurPendingInvitation'; -import { getConversationIdForLogging } from '../../util/idForLogging'; +import { + getConversationIdForLogging, + getMessageIdForLogging, +} from '../../util/idForLogging'; import { singleProtoJobQueue } from '../../jobs/singleProtoJobQueue'; import MessageSender from '../../textsecure/SendMessage'; import { @@ -204,6 +208,18 @@ import { markCallHistoryReadInConversation } from './callHistory'; import type { CapabilitiesType } from '../../textsecure/WebAPI'; import { actions as searchActions } from './search'; import type { SearchActionType } from './search'; +import { getNotificationTextForMessage } from '../../util/getNotificationTextForMessage'; +import { doubleCheckMissingQuoteReference as doDoubleCheckMissingQuoteReference } from '../../util/doubleCheckMissingQuoteReference'; +import { queueAttachmentDownloadsForMessage } from '../../util/queueAttachmentDownloads'; +import { markAttachmentAsCorrupted as doMarkAttachmentAsCorrupted } from '../../messageModifiers/AttachmentDownloads'; +import { + isSent, + SendActionType, + sendStateReducer, +} from '../../messages/MessageSendState'; +import { markFailed } from '../../test-node/util/messageFailures'; +import { cleanupMessages, postSaveUpdates } from '../../util/cleanup'; +import { MessageModel } from '../../models/messages'; // State export type DBConversationType = ReadonlyDeep<{ @@ -1410,10 +1426,7 @@ function markMessageRead( return; } - const message = await __DEPRECATED$getMessageById( - messageId, - 'markMessageRead' - ); + const message = await getMessageById(messageId); if (!message) { throw new Error(`markMessageRead: failed to load message ${messageId}`); } @@ -1767,10 +1780,7 @@ function deleteMessages({ await Promise.all( messageIds.map( async (messageId): Promise => { - const message = await __DEPRECATED$getMessageById( - messageId, - 'deleteMessages' - ); + const message = await getMessageById(messageId); if (!message) { throw new Error(`deleteMessages: Message ${messageId} missing!`); } @@ -1805,7 +1815,7 @@ function deleteMessages({ } await DataWriter.removeMessages(messageIds, { - singleProtoJobQueue, + cleanupMessages, }); popPanelForConversation()(dispatch, getState, undefined); @@ -1930,9 +1940,7 @@ function setMessageToEdit( return; } - const message = ( - await __DEPRECATED$getMessageById(messageId, 'setMessageToEdit') - )?.attributes; + const message = (await getMessageById(messageId))?.attributes; if (!message) { return; } @@ -2025,10 +2033,7 @@ function generateNewGroupLink( * replace it with an actual action that fits in with the redux approach. */ export const markViewed = (messageId: string): void => { - const message = window.MessageCache.__DEPRECATED$getById( - messageId, - 'markViewed' - ); + const message = window.MessageCache.getById(messageId); if (!message) { throw new Error(`markViewed: Message ${messageId} missing!`); } @@ -2051,7 +2056,9 @@ export const markViewed = (messageId: string): void => { ); senderAci = sourceServiceId; - const convoAttributes = message.getConversation()?.attributes; + const convo = window.ConversationController.get( + message.get('conversationId') + ); const conversationId = message.get('conversationId'); drop( conversationJobQueue.add({ @@ -2065,8 +2072,8 @@ export const markViewed = (messageId: string): void => { senderE164, senderAci, timestamp, - isDirectConversation: convoAttributes - ? isDirectConversation(convoAttributes) + isDirectConversation: convo + ? isDirectConversation(convo.attributes) : true, }, ], @@ -2292,16 +2299,14 @@ function kickOffAttachmentDownload( options: Readonly<{ messageId: string }> ): ThunkAction { return async dispatch => { - const message = await __DEPRECATED$getMessageById( - options.messageId, - 'kickOffAttachmentDownload' - ); + const message = await getMessageById(options.messageId); if (!message) { throw new Error( `kickOffAttachmentDownload: Message ${options.messageId} missing!` ); } - const didUpdateValues = await message.queueAttachmentDownloads( + const didUpdateValues = await queueAttachmentDownloadsForMessage( + message, AttachmentDownloadUrgency.IMMEDIATE ); @@ -2309,6 +2314,7 @@ function kickOffAttachmentDownload( drop( DataWriter.saveMessage(message.attributes, { ourAci: window.textsecure.storage.user.getCheckedAci(), + postSaveUpdates, }) ); } @@ -2329,10 +2335,7 @@ function cancelAttachmentDownload({ NoopActionType > { return async dispatch => { - const message = await __DEPRECATED$getMessageById( - messageId, - 'cancelAttachmentDownload' - ); + const message = await getMessageById(messageId); if (!message) { log.warn(`cancelAttachmentDownload: Message ${messageId} missing!`); } else { @@ -2344,7 +2347,10 @@ function cancelAttachmentDownload({ }); const ourAci = window.textsecure.storage.user.getCheckedAci(); - await DataWriter.saveMessage(message.attributes, { ourAci }); + await DataWriter.saveMessage(message.attributes, { + ourAci, + postSaveUpdates, + }); } // A click kicks off downloads for every attachment in a message, so cancel does too @@ -2370,16 +2376,7 @@ function markAttachmentAsCorrupted( options: AttachmentOptions ): ThunkAction { return async dispatch => { - const message = await __DEPRECATED$getMessageById( - options.messageId, - 'markAttachmentAsCorrupted' - ); - if (!message) { - throw new Error( - `markAttachmentAsCorrupted: Message ${options.messageId} missing!` - ); - } - message.markAttachmentAsCorrupted(options.attachment); + await doMarkAttachmentAsCorrupted(options.messageId, options.attachment); dispatch({ type: 'NOOP', @@ -2392,10 +2389,7 @@ function openGiftBadge( messageId: string ): ThunkAction { return async dispatch => { - const message = await __DEPRECATED$getMessageById( - messageId, - 'openGiftBadge' - ); + const message = await getMessageById(messageId); if (!message) { throw new Error(`openGiftBadge: Message ${messageId} missing!`); } @@ -2415,14 +2409,106 @@ function retryMessageSend( messageId: string ): ThunkAction { return async dispatch => { - const message = await __DEPRECATED$getMessageById( - messageId, - 'retryMessageSend' - ); + const message = await getMessageById(messageId); if (!message) { throw new Error(`retryMessageSend: Message ${messageId} missing!`); } - await message.retrySend(); + + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + const conversation = window.ConversationController.get( + message.attributes.conversationId + )!; + + let currentConversationRecipients: Set | undefined; + + const { storyDistributionListId } = message.attributes; + + if (storyDistributionListId) { + const storyDistribution = + await DataReader.getStoryDistributionWithMembers( + storyDistributionListId + ); + + if (!storyDistribution) { + markFailed(message); + return; + } + + currentConversationRecipients = new Set( + storyDistribution.members + .map(serviceId => window.ConversationController.get(serviceId)?.id) + .filter(isNotNil) + ); + } else { + currentConversationRecipients = conversation.getMemberConversationIds(); + } + + // Determine retry recipients and get their most up-to-date addressing information + const oldSendStateByConversationId = + message.get('sendStateByConversationId') || {}; + + const newSendStateByConversationId = { ...oldSendStateByConversationId }; + for (const [conversationId, sendState] of Object.entries( + oldSendStateByConversationId + )) { + if (isSent(sendState.status)) { + continue; + } + + const recipient = window.ConversationController.get(conversationId); + if ( + !recipient || + (!currentConversationRecipients.has(conversationId) && + !isMe(recipient.attributes)) + ) { + continue; + } + + newSendStateByConversationId[conversationId] = sendStateReducer( + sendState, + { + type: SendActionType.ManuallyRetried, + updatedAt: Date.now(), + } + ); + } + + message.set({ sendStateByConversationId: newSendStateByConversationId }); + + if (isStory(message.attributes)) { + await conversationJobQueue.add( + { + type: conversationQueueJobEnum.enum.Story, + conversationId: conversation.id, + messageIds: [message.id], + // using the group timestamp, which will differ from the 1:1 timestamp + timestamp: message.attributes.timestamp, + }, + async jobToInsert => { + await DataWriter.saveMessage(message.attributes, { + jobToInsert, + ourAci: window.textsecure.storage.user.getCheckedAci(), + postSaveUpdates, + }); + } + ); + } else { + await conversationJobQueue.add( + { + type: conversationQueueJobEnum.enum.NormalMessage, + conversationId: conversation.id, + messageId: message.id, + revision: conversation.get('revision'), + }, + async jobToInsert => { + await DataWriter.saveMessage(message.attributes, { + jobToInsert, + ourAci: window.textsecure.storage.user.getCheckedAci(), + postSaveUpdates, + }); + } + ); + } dispatch({ type: 'NOOP', @@ -2435,15 +2521,12 @@ export function copyMessageText( messageId: string ): ThunkAction { return async dispatch => { - const message = await __DEPRECATED$getMessageById( - messageId, - 'copyMessageText' - ); + const message = await getMessageById(messageId); if (!message) { throw new Error(`copy: Message ${messageId} missing!`); } - const body = message.getNotificationText(); + const body = getNotificationTextForMessage(message.attributes); clipboard.writeText(body); dispatch({ @@ -2457,10 +2540,7 @@ export function retryDeleteForEveryone( messageId: string ): ThunkAction { return async dispatch => { - const message = await __DEPRECATED$getMessageById( - messageId, - 'retryDeleteForEveryone' - ); + const message = await getMessageById(messageId); if (!message) { throw new Error(`retryDeleteForEveryone: Message ${messageId} missing!`); } @@ -2472,7 +2552,9 @@ export function retryDeleteForEveryone( } try { - const conversation = message.getConversation(); + const conversation = window.ConversationController.get( + message.get('conversationId') + ); if (!conversation) { throw new Error( `retryDeleteForEveryone: Conversation for ${messageId} missing!` @@ -2489,7 +2571,7 @@ export function retryDeleteForEveryone( }; log.info( - `retryDeleteForEveryone: Adding job for message ${message.idForLogging()}!` + `retryDeleteForEveryone: Adding job for message ${getMessageIdForLogging(message.attributes)}!` ); await conversationJobQueue.add(jobData); @@ -3247,12 +3329,7 @@ function pushPanelForConversation( const message = conversations.messagesLookup[messageId] || - ( - await __DEPRECATED$getMessageById( - messageId, - 'pushPanelForConversation' - ) - )?.attributes; + (await getMessageById(messageId))?.attributes; if (!message) { throw new Error( 'pushPanelForConversation: could not find message for MessageDetails' @@ -3328,17 +3405,16 @@ function deleteMessagesForEveryone( await Promise.all( messageIds.map(async messageId => { try { - const message = window.MessageCache.__DEPRECATED$getById( - messageId, - 'deleteMessagesForEveryone' - ); + const message = window.MessageCache.getById(messageId); if (!message) { throw new Error( `deleteMessageForEveryone: Message ${messageId} missing!` ); } - const conversation = message.getConversation(); + const conversation = window.ConversationController.get( + message.get('conversationId') + ); if (!conversation) { throw new Error('deleteMessageForEveryone: no conversation'); } @@ -3834,11 +3910,7 @@ function loadRecentMediaItems( // Cache these messages in memory to ensure Lightbox can find them messages.forEach(message => { - window.MessageCache.__DEPRECATED$register( - message.id, - message, - 'loadRecentMediaItems' - ); + window.MessageCache.register(new MessageModel(message)); }); let index = 0; @@ -4042,10 +4114,7 @@ export function saveAttachmentFromMessage( providedAttachment?: AttachmentType ): ThunkAction { return async (dispatch, getState) => { - const message = await __DEPRECATED$getMessageById( - messageId, - 'saveAttachmentFromMessage' - ); + const message = await getMessageById(messageId); if (!message) { throw new Error( `saveAttachmentFromMessage: Message ${messageId} missing!` @@ -4138,10 +4207,7 @@ export function scrollToMessage( throw new Error('scrollToMessage: No conversation found'); } - const message = await __DEPRECATED$getMessageById( - messageId, - 'scrollToMessage' - ); + const message = await getMessageById(messageId); if (!message) { throw new Error(`scrollToMessage: failed to load message ${messageId}`); } @@ -4155,12 +4221,7 @@ export function scrollToMessage( let isInMemory = true; - if ( - !window.MessageCache.__DEPRECATED$getById( - messageId, - 'scrollToMessage/notInMemory' - ) - ) { + if (!window.MessageCache.getById(messageId)) { isInMemory = false; } @@ -4591,10 +4652,7 @@ function onConversationOpened( log.info(`${logId}: Updating newly opened conversation state`); if (messageId) { - const message = await __DEPRECATED$getMessageById( - messageId, - 'onConversationOpened' - ); + const message = await getMessageById(messageId); if (message) { drop(conversation.loadAndScroll(messageId)); @@ -4733,12 +4791,9 @@ function showArchivedConversations(): ShowArchivedConversationsActionType { } function doubleCheckMissingQuoteReference(messageId: string): NoopActionType { - const message = window.MessageCache.__DEPRECATED$getById( - messageId, - 'doubleCheckMissingQuoteReference' - ); + const message = window.MessageCache.getById(messageId); if (message) { - void message.doubleCheckMissingQuoteReference(); + drop(doDoubleCheckMissingQuoteReference(message)); } return { diff --git a/ts/state/ducks/globalModals.ts b/ts/state/ducks/globalModals.ts index a5b4e1f10..ab67deacb 100644 --- a/ts/state/ducks/globalModals.ts +++ b/ts/state/ducks/globalModals.ts @@ -49,6 +49,7 @@ import type { CallLinkType } from '../../types/CallLink'; import type { LocalizerType } from '../../types/I18N'; import { linkCallRoute } from '../../util/signalRoutes'; import type { StartCallData } from '../../components/ConfirmLeaveCallModal'; +import { getMessageById } from '../../messages/getMessageById'; // State @@ -624,12 +625,13 @@ function toggleForwardMessagesModal( if (payload.type === ForwardMessagesModalType.Forward) { messageDrafts = await Promise.all( payload.messageIds.map(async messageId => { - const messageAttributes = await window.MessageCache.resolveAttributes( - 'toggleForwardMessagesModal', - messageId - ); - - const { attachments = [] } = messageAttributes; + const message = await getMessageById(messageId); + if (!message) { + throw new Error( + 'toggleForwardMessagesModal: failed to find target message' + ); + } + const { attachments = [] } = message.attributes; if (!attachments.every(isDownloaded)) { dispatch( @@ -641,7 +643,7 @@ function toggleForwardMessagesModal( const messagePropsSelector = getMessagePropsSelector(state); const conversationSelector = getConversationSelector(state); - const messageProps = messagePropsSelector(messageAttributes); + const messageProps = messagePropsSelector(message.attributes); const messageDraft = toMessageForwardDraft( messageProps, conversationSelector @@ -944,12 +946,14 @@ function showEditHistoryModal( messageId: string ): ThunkAction { return async dispatch => { - const messageAttributes = await window.MessageCache.resolveAttributes( - 'showEditHistoryModal', - messageId + const message = await getMessageById(messageId); + if (!message) { + throw new Error('showEditHistoryModal: failed to find target message'); + } + + const nextEditHistoryMessages = copyOverMessageAttributesIntoEditHistory( + message.attributes ); - const nextEditHistoryMessages = - copyOverMessageAttributesIntoEditHistory(messageAttributes); if (!nextEditHistoryMessages) { log.warn('showEditHistoryModal: no edit history for message'); diff --git a/ts/state/ducks/lightbox.ts b/ts/state/ducks/lightbox.ts index 06a35ca45..dab683ba3 100644 --- a/ts/state/ducks/lightbox.ts +++ b/ts/state/ducks/lightbox.ts @@ -17,7 +17,7 @@ import type { ShowToastActionType } from './toast'; import type { StateType as RootStateType } from '../reducer'; import * as log from '../../logging/log'; -import { __DEPRECATED$getMessageById } from '../../messages/getMessageById'; +import { getMessageById } from '../../messages/getMessageById'; import type { ReadonlyMessageAttributesType } from '../../model-types.d'; import { isGIF } from '../../types/Attachment'; import { @@ -40,6 +40,8 @@ import { import { showStickerPackPreview } from './globalModals'; import { useBoundActions } from '../../hooks/useBoundActions'; import { DataReader } from '../../sql/Client'; +import { getMessageIdForLogging } from '../../util/idForLogging'; +import { markViewOnceMessageViewed } from '../../services/MessageUpdater'; // eslint-disable-next-line local-rules/type-alias-readonlydeep export type LightboxStateType = @@ -156,10 +158,7 @@ function showLightboxForViewOnceMedia( return async dispatch => { log.info('showLightboxForViewOnceMedia: attempting to display message'); - const message = await __DEPRECATED$getMessageById( - messageId, - 'showLightboxForViewOnceMedia' - ); + const message = await getMessageById(messageId); if (!message) { throw new Error( `showLightboxForViewOnceMedia: Message ${messageId} missing!` @@ -168,20 +167,20 @@ function showLightboxForViewOnceMedia( if (!isTapToView(message.attributes)) { throw new Error( - `showLightboxForViewOnceMedia: Message ${message.idForLogging()} is not a tap to view message` + `showLightboxForViewOnceMedia: Message ${getMessageIdForLogging(message.attributes)} is not a tap to view message` ); } - if (message.isErased()) { + if (message.get('isErased')) { throw new Error( - `showLightboxForViewOnceMedia: Message ${message.idForLogging()} is already erased` + `showLightboxForViewOnceMedia: Message ${getMessageIdForLogging(message.attributes)} is already erased` ); } const firstAttachment = (message.get('attachments') || [])[0]; if (!firstAttachment || !firstAttachment.path) { throw new Error( - `showLightboxForViewOnceMedia: Message ${message.idForLogging()} had no first attachment with path` + `showLightboxForViewOnceMedia: Message ${getMessageIdForLogging(message.attributes)} had no first attachment with path` ); } @@ -195,7 +194,7 @@ function showLightboxForViewOnceMedia( path: tempPath, }; - await message.markViewOnceMessageViewed(); + await markViewOnceMessageViewed(message); const { contentType } = tempAttachment; @@ -253,10 +252,7 @@ function showLightbox(opts: { return async (dispatch, getState) => { const { attachment, messageId } = opts; - const message = await __DEPRECATED$getMessageById( - messageId, - 'showLightbox' - ); + const message = await getMessageById(messageId); if (!message) { throw new Error(`showLightbox: Message ${messageId} missing!`); } @@ -393,10 +389,7 @@ function showLightboxForAdjacentMessage( const [media] = lightbox.media; const { id: messageId, receivedAt, sentAt } = media.message; - const message = await __DEPRECATED$getMessageById( - messageId, - 'showLightboxForAdjacentMessage' - ); + const message = await getMessageById(messageId); if (!message) { log.warn('showLightboxForAdjacentMessage: original message is gone'); dispatch({ diff --git a/ts/state/ducks/mediaGallery.ts b/ts/state/ducks/mediaGallery.ts index a30612e4d..8530ffe15 100644 --- a/ts/state/ducks/mediaGallery.ts +++ b/ts/state/ducks/mediaGallery.ts @@ -33,6 +33,7 @@ import type { MIMEType } from '../../types/MIME'; import type { MediaItemType } from '../../types/MediaItem'; import type { StateType as RootStateType } from '../reducer'; import type { MessageAttributesType } from '../../model-types'; +import { MessageModel } from '../../models/messages'; type MediaItemMessage = ReadonlyDeep<{ attachments: Array; @@ -141,13 +142,13 @@ function _getMediaItemMessage( } function _cleanVisualAttachments( - rawMedia: ReadonlyDeep> + rawMedia: ReadonlyDeep> ): ReadonlyArray { return rawMedia .flatMap(message => { let index = 0; - return (message.attachments || []).map( + return (message.get('attachments') || []).map( (attachment: AttachmentType): MediaType | undefined => { if ( !attachment.path || @@ -168,7 +169,7 @@ function _cleanVisualAttachments( contentType: attachment.contentType, index, attachment, - message: _getMediaItemMessage(message), + message: _getMediaItemMessage(message.attributes), }; index += 1; @@ -181,11 +182,11 @@ function _cleanVisualAttachments( } function _cleanFileAttachments( - rawDocuments: ReadonlyDeep> + rawDocuments: ReadonlyDeep> ): ReadonlyArray { return rawDocuments .map(message => { - const attachments = message.attachments || []; + const attachments = message.get('attachments') || []; const attachment = attachments[0]; if (!attachment) { return; @@ -196,7 +197,7 @@ function _cleanFileAttachments( index: 0, attachment, message: { - ..._getMediaItemMessage(message), + ..._getMediaItemMessage(message.attributes), attachments: [attachment], }, }; @@ -205,27 +206,25 @@ function _cleanFileAttachments( } async function _upgradeMessages( - messages: ReadonlyArray -): Promise> { + messages: ReadonlyArray +): Promise { // We upgrade these messages so they are sure to have thumbnails - const upgraded = await Promise.all( + await Promise.all( messages.map(async message => { try { - return await window.MessageCache.upgradeSchema( + await window.MessageCache.upgradeSchema( message, VERSION_NEEDED_FOR_DISPLAY ); } catch (error) { log.warn( '_upgradeMessages: Failed to upgrade message ' + - `${getMessageIdForLogging(message)}: ${Errors.toLogFormat(error)}` + `${getMessageIdForLogging(message.attributes)}: ${Errors.toLogFormat(error)}` ); return undefined; } }) ); - - return upgraded.filter(isNotNil); } function initialLoad( @@ -242,24 +241,28 @@ function initialLoad( payload: { loading: true }, }); - const rawMedia = await DataReader.getOlderMessagesByConversation({ - conversationId, - includeStoryReplies: false, - limit: FETCH_CHUNK_COUNT, - requireVisualMediaAttachments: true, - storyId: undefined, - }); - const rawDocuments = await DataReader.getOlderMessagesByConversation({ - conversationId, - includeStoryReplies: false, - limit: FETCH_CHUNK_COUNT, - requireFileAttachments: true, - storyId: undefined, - }); + const rawMedia = ( + await DataReader.getOlderMessagesByConversation({ + conversationId, + includeStoryReplies: false, + limit: FETCH_CHUNK_COUNT, + requireVisualMediaAttachments: true, + storyId: undefined, + }) + ).map(item => window.MessageCache.register(new MessageModel(item))); + const rawDocuments = ( + await DataReader.getOlderMessagesByConversation({ + conversationId, + includeStoryReplies: false, + limit: FETCH_CHUNK_COUNT, + requireFileAttachments: true, + storyId: undefined, + }) + ).map(item => window.MessageCache.register(new MessageModel(item))); - const upgraded = await _upgradeMessages(rawMedia); - const media = _cleanVisualAttachments(upgraded); + await _upgradeMessages(rawMedia); + const media = _cleanVisualAttachments(rawMedia); const documents = _cleanFileAttachments(rawDocuments); dispatch({ @@ -305,19 +308,22 @@ function loadMoreMedia( const { sentAt, receivedAt, id: messageId } = oldestLoadedMedia.message; - const rawMedia = await DataReader.getOlderMessagesByConversation({ - conversationId, - includeStoryReplies: false, - limit: FETCH_CHUNK_COUNT, - messageId, - receivedAt, - requireVisualMediaAttachments: true, - sentAt, - storyId: undefined, - }); + const rawMedia = ( + await DataReader.getOlderMessagesByConversation({ + conversationId, + includeStoryReplies: false, + limit: FETCH_CHUNK_COUNT, + messageId, + receivedAt, + requireVisualMediaAttachments: true, + sentAt, + storyId: undefined, + }) + ).map(item => window.MessageCache.register(new MessageModel(item))); - const upgraded = await _upgradeMessages(rawMedia); - const media = _cleanVisualAttachments(upgraded); + await _upgradeMessages(rawMedia); + + const media = _cleanVisualAttachments(rawMedia); dispatch({ type: LOAD_MORE_MEDIA, @@ -367,16 +373,18 @@ function loadMoreDocuments( const { sentAt, receivedAt, id: messageId } = oldestLoadedDocument.message; - const rawDocuments = await DataReader.getOlderMessagesByConversation({ - conversationId, - includeStoryReplies: false, - limit: FETCH_CHUNK_COUNT, - messageId, - receivedAt, - requireFileAttachments: true, - sentAt, - storyId: undefined, - }); + const rawDocuments = ( + await DataReader.getOlderMessagesByConversation({ + conversationId, + includeStoryReplies: false, + limit: FETCH_CHUNK_COUNT, + messageId, + receivedAt, + requireFileAttachments: true, + sentAt, + storyId: undefined, + }) + ).map(item => window.MessageCache.register(new MessageModel(item))); const documents = _cleanFileAttachments(rawDocuments); @@ -500,8 +508,12 @@ export function reducer( const oldestLoadedMedia = state.media[0]; const oldestLoadedDocument = state.documents[0]; - const newMedia = _cleanVisualAttachments([message]); - const newDocuments = _cleanFileAttachments([message]); + const newMedia = _cleanVisualAttachments([ + window.MessageCache.register(new MessageModel(message)), + ]); + const newDocuments = _cleanFileAttachments([ + window.MessageCache.register(new MessageModel(message)), + ]); let { documents, haveOldestDocument, haveOldestMedia, media } = state; diff --git a/ts/state/ducks/stories.ts b/ts/state/ducks/stories.ts index c1cf73b7a..2a2d4579e 100644 --- a/ts/state/ducks/stories.ts +++ b/ts/state/ducks/stories.ts @@ -36,7 +36,7 @@ import { blockSendUntilConversationsAreVerified } from '../../util/blockSendUnti import { deleteStoryForEveryone as doDeleteStoryForEveryone } from '../../util/deleteStoryForEveryone'; import { deleteGroupStoryReplyForEveryone as doDeleteGroupStoryReplyForEveryone } from '../../util/deleteGroupStoryReplyForEveryone'; import { enqueueReactionForSend } from '../../reactions/enqueueReactionForSend'; -import { __DEPRECATED$getMessageById } from '../../messages/getMessageById'; +import { getMessageById } from '../../messages/getMessageById'; import { markOnboardingStoryAsRead } from '../../util/markOnboardingStoryAsRead'; import { markViewed } from '../../services/MessageUpdater'; import { queueAttachmentDownloads } from '../../util/queueAttachmentDownloads'; @@ -69,7 +69,7 @@ import { conversationQueueJobEnum, } from '../../jobs/conversationJobQueue'; import { ReceiptType } from '../../types/Receipt'; -import { singleProtoJobQueue } from '../../jobs/singleProtoJobQueue'; +import { cleanupMessages, postSaveUpdates } from '../../util/cleanup'; export type StoryDataType = ReadonlyDeep< { @@ -286,7 +286,7 @@ function deleteGroupStoryReply( messageId: string ): ThunkAction { return async dispatch => { - await DataWriter.removeMessage(messageId, { singleProtoJobQueue }); + await DataWriter.removeMessage(messageId, { cleanupMessages }); dispatch({ type: STORY_REPLY_DELETED, payload: messageId, @@ -382,10 +382,7 @@ function markStoryRead( return; } - const message = await __DEPRECATED$getMessageById( - messageId, - 'markStoryRead' - ); + const message = await getMessageById(messageId); if (!message) { log.warn(`markStoryRead: no message found ${messageId}`); @@ -427,6 +424,7 @@ function markStoryRead( drop( DataWriter.saveMessage(message.attributes, { ourAci: window.textsecure.storage.user.getCheckedAci(), + postSaveUpdates, }) ); @@ -524,10 +522,7 @@ function queueStoryDownload( return; } - const message = await __DEPRECATED$getMessageById( - storyId, - 'queueStoryDownload' - ); + const message = await getMessageById(storyId); if (message) { // We want to ensure that we re-hydrate the story reply context with the @@ -1402,10 +1397,7 @@ function removeAllContactStories( const messages = ( await Promise.all( messageIds.map(async messageId => { - const message = await __DEPRECATED$getMessageById( - messageId, - 'removeAllContactStories' - ); + const message = await getMessageById(messageId); if (!message) { log.warn(`${logId}: no message found ${messageId}`); @@ -1419,7 +1411,7 @@ function removeAllContactStories( log.info(`${logId}: removing ${messages.length} stories`); - await DataWriter.removeMessages(messageIds, { singleProtoJobQueue }); + await DataWriter.removeMessages(messageIds, { cleanupMessages }); dispatch({ type: 'NOOP', diff --git a/ts/state/smart/ForwardMessagesModal.tsx b/ts/state/smart/ForwardMessagesModal.tsx index 3d73eefbc..467c08e8f 100644 --- a/ts/state/smart/ForwardMessagesModal.tsx +++ b/ts/state/smart/ForwardMessagesModal.tsx @@ -23,7 +23,7 @@ import { useLinkPreviewActions } from '../ducks/linkPreviews'; import { SmartCompositionTextArea } from './CompositionTextArea'; import { useToastActions } from '../ducks/toast'; import { isDownloaded } from '../../types/Attachment'; -import { __DEPRECATED$getMessageById } from '../../messages/getMessageById'; +import { getMessageById } from '../../messages/getMessageById'; import { strictAssert } from '../../util/assert'; import type { ForwardMessageData, @@ -117,10 +117,7 @@ function SmartForwardMessagesModalInner({ if (draft.originalMessageId == null) { return { draft, originalMessage: null }; } - const message = await __DEPRECATED$getMessageById( - draft.originalMessageId, - 'doForwardMessages' - ); + const message = await getMessageById(draft.originalMessageId); strictAssert(message, 'no message found'); return { draft, diff --git a/ts/test-electron/MessageReceipts_test.ts b/ts/test-electron/MessageReceipts_test.ts index 65f23e321..3f3d95ffc 100644 --- a/ts/test-electron/MessageReceipts_test.ts +++ b/ts/test-electron/MessageReceipts_test.ts @@ -17,6 +17,7 @@ import { messageReceiptTypeSchema, } from '../messageModifiers/MessageReceipts'; import { ReadStatus } from '../messages/MessageReadStatus'; +import { postSaveUpdates } from '../util/cleanup'; describe('MessageReceipts', () => { let ourAci: AciString; @@ -81,6 +82,7 @@ describe('MessageReceipts', () => { await DataWriter.saveMessage(messageAttributes, { forceSave: true, ourAci, + postSaveUpdates, }); await Promise.all([ @@ -158,6 +160,7 @@ describe('MessageReceipts', () => { await DataWriter.saveMessage(messageAttributes, { forceSave: true, ourAci, + postSaveUpdates, }); await DataWriter.saveEditedMessage(messageAttributes, ourAci, { conversationId: messageAttributes.conversationId, diff --git a/ts/test-electron/backup/helpers.ts b/ts/test-electron/backup/helpers.ts index 9f547be6e..b34aab532 100644 --- a/ts/test-electron/backup/helpers.ts +++ b/ts/test-electron/backup/helpers.ts @@ -27,6 +27,7 @@ import { generateAci, generatePni } from '../../types/ServiceId'; import { DataReader, DataWriter } from '../../sql/Client'; import { getRandomBytes } from '../../Crypto'; import * as Bytes from '../../Bytes'; +import { postSaveUpdates } from '../../util/cleanup'; export const OUR_ACI = generateAci(); export const OUR_PNI = generatePni(); @@ -213,7 +214,11 @@ export async function asymmetricRoundtripHarness( try { const targetOutputFile = path.join(outDir, 'backup.bin'); - await DataWriter.saveMessages(before, { forceSave: true, ourAci: OUR_ACI }); + await DataWriter.saveMessages(before, { + forceSave: true, + ourAci: OUR_ACI, + postSaveUpdates, + }); await backupsService.exportToDisk(targetOutputFile, options.backupLevel); diff --git a/ts/test-electron/backup/integration_test.ts b/ts/test-electron/backup/integration_test.ts index 4fb049504..8521261e0 100644 --- a/ts/test-electron/backup/integration_test.ts +++ b/ts/test-electron/backup/integration_test.ts @@ -17,7 +17,6 @@ import { clearData } from './helpers'; import { loadAllAndReinitializeRedux } from '../../services/allLoaders'; import { backupsService, BackupType } from '../../services/backups'; import { initialize as initializeExpiringMessageService } from '../../services/expiringMessagesDeletion'; -import { singleProtoJobQueue } from '../../jobs/singleProtoJobQueue'; import { DataWriter } from '../../sql/Client'; const { BACKUP_INTEGRATION_DIR } = process.env; @@ -42,7 +41,7 @@ class MemoryStream extends InputStream { describe('backup/integration', () => { before(async () => { - await initializeExpiringMessageService(singleProtoJobQueue); + await initializeExpiringMessageService(); }); beforeEach(async () => { diff --git a/ts/test-electron/models/conversations_test.ts b/ts/test-electron/models/conversations_test.ts index 22d516fd1..c50d25e15 100644 --- a/ts/test-electron/models/conversations_test.ts +++ b/ts/test-electron/models/conversations_test.ts @@ -2,12 +2,14 @@ // SPDX-License-Identifier: AGPL-3.0-only import { assert } from 'chai'; -import { v4 as generateUuid } from 'uuid'; +import { v7 as generateUuid } from 'uuid'; import { DataWriter } from '../../sql/Client'; import { SendStatus } from '../../messages/MessageSendState'; import { IMAGE_PNG } from '../../types/MIME'; import { generateAci, generatePni } from '../../types/ServiceId'; +import { postSaveUpdates } from '../../util/cleanup'; +import { MessageModel } from '../../models/messages'; describe('Conversations', () => { async function resetConversationController(): Promise { @@ -40,6 +42,7 @@ describe('Conversations', () => { profileSharing: true, version: 0, expireTimerVersion: 1, + lastMessage: 'starting value', }); await window.textsecure.storage.user.setCredentials({ @@ -59,7 +62,7 @@ describe('Conversations', () => { // Creating a fake message const now = Date.now(); - let message = new window.Whisper.Message({ + let message = new MessageModel({ attachments: [], body: 'bananas', conversationId: conversation.id, @@ -84,12 +87,9 @@ describe('Conversations', () => { await DataWriter.saveMessage(message.attributes, { forceSave: true, ourAci, + postSaveUpdates, }); - message = window.MessageCache.__DEPRECATED$register( - message.id, - message, - 'test' - ); + message = window.MessageCache.register(message); await DataWriter.updateConversation(conversation.attributes); await conversation.updateLastMessage(); diff --git a/ts/test-electron/models/messages_test.ts b/ts/test-electron/models/messages_test.ts index 39152dd22..b04301fff 100644 --- a/ts/test-electron/models/messages_test.ts +++ b/ts/test-electron/models/messages_test.ts @@ -9,7 +9,7 @@ import type { AttachmentType } from '../../types/Attachment'; import type { CallbackResultType } from '../../textsecure/Types.d'; import type { ConversationModel } from '../../models/conversations'; import type { MessageAttributesType } from '../../model-types.d'; -import type { MessageModel } from '../../models/messages'; +import { MessageModel } from '../../models/messages'; import type { RawBodyRange } from '../../types/BodyRange'; import type { StorageAccessType } from '../../types/Storage.d'; import type { WebAPIType } from '../../textsecure/WebAPI'; @@ -30,6 +30,9 @@ import { TEXT_ATTACHMENT, VIDEO_MP4, } from '../../types/MIME'; +import { getNotificationDataForMessage } from '../../util/getNotificationDataForMessage'; +import { getNotificationTextForMessage } from '../../util/getNotificationTextForMessage'; +import { send } from '../../messages/send'; describe('Message', () => { const STORAGE_KEYS_TO_RESTORE: Array = [ @@ -54,17 +57,22 @@ describe('Message', () => { const ourServiceId = generateAci(); function createMessage(attrs: Partial): MessageModel { - return new window.Whisper.Message({ - id: generateUuid(), - ...attrs, - received_at: Date.now(), - } as MessageAttributesType); + const id = generateUuid(); + return window.MessageCache.register( + new MessageModel({ + id, + ...attrs, + sent_at: Date.now(), + received_at: Date.now(), + } as MessageAttributesType) + ); } function createMessageAndGetNotificationData(attrs: { [key: string]: unknown; }) { - return createMessage(attrs).getNotificationData(); + const message = createMessage(attrs); + return getNotificationDataForMessage(message.attributes); } before(async () => { @@ -184,7 +192,7 @@ describe('Message', () => { editMessage: undefined, }); - await message.send({ + await send(message, { promise, targetTimestamp: message.get('timestamp'), }); @@ -207,7 +215,7 @@ describe('Message', () => { const message = createMessage({ type: 'outgoing', source }); const promise = Promise.reject(new Error('foo bar')); - await message.send({ + await send(message, { promise, targetTimestamp: message.get('timestamp'), }); @@ -224,7 +232,7 @@ describe('Message', () => { errors: [new Error('baz qux')], }; const promise = Promise.reject(result); - await message.send({ + await send(message, { promise, targetTimestamp: message.get('timestamp'), }); @@ -675,18 +683,20 @@ describe('Message', () => { describe('getNotificationText', () => { it("returns a notification's text", async () => { + const message = createMessage({ + conversationId: ( + await window.ConversationController.getOrCreateAndWait( + generateUuid(), + 'private' + ) + ).id, + type: 'incoming', + source, + body: 'hello world', + }); + assert.strictEqual( - createMessage({ - conversationId: ( - await window.ConversationController.getOrCreateAndWait( - generateUuid(), - 'private' - ) - ).id, - type: 'incoming', - source, - body: 'hello world', - }).getNotificationText(), + getNotificationTextForMessage(message.attributes), 'hello world' ); }); @@ -698,24 +708,24 @@ describe('Message', () => { return false; }, }); - + const message = createMessage({ + conversationId: ( + await window.ConversationController.getOrCreateAndWait( + generateUuid(), + 'private' + ) + ).id, + type: 'incoming', + source, + attachments: [ + { + contentType: IMAGE_PNG, + size: 0, + }, + ], + }); assert.strictEqual( - createMessage({ - conversationId: ( - await window.ConversationController.getOrCreateAndWait( - generateUuid(), - 'private' - ) - ).id, - type: 'incoming', - source, - attachments: [ - { - contentType: IMAGE_PNG, - size: 0, - }, - ], - }).getNotificationText(), + getNotificationTextForMessage(message.attributes), '📷 Photo' ); }); @@ -728,23 +738,25 @@ describe('Message', () => { }, }); + const message = createMessage({ + conversationId: ( + await window.ConversationController.getOrCreateAndWait( + generateUuid(), + 'private' + ) + ).id, + type: 'incoming', + source, + attachments: [ + { + contentType: IMAGE_PNG, + size: 0, + }, + ], + }); + assert.strictEqual( - createMessage({ - conversationId: ( - await window.ConversationController.getOrCreateAndWait( - generateUuid(), - 'private' - ) - ).id, - type: 'incoming', - source, - attachments: [ - { - contentType: IMAGE_PNG, - size: 0, - }, - ], - }).getNotificationText(), + getNotificationTextForMessage(message.attributes), 'Photo' ); }); diff --git a/ts/test-electron/services/AttachmentDownloadManager_test.ts b/ts/test-electron/services/AttachmentDownloadManager_test.ts index 07be8b898..2407a2a39 100644 --- a/ts/test-electron/services/AttachmentDownloadManager_test.ts +++ b/ts/test-electron/services/AttachmentDownloadManager_test.ts @@ -22,6 +22,7 @@ import { type AttachmentType, AttachmentVariant } from '../../types/Attachment'; import { strictAssert } from '../../util/assert'; import { AttachmentDownloadSource } from '../../sql/Interface'; import { getAttachmentCiphertextLength } from '../../AttachmentCrypto'; +import { postSaveUpdates } from '../../util/cleanup'; function composeJob({ messageId, @@ -119,6 +120,7 @@ describe('AttachmentDownloadManager/JobManager', () => { { ourAci: 'ourAci' as AciString, forceSave: true, + postSaveUpdates, } ); await downloadManager?.addJob({ diff --git a/ts/test-electron/services/MessageCache_test.ts b/ts/test-electron/services/MessageCache_test.ts index 21e7bec8c..1e26cf56a 100644 --- a/ts/test-electron/services/MessageCache_test.ts +++ b/ts/test-electron/services/MessageCache_test.ts @@ -3,8 +3,6 @@ import { assert } from 'chai'; import { v4 as uuid } from 'uuid'; -import type { MessageAttributesType } from '../../model-types.d'; -import { DataReader, DataWriter } from '../../sql/Client'; import { MessageModel } from '../../models/messages'; import { strictAssert } from '../../util/assert'; @@ -56,25 +54,33 @@ describe('MessageCache', () => { type: 'outgoing', }); - message1 = mc.__DEPRECATED$register(message1.id, message1, 'test'); - message2 = mc.__DEPRECATED$register(message2.id, message2, 'test'); + message1 = mc.register(message1); + message2 = mc.register(message2); // We deliberately register this message twice for testing. - message2 = mc.__DEPRECATED$register(message2.id, message2, 'test'); - mc.__DEPRECATED$register(message3.id, message3, 'test'); + message2 = mc.register(message2); + mc.register(message3); const filteredMessage = await mc.findBySentAt(1234, () => true); - assert.deepEqual(filteredMessage, message1.attributes, 'first'); + assert.deepEqual( + filteredMessage?.attributes, + message1.attributes, + 'first' + ); - mc.__DEPRECATED$unregister(message1.id); + mc.unregister(message1.id); const filteredMessage2 = await mc.findBySentAt(1234, () => true); - assert.deepEqual(filteredMessage2, message2.attributes, 'second'); + assert.deepEqual( + filteredMessage2?.attributes, + message2.attributes, + 'second' + ); }); }); - describe('__DEPRECATED$register: syncing with backbone', () => { + describe('register: syncing with backbone', () => { it('backbone to redux', () => { const message1 = new MessageModel({ conversationId: 'xyz', @@ -85,65 +91,33 @@ describe('MessageCache', () => { timestamp: Date.now(), type: 'outgoing', }); - const messageFromController = window.MessageCache.__DEPRECATED$register( - message1.id, - message1, - 'test' - ); + const messageFromController = window.MessageCache.register(message1); assert.strictEqual( message1, messageFromController, - 'same objects from mc.__DEPRECATED$register' + 'same objects from mc.register' ); - const messageById = window.MessageCache.__DEPRECATED$getById( - message1.id, - 'test' + const messageInCache = window.MessageCache.getById(message1.id); + assert.strictEqual( + message1, + messageInCache, + 'same objects from mc.getById' ); - - assert.strictEqual(message1, messageById, 'same objects from mc.getById'); - - const messageInCache = window.MessageCache.accessAttributes(message1.id); - strictAssert(messageInCache, 'no message found'); assert.deepEqual( message1.attributes, - messageInCache, + messageInCache?.attributes, 'same attributes as in cache' ); message1.set({ body: 'test2' }); assert.equal(message1.attributes.body, 'test2', 'message model updated'); assert.equal( - messageById?.attributes.body, + messageInCache?.attributes.body, 'test2', 'old reference from messageById was updated' ); - assert.equal( - messageInCache.body, - 'test1', - 'old cache reference not updated' - ); - - const newMessageById = window.MessageCache.__DEPRECATED$getById( - message1.id, - 'test' - ); - assert.deepEqual( - message1.attributes, - newMessageById?.attributes, - 'same attributes from mc.getById (2)' - ); - - const newMessageInCache = window.MessageCache.accessAttributes( - message1.id - ); - strictAssert(newMessageInCache, 'no message found'); - assert.deepEqual( - message1.attributes, - newMessageInCache, - 'same attributes as in cache (2)' - ); }); it('redux to backbone (working with models)', () => { @@ -157,271 +131,28 @@ describe('MessageCache', () => { type: 'outgoing', }); - window.MessageCache.toMessageAttributes(message.attributes); + const messageFromController = window.MessageCache.register(message); - const messageFromController = window.MessageCache.__DEPRECATED$register( - message.id, - message, - 'test' - ); - - assert.notStrictEqual( + assert.strictEqual( message, messageFromController, - 'mc.__DEPRECATED$register returns existing but it is not the same reference' + 'mc.register returns existing but it is not the same reference' ); assert.deepEqual( message.attributes, messageFromController.attributes, - 'mc.__DEPRECATED$register returns existing and is the same attributes' + 'mc.register returns existing and is the same attributes' ); - messageFromController.set({ body: 'test2' }); + message.set({ body: 'test2' }); - assert.notEqual( - message.get('body'), - messageFromController.get('body'), - 'new model is not equal to old model' - ); - - const messageInCache = window.MessageCache.accessAttributes(message.id); + const messageInCache = window.MessageCache.getById(message.id); strictAssert(messageInCache, 'no message found'); assert.equal( messageFromController.get('body'), - messageInCache.body, + messageInCache.get('body'), 'new update is in cache' ); - - assert.isUndefined( - messageFromController.get('storyReplyContext'), - 'storyReplyContext is undefined' - ); - - window.MessageCache.setAttributes({ - messageId: message.id, - messageAttributes: { - storyReplyContext: { - attachment: undefined, - authorAci: undefined, - messageId: 'test123', - }, - }, - skipSaveToDatabase: true, - }); - - // This works because we refresh the model whenever an attribute changes - // but this should log a warning. - assert.equal( - messageFromController.get('storyReplyContext')?.messageId, - 'test123', - 'storyReplyContext was updated (stale model)' - ); - - const newMessageFromController = - window.MessageCache.__DEPRECATED$register(message.id, message, 'test'); - - assert.equal( - newMessageFromController.get('storyReplyContext')?.messageId, - 'test123', - 'storyReplyContext was updated (not stale)' - ); - }); - - it('redux to backbone (working with attributes)', () => { - it('sets the attributes and returns a fresh copy', () => { - const mc = new MessageCache(); - - const messageAttributes: MessageAttributesType = { - conversationId: uuid(), - id: uuid(), - received_at: 1, - sent_at: Date.now(), - timestamp: Date.now(), - type: 'incoming', - }; - - const messageModel = mc.__DEPRECATED$register( - messageAttributes.id, - messageAttributes, - 'test/updateAttributes' - ); - - assert.deepEqual( - messageAttributes, - messageModel.attributes, - 'initial attributes matches message model' - ); - - const proposedStoryReplyContext = { - attachment: undefined, - authorAci: undefined, - messageId: 'test123', - }; - - assert.notDeepEqual( - messageModel.attributes.storyReplyContext, - proposedStoryReplyContext, - 'attributes were changed outside of the message model' - ); - - mc.setAttributes({ - messageId: messageAttributes.id, - messageAttributes: { - storyReplyContext: proposedStoryReplyContext, - }, - skipSaveToDatabase: true, - }); - - const nextMessageAttributes = mc.accessAttributesOrThrow( - 'test', - messageAttributes.id - ); - - assert.notDeepEqual( - messageAttributes, - nextMessageAttributes, - 'initial attributes are stale' - ); - assert.notDeepEqual( - messageAttributes.storyReplyContext, - proposedStoryReplyContext, - 'initial attributes are stale 2' - ); - - assert.deepEqual( - nextMessageAttributes.storyReplyContext, - proposedStoryReplyContext, - 'fresh attributes match what was proposed' - ); - assert.notStrictEqual( - nextMessageAttributes.storyReplyContext, - proposedStoryReplyContext, - 'fresh attributes are not the same reference as proposed attributes' - ); - - assert.deepEqual( - messageModel.attributes, - nextMessageAttributes, - 'model was updated' - ); - - assert.equal( - messageModel.get('storyReplyContext')?.messageId, - 'test123', - 'storyReplyContext in model is set correctly' - ); - }); - }); - }); - - describe('accessAttributes', () => { - it('gets the attributes if they exist', () => { - const mc = new MessageCache(); - - const messageAttributes: MessageAttributesType = { - conversationId: uuid(), - id: uuid(), - received_at: 1, - sent_at: Date.now(), - timestamp: Date.now(), - type: 'incoming', - }; - - mc.toMessageAttributes(messageAttributes); - - const accessAttributes = mc.accessAttributes(messageAttributes.id); - - assert.deepEqual( - accessAttributes, - messageAttributes, - 'attributes returned have the same values' - ); - assert.notStrictEqual( - accessAttributes, - messageAttributes, - 'attributes returned are not the same references' - ); - - const undefinedMessage = mc.accessAttributes(uuid()); - assert.isUndefined(undefinedMessage, 'access did not find message'); - }); - }); - - describe('setAttributes', () => { - it('saves the new attributes to the database', async () => { - const mc = new MessageCache(); - - const ourAci = generateAci(); - const id = uuid(); - const messageAttributes: MessageAttributesType = { - conversationId: uuid(), - id, - received_at: 1, - sent_at: Date.now(), - timestamp: Date.now(), - type: 'incoming', - }; - await DataWriter.saveMessage(messageAttributes, { - forceSave: true, - ourAci, - }); - - const changes = { - received_at: 2, - }; - const newAttributes = { - ...messageAttributes, - ...changes, - }; - - mc.toMessageAttributes(messageAttributes); - - await mc.setAttributes({ - messageId: id, - messageAttributes: changes, - skipSaveToDatabase: false, - }); - - const messageFromDatabase = await DataReader.getMessageById(id); - - assert.deepEqual(newAttributes, messageFromDatabase); - }); - }); - - describe('accessAttributesOrThrow', () => { - it('accesses the attributes or throws if they do not exist', () => { - const mc = new MessageCache(); - - const messageAttributes: MessageAttributesType = { - conversationId: uuid(), - id: uuid(), - received_at: 1, - sent_at: Date.now(), - timestamp: Date.now(), - type: 'incoming', - }; - - mc.toMessageAttributes(messageAttributes); - - const accessAttributes = mc.accessAttributesOrThrow( - 'tests.1', - messageAttributes.id - ); - - assert.deepEqual( - accessAttributes, - messageAttributes, - 'attributes returned have the same values' - ); - assert.notStrictEqual( - accessAttributes, - messageAttributes, - 'attributes returned are not the same references' - ); - - assert.throws(() => { - mc.accessAttributesOrThrow('tests.2', uuid()); - }); }); }); }); diff --git a/ts/test-electron/sql/conversationSummary_test.ts b/ts/test-electron/sql/conversationSummary_test.ts index fec1be2af..acc36b4df 100644 --- a/ts/test-electron/sql/conversationSummary_test.ts +++ b/ts/test-electron/sql/conversationSummary_test.ts @@ -9,6 +9,7 @@ import { generateAci } from '../../types/ServiceId'; import { DurationInSeconds } from '../../util/durations'; import type { MessageAttributesType } from '../../model-types.d'; +import { postSaveUpdates } from '../../util/cleanup'; const { _getAllMessages, getConversationMessageStats } = DataReader; const { removeAll, saveMessages } = DataWriter; @@ -56,6 +57,7 @@ describe('sql/conversationSummary', () => { await saveMessages([message1, message2, message3], { forceSave: true, ourAci, + postSaveUpdates, }); assert.lengthOf(await _getAllMessages(), 3); @@ -109,6 +111,7 @@ describe('sql/conversationSummary', () => { await saveMessages([message1, message2, message3], { forceSave: true, ourAci, + postSaveUpdates, }); assert.lengthOf(await _getAllMessages(), 3); @@ -199,6 +202,7 @@ describe('sql/conversationSummary', () => { { forceSave: true, ourAci, + postSaveUpdates, } ); @@ -306,6 +310,7 @@ describe('sql/conversationSummary', () => { { forceSave: true, ourAci, + postSaveUpdates, } ); @@ -355,6 +360,7 @@ describe('sql/conversationSummary', () => { await saveMessages([message1, message2], { forceSave: true, ourAci, + postSaveUpdates, }); assert.lengthOf(await _getAllMessages(), 2); @@ -404,6 +410,7 @@ describe('sql/conversationSummary', () => { await saveMessages([message1, message2], { forceSave: true, ourAci, + postSaveUpdates, }); assert.lengthOf(await _getAllMessages(), 2); @@ -446,6 +453,7 @@ describe('sql/conversationSummary', () => { await saveMessages([message1, message2], { forceSave: true, ourAci, + postSaveUpdates, }); assert.lengthOf(await _getAllMessages(), 2); @@ -490,6 +498,7 @@ describe('sql/conversationSummary', () => { await saveMessages([message1, message2], { forceSave: true, ourAci, + postSaveUpdates, }); assert.lengthOf(await _getAllMessages(), 2); @@ -549,6 +558,7 @@ describe('sql/conversationSummary', () => { await saveMessages([message1, message2], { forceSave: true, ourAci, + postSaveUpdates, }); assert.lengthOf(await _getAllMessages(), 2); diff --git a/ts/test-electron/sql/fullTextSearch_test.ts b/ts/test-electron/sql/fullTextSearch_test.ts index 1dc7923c5..79e99fa9c 100644 --- a/ts/test-electron/sql/fullTextSearch_test.ts +++ b/ts/test-electron/sql/fullTextSearch_test.ts @@ -8,6 +8,7 @@ import { DataReader, DataWriter } from '../../sql/Client'; import { generateAci } from '../../types/ServiceId'; import type { MessageAttributesType } from '../../model-types.d'; +import { postSaveUpdates } from '../../util/cleanup'; const { _getAllMessages, searchMessages } = DataReader; const { removeAll, saveMessages, saveMessage } = DataWriter; @@ -54,6 +55,7 @@ describe('sql/searchMessages', () => { await saveMessages([message1, message2, message3], { forceSave: true, ourAci, + postSaveUpdates, }); assert.lengthOf(await _getAllMessages(), 3); @@ -63,7 +65,7 @@ describe('sql/searchMessages', () => { assert.strictEqual(searchResults[0].id, message2.id); message3.body = 'message 3 - unique string'; - await saveMessage(message3, { ourAci }); + await saveMessage(message3, { ourAci, postSaveUpdates }); const searchResults2 = await searchMessages({ query: 'unique' }); assert.lengthOf(searchResults2, 2); @@ -110,6 +112,7 @@ describe('sql/searchMessages', () => { await saveMessages([message1, message2, message3], { forceSave: true, ourAci, + postSaveUpdates, }); assert.lengthOf(await _getAllMessages(), 3); @@ -119,7 +122,7 @@ describe('sql/searchMessages', () => { assert.strictEqual(searchResults[0].id, message1.id); message1.body = 'message 3 - unique string'; - await saveMessage(message3, { ourAci }); + await saveMessage(message3, { ourAci, postSaveUpdates }); const searchResults2 = await searchMessages({ query: 'unique' }); assert.lengthOf(searchResults2, 1); @@ -165,6 +168,7 @@ describe('sql/searchMessages', () => { await saveMessages([message1, message2, message3], { forceSave: true, ourAci, + postSaveUpdates, }); assert.lengthOf(await _getAllMessages(), 3); @@ -174,7 +178,7 @@ describe('sql/searchMessages', () => { assert.strictEqual(searchResults[0].id, message1.id); message1.body = 'message 3 - unique string'; - await saveMessage(message3, { ourAci }); + await saveMessage(message3, { ourAci, postSaveUpdates }); const searchResults2 = await searchMessages({ query: 'unique' }); assert.lengthOf(searchResults2, 1); @@ -211,6 +215,7 @@ describe('sql/searchMessages', () => { await saveMessages([message1, message2], { forceSave: true, ourAci, + postSaveUpdates, }); assert.lengthOf(await _getAllMessages(), 2); @@ -251,6 +256,7 @@ describe('sql/searchMessages/withMentions', () => { await saveMessages(messages, { forceSave: true, ourAci, + postSaveUpdates, }); return messages; } diff --git a/ts/test-electron/sql/getCallHistoryMessageByCallId_test.ts b/ts/test-electron/sql/getCallHistoryMessageByCallId_test.ts index d2380ea48..9518a5ea1 100644 --- a/ts/test-electron/sql/getCallHistoryMessageByCallId_test.ts +++ b/ts/test-electron/sql/getCallHistoryMessageByCallId_test.ts @@ -8,6 +8,7 @@ import { DataReader, DataWriter } from '../../sql/Client'; import { generateAci } from '../../types/ServiceId'; import type { MessageAttributesType } from '../../model-types.d'; +import { postSaveUpdates } from '../../util/cleanup'; const { _getAllMessages, getCallHistoryMessageByCallId } = DataReader; const { removeAll, saveMessages } = DataWriter; @@ -37,6 +38,7 @@ describe('sql/getCallHistoryMessageByCallId', () => { await saveMessages([callHistoryMessage], { forceSave: true, ourAci, + postSaveUpdates, }); const allMessages = await _getAllMessages(); diff --git a/ts/test-electron/sql/getMessagesBetween_test.ts b/ts/test-electron/sql/getMessagesBetween_test.ts index c49306dc2..a9a88cbdd 100644 --- a/ts/test-electron/sql/getMessagesBetween_test.ts +++ b/ts/test-electron/sql/getMessagesBetween_test.ts @@ -8,6 +8,7 @@ import { generateAci } from '../../types/ServiceId'; import { DataReader, DataWriter } from '../../sql/Client'; import type { MessageAttributesType } from '../../model-types'; +import { postSaveUpdates } from '../../util/cleanup'; const { _getAllMessages, getMessagesBetween } = DataReader; const { saveMessages, _removeAllMessages } = DataWriter; @@ -45,6 +46,7 @@ describe('sql/getMessagesBetween', () => { await saveMessages([message1, message2, message3, message4, message5], { forceSave: true, ourAci, + postSaveUpdates, }); assert.lengthOf(await _getAllMessages(), 5); @@ -93,6 +95,7 @@ describe('sql/getMessagesBetween', () => { await saveMessages([message1, message2, message3, message5], { forceSave: true, ourAci, + postSaveUpdates, }); assert.lengthOf(await _getAllMessages(), 4); diff --git a/ts/test-electron/sql/getNearbyMessageFromDeletedSet_test.ts b/ts/test-electron/sql/getNearbyMessageFromDeletedSet_test.ts index 3b0343783..4750fff30 100644 --- a/ts/test-electron/sql/getNearbyMessageFromDeletedSet_test.ts +++ b/ts/test-electron/sql/getNearbyMessageFromDeletedSet_test.ts @@ -8,6 +8,7 @@ import { DataReader, DataWriter } from '../../sql/Client'; import { generateAci } from '../../types/ServiceId'; import type { MessageAttributesType } from '../../model-types'; +import { postSaveUpdates } from '../../util/cleanup'; const { _getAllMessages, getNearbyMessageFromDeletedSet } = DataReader; const { saveMessages, _removeAllMessages } = DataWriter; @@ -45,6 +46,7 @@ describe('sql/getNearbyMessageFromDeletedSet', () => { await saveMessages([message1, message2, message3, message4, message5], { forceSave: true, ourAci, + postSaveUpdates, }); assert.lengthOf(await _getAllMessages(), 5); diff --git a/ts/test-electron/sql/getRecentStoryReplies_test.ts b/ts/test-electron/sql/getRecentStoryReplies_test.ts index c0dc13792..d0e9588b3 100644 --- a/ts/test-electron/sql/getRecentStoryReplies_test.ts +++ b/ts/test-electron/sql/getRecentStoryReplies_test.ts @@ -8,6 +8,7 @@ import { DataReader, DataWriter } from '../../sql/Client'; import { generateAci } from '../../types/ServiceId'; import type { MessageAttributesType } from '../../model-types.d'; +import { postSaveUpdates } from '../../util/cleanup'; const { _getAllMessages, getRecentStoryReplies } = DataReader; const { removeAll, saveMessages } = DataWriter; @@ -91,6 +92,7 @@ describe('sql/getRecentStoryReplies', () => { { forceSave: true, ourAci, + postSaveUpdates, } ); diff --git a/ts/test-electron/sql/markRead_test.ts b/ts/test-electron/sql/markRead_test.ts index d77a74958..df1abf1b9 100644 --- a/ts/test-electron/sql/markRead_test.ts +++ b/ts/test-electron/sql/markRead_test.ts @@ -12,6 +12,7 @@ import { ReactionReadStatus } from '../../types/Reactions'; import { DurationInSeconds } from '../../util/durations'; import type { MessageAttributesType } from '../../model-types.d'; import { ReadStatus } from '../../messages/MessageReadStatus'; +import { postSaveUpdates } from '../../util/cleanup'; const { _getAllReactions, _getAllMessages, getTotalUnreadForConversation } = DataReader; @@ -126,6 +127,7 @@ describe('sql/markRead', () => { { forceSave: true, ourAci, + postSaveUpdates, } ); @@ -290,6 +292,7 @@ describe('sql/markRead', () => { { forceSave: true, ourAci, + postSaveUpdates, } ); @@ -392,6 +395,7 @@ describe('sql/markRead', () => { await saveMessages([message1, message2, message3, message4, message5], { forceSave: true, ourAci, + postSaveUpdates, }); assert.strictEqual( @@ -518,6 +522,7 @@ describe('sql/markRead', () => { { forceSave: true, ourAci, + postSaveUpdates, } ); assert.lengthOf(await _getAllMessages(), pad.length + 5); @@ -673,6 +678,7 @@ describe('sql/markRead', () => { await saveMessages([message1, message2, message3, message4, message5], { forceSave: true, ourAci, + postSaveUpdates, }); assert.lengthOf(await _getAllMessages(), 5); @@ -823,6 +829,7 @@ describe('sql/markRead', () => { await saveMessages([message1, message2, message3, message4], { forceSave: true, ourAci, + postSaveUpdates, }); assert.lengthOf(await _getAllMessages(), 4); diff --git a/ts/test-electron/sql/sendLog_test.ts b/ts/test-electron/sql/sendLog_test.ts index 2bea0e3e2..d820f7a00 100644 --- a/ts/test-electron/sql/sendLog_test.ts +++ b/ts/test-electron/sql/sendLog_test.ts @@ -7,7 +7,7 @@ import { v4 as generateUuid } from 'uuid'; import { DataReader, DataWriter } from '../../sql/Client'; import { generateAci } from '../../types/ServiceId'; import { constantTimeEqual, getRandomBytes } from '../../Crypto'; -import { singleProtoJobQueue } from '../../jobs/singleProtoJobQueue'; +import { cleanupMessages, postSaveUpdates } from '../../util/cleanup'; const { _getAllSentProtoMessageIds, @@ -128,7 +128,7 @@ describe('sql/sendLog', () => { timestamp, type: 'outgoing', }, - { forceSave: true, ourAci } + { forceSave: true, ourAci, postSaveUpdates } ); const bytes = getRandomBytes(128); @@ -152,7 +152,7 @@ describe('sql/sendLog', () => { assert.strictEqual(actual.timestamp, proto.timestamp); - await removeMessage(id, { singleProtoJobQueue }); + await removeMessage(id, { cleanupMessages }); assert.lengthOf(await getAllSentProtos(), 0); }); diff --git a/ts/test-electron/sql/stories_test.ts b/ts/test-electron/sql/stories_test.ts index ea33ee471..999ee854d 100644 --- a/ts/test-electron/sql/stories_test.ts +++ b/ts/test-electron/sql/stories_test.ts @@ -8,6 +8,7 @@ import { DataReader, DataWriter } from '../../sql/Client'; import { generateAci } from '../../types/ServiceId'; import type { MessageAttributesType } from '../../model-types.d'; +import { postSaveUpdates } from '../../util/cleanup'; const { _getAllMessages, getAllStories } = DataReader; const { removeAll, saveMessages } = DataWriter; @@ -80,6 +81,7 @@ describe('sql/stories', () => { await saveMessages([story1, story2, story3, story4, story5], { forceSave: true, ourAci, + postSaveUpdates, }); assert.lengthOf(await _getAllMessages(), 5); @@ -217,6 +219,7 @@ describe('sql/stories', () => { { forceSave: true, ourAci, + postSaveUpdates, } ); diff --git a/ts/test-electron/sql/timelineFetches_test.ts b/ts/test-electron/sql/timelineFetches_test.ts index e89e1e37f..84434916f 100644 --- a/ts/test-electron/sql/timelineFetches_test.ts +++ b/ts/test-electron/sql/timelineFetches_test.ts @@ -9,6 +9,7 @@ import { generateAci } from '../../types/ServiceId'; import type { MessageAttributesType } from '../../model-types.d'; import { ReadStatus } from '../../messages/MessageReadStatus'; +import { postSaveUpdates } from '../../util/cleanup'; const { _getAllMessages, @@ -86,6 +87,7 @@ describe('sql/timelineFetches', () => { await saveMessages([message1, message2, message3, message4, message5], { forceSave: true, ourAci, + postSaveUpdates, }); assert.lengthOf(await _getAllMessages(), 5); @@ -144,6 +146,7 @@ describe('sql/timelineFetches', () => { await saveMessages([message1, message2, message3], { forceSave: true, ourAci, + postSaveUpdates, }); assert.lengthOf(await _getAllMessages(), 3); @@ -199,6 +202,7 @@ describe('sql/timelineFetches', () => { await saveMessages([message1, message2, message3], { forceSave: true, ourAci, + postSaveUpdates, }); assert.lengthOf(await _getAllMessages(), 3); @@ -251,6 +255,7 @@ describe('sql/timelineFetches', () => { await saveMessages([message1, message2, message3], { forceSave: true, ourAci, + postSaveUpdates, }); assert.lengthOf(await _getAllMessages(), 3); @@ -305,6 +310,7 @@ describe('sql/timelineFetches', () => { await saveMessages([message1, message2, message3], { forceSave: true, ourAci, + postSaveUpdates, }); assert.lengthOf(await _getAllMessages(), 3); @@ -363,6 +369,7 @@ describe('sql/timelineFetches', () => { await saveMessages([message1, message2, message3], { forceSave: true, ourAci, + postSaveUpdates, }); assert.lengthOf(await _getAllMessages(), 3); @@ -442,6 +449,7 @@ describe('sql/timelineFetches', () => { await saveMessages([message1, message2, message3, message4, message5], { forceSave: true, ourAci, + postSaveUpdates, }); assert.lengthOf(await _getAllMessages(), 5); @@ -499,6 +507,7 @@ describe('sql/timelineFetches', () => { await saveMessages([message1, message2, message3], { forceSave: true, ourAci, + postSaveUpdates, }); assert.lengthOf(await _getAllMessages(), 3); @@ -552,6 +561,7 @@ describe('sql/timelineFetches', () => { await saveMessages([message1, message2, message3], { forceSave: true, ourAci, + postSaveUpdates, }); assert.lengthOf(await _getAllMessages(), 3); @@ -608,6 +618,7 @@ describe('sql/timelineFetches', () => { await saveMessages([message1, message2, message3], { forceSave: true, ourAci, + postSaveUpdates, }); assert.lengthOf(await _getAllMessages(), 3); @@ -662,6 +673,7 @@ describe('sql/timelineFetches', () => { await saveMessages([message1, message2, message3], { forceSave: true, ourAci, + postSaveUpdates, }); assert.lengthOf(await _getAllMessages(), 3); @@ -781,7 +793,7 @@ describe('sql/timelineFetches', () => { newestInStory, newest, ], - { forceSave: true, ourAci } + { forceSave: true, ourAci, postSaveUpdates } ); assert.lengthOf(await _getAllMessages(), 8); @@ -873,7 +885,11 @@ describe('sql/timelineFetches', () => { } ); - await saveMessages(formattedMessages, { forceSave: true, ourAci }); + await saveMessages(formattedMessages, { + forceSave: true, + ourAci, + postSaveUpdates, + }); assert.lengthOf(await _getAllMessages(), 4); diff --git a/ts/test-electron/state/ducks/stories_test.ts b/ts/test-electron/state/ducks/stories_test.ts index faa935717..e8ccf3e64 100644 --- a/ts/test-electron/state/ducks/stories_test.ts +++ b/ts/test-electron/state/ducks/stories_test.ts @@ -27,6 +27,8 @@ import { actions, getEmptyState } from '../../../state/ducks/stories'; import { noopAction } from '../../../state/ducks/noop'; import { reducer as rootReducer } from '../../../state/reducer'; import { dropNull } from '../../../util/dropNull'; +import { postSaveUpdates } from '../../../util/cleanup'; +import { MessageModel } from '../../../models/messages'; describe('both/state/ducks/stories', () => { const getEmptyRootState = () => ({ @@ -862,11 +864,7 @@ describe('both/state/ducks/stories', () => { const storyId = generateUuid(); const messageAttributes = getStoryMessage(storyId); - window.MessageCache.__DEPRECATED$register( - storyId, - messageAttributes, - 'test' - ); + window.MessageCache.register(new MessageModel(messageAttributes)); const dispatch = sinon.spy(); await queueStoryDownload(storyId)(dispatch, getEmptyRootState, null); @@ -888,11 +886,7 @@ describe('both/state/ducks/stories', () => { ], }; - window.MessageCache.__DEPRECATED$register( - storyId, - messageAttributes, - 'test' - ); + window.MessageCache.register(new MessageModel(messageAttributes)); const dispatch = sinon.spy(); await queueStoryDownload(storyId)(dispatch, getEmptyRootState, null); @@ -914,11 +908,7 @@ describe('both/state/ducks/stories', () => { ], }; - window.MessageCache.__DEPRECATED$register( - storyId, - messageAttributes, - 'test' - ); + window.MessageCache.register(new MessageModel(messageAttributes)); const dispatch = sinon.spy(); await queueStoryDownload(storyId)(dispatch, getEmptyRootState, null); @@ -941,6 +931,7 @@ describe('both/state/ducks/stories', () => { await DataWriter.saveMessage(messageAttributes, { forceSave: true, ourAci: generateAci(), + postSaveUpdates, }); const rootState = getEmptyRootState(); @@ -963,11 +954,7 @@ describe('both/state/ducks/stories', () => { }, }); - window.MessageCache.__DEPRECATED$register( - storyId, - messageAttributes, - 'test' - ); + window.MessageCache.register(new MessageModel(messageAttributes)); const dispatch = sinon.spy(); await queueStoryDownload(storyId)(dispatch, getState, null); @@ -1007,6 +994,7 @@ describe('both/state/ducks/stories', () => { await DataWriter.saveMessage(messageAttributes, { forceSave: true, ourAci: generateAci(), + postSaveUpdates, }); const rootState = getEmptyRootState(); @@ -1029,11 +1017,7 @@ describe('both/state/ducks/stories', () => { }, }); - window.MessageCache.__DEPRECATED$register( - storyId, - messageAttributes, - 'test' - ); + window.MessageCache.register(new MessageModel(messageAttributes)); const dispatch = sinon.spy(); await queueStoryDownload(storyId)(dispatch, getState, null); diff --git a/ts/test-electron/textsecure/KeyChangeListener_test.ts b/ts/test-electron/textsecure/KeyChangeListener_test.ts index dbbd4db08..aa62b6c63 100644 --- a/ts/test-electron/textsecure/KeyChangeListener_test.ts +++ b/ts/test-electron/textsecure/KeyChangeListener_test.ts @@ -12,7 +12,7 @@ import { SignalProtocolStore } from '../../SignalProtocolStore'; import type { ConversationModel } from '../../models/conversations'; import * as KeyChangeListener from '../../textsecure/KeyChangeListener'; import * as Bytes from '../../Bytes'; -import { singleProtoJobQueue } from '../../jobs/singleProtoJobQueue'; +import { cleanupMessages } from '../../util/cleanup'; describe('KeyChangeListener', () => { let oldNumberId: string | undefined; @@ -71,7 +71,7 @@ describe('KeyChangeListener', () => { afterEach(async () => { await DataWriter.removeMessagesInConversation(convo.id, { logId: ourServiceIdWithKeyChange, - singleProtoJobQueue, + cleanupMessages, }); await DataWriter.removeConversation(convo.id); @@ -109,7 +109,7 @@ describe('KeyChangeListener', () => { afterEach(async () => { await DataWriter.removeMessagesInConversation(groupConvo.id, { logId: ourServiceIdWithKeyChange, - singleProtoJobQueue, + cleanupMessages, }); await DataWriter.removeConversation(groupConvo.id); }); diff --git a/ts/test-electron/util/migrateMessageData_test.ts b/ts/test-electron/util/migrateMessageData_test.ts index b856dbf81..f1165f703 100644 --- a/ts/test-electron/util/migrateMessageData_test.ts +++ b/ts/test-electron/util/migrateMessageData_test.ts @@ -7,6 +7,7 @@ import { _migrateMessageData as migrateMessageData } from '../../messages/migrat import type { MessageAttributesType } from '../../model-types'; import { DataReader, DataWriter } from '../../sql/Client'; import { generateAci } from '../../types/ServiceId'; +import { postSaveUpdates } from '../../util/cleanup'; function composeMessage(timestamp: number): MessageAttributesType { return { @@ -39,6 +40,7 @@ describe('utils/migrateMessageData', async () => { await DataWriter.saveMessages(messages, { forceSave: true, ourAci: generateAci(), + postSaveUpdates, }); const result = await migrateMessageData({ diff --git a/ts/test-mock/pnp/pni_change_test.ts b/ts/test-mock/pnp/pni_change_test.ts index ff9d7b8f5..fed5743fe 100644 --- a/ts/test-mock/pnp/pni_change_test.ts +++ b/ts/test-mock/pnp/pni_change_test.ts @@ -274,7 +274,7 @@ describe('pnp/PNI Change', function (this: Mocha.Suite) { } }); - it('shows identity and phone number change on send to contact when e165 has changed owners', async () => { + it('shows identity and phone number change on send to contact when e164 has changed owners', async () => { const { desktop, phone } = bootstrap; const window = await app.getWindow(); diff --git a/ts/test-node/util/messageFailures.ts b/ts/test-node/util/messageFailures.ts new file mode 100644 index 000000000..885165855 --- /dev/null +++ b/ts/test-node/util/messageFailures.ts @@ -0,0 +1,159 @@ +// Copyright 2024 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +import { mapValues, pick } from 'lodash'; + +import type { CustomError } from '../../textsecure/Types'; + +import type { MessageAttributesType } from '../../model-types'; +import * as log from '../../logging/log'; +import * as Errors from '../../types/errors'; +import { + getChangesForPropAtTimestamp, + getPropForTimestamp, +} from '../../util/editHelpers'; +import { + isSent, + SendActionType, + sendStateReducer, + someRecipientSendStatus, +} from '../../messages/MessageSendState'; +import { isStory } from '../../messages/helpers'; +import { + notificationService, + NotificationType, +} from '../../services/notifications'; +import type { MessageModel } from '../../models/messages'; + +export async function saveErrorsOnMessage( + message: MessageModel, + providedErrors: Error | Array, + options: { skipSave?: boolean } = {} +): Promise { + const { skipSave } = options; + + let errors: Array; + + if (!(providedErrors instanceof Array)) { + errors = [providedErrors]; + } else { + errors = providedErrors; + } + + errors.forEach(e => { + log.error('Message.saveErrors:', Errors.toLogFormat(e)); + }); + errors = errors.map(e => { + // Note: in our environment, instanceof can be scary, so we have a backup check + // (Node.js vs Browser context). + // We check instanceof second because typescript believes that anything that comes + // through here must be an instance of Error, so e is 'never' after that check. + if ((e.message && e.stack) || e instanceof Error) { + return pick( + e, + 'name', + 'message', + 'code', + 'number', + 'identifier', + 'retryAfter', + 'data', + 'reason' + ) as Required; + } + return e; + }); + + message.set({ + errors: errors.concat(message.get('errors') || []), + }); + + if (!skipSave) { + await window.MessageCache.saveMessage(message); + } +} + +export function isReplayableError(e: Error): boolean { + return ( + e.name === 'MessageError' || + e.name === 'OutgoingMessageError' || + e.name === 'SendMessageNetworkError' || + e.name === 'SendMessageChallengeError' || + e.name === 'OutgoingIdentityKeyError' + ); +} + +/** + * Change any Pending send state to Failed. Note that this will not mark successful + * sends failed. + */ +export function markFailed( + message: MessageModel, + editMessageTimestamp?: number +): void { + const now = Date.now(); + + const targetTimestamp = editMessageTimestamp || message.get('timestamp'); + const sendStateByConversationId = getPropForTimestamp({ + log, + message: message.attributes, + prop: 'sendStateByConversationId', + targetTimestamp, + }); + + const newSendStateByConversationId = mapValues( + sendStateByConversationId || {}, + sendState => + sendStateReducer(sendState, { + type: SendActionType.Failed, + updatedAt: now, + }) + ); + + const updates = getChangesForPropAtTimestamp({ + log, + message: message.attributes, + prop: 'sendStateByConversationId', + targetTimestamp, + value: newSendStateByConversationId, + }); + if (updates) { + message.set(updates); + } + + notifyStorySendFailed(message); +} + +export function notifyStorySendFailed(message: MessageModel): void { + if (!isStory(message.attributes)) { + return; + } + + const { conversationId, id, timestamp } = message.attributes; + const conversation = window.ConversationController.get(conversationId); + + notificationService.add({ + conversationId, + storyId: id, + messageId: id, + senderTitle: conversation?.getTitle() ?? window.i18n('icu:Stories__mine'), + message: hasSuccessfulDelivery(message.attributes) + ? window.i18n('icu:Stories__failed-send--partial') + : window.i18n('icu:Stories__failed-send--full'), + isExpiringMessage: false, + sentAt: timestamp, + type: NotificationType.Message, + }); +} + +function hasSuccessfulDelivery(message: MessageAttributesType): boolean { + const { sendStateByConversationId } = message; + const ourConversationId = + window.ConversationController.getOurConversationIdOrThrow(); + + return someRecipientSendStatus( + sendStateByConversationId ?? {}, + ourConversationId, + isSent + ); +} diff --git a/ts/types/EmbeddedContact.ts b/ts/types/EmbeddedContact.ts index f2a9413e3..a6612074a 100644 --- a/ts/types/EmbeddedContact.ts +++ b/ts/types/EmbeddedContact.ts @@ -26,9 +26,9 @@ import { getLocalAttachmentUrl } from '../util/getLocalAttachmentUrl'; type GenericEmbeddedContactType = { name?: Name; - number?: Array; - email?: Array; - address?: Array; + number?: ReadonlyArray; + email?: ReadonlyArray; + address?: ReadonlyArray; avatar?: AvatarType; organization?: string; diff --git a/ts/util/MessageModelLogger.ts b/ts/util/MessageModelLogger.ts deleted file mode 100644 index bfec13b05..000000000 --- a/ts/util/MessageModelLogger.ts +++ /dev/null @@ -1,52 +0,0 @@ -// Copyright 2023 Signal Messenger, LLC -// SPDX-License-Identifier: AGPL-3.0-only - -import type { MessageModel } from '../models/messages'; -import { getEnvironment, Environment } from '../environment'; - -export function getMessageModelLogger(model: MessageModel): MessageModel { - const { id } = model; - - if (getEnvironment() !== Environment.Development) { - return model; - } - - const proxyHandler: ProxyHandler = { - get(_: MessageModel, property: keyof MessageModel) { - // Allowed set of attributes & methods - if (property === 'attributes') { - return model.attributes; - } - - if (property === 'id') { - return id; - } - - if (property === 'get') { - return model.get.bind(model); - } - - if (property === 'set') { - return model.set.bind(model); - } - - if (property === 'registerLocations') { - return model.registerLocations; - } - - // Disallowed set of methods & attributes - - if (typeof model[property] === 'function') { - return model[property].bind(model); - } - - if (typeof model[property] !== 'undefined') { - return model[property]; - } - - return undefined; - }, - }; - - return new Proxy(model, proxyHandler); -} diff --git a/ts/util/attachmentDownloadQueue.ts b/ts/util/attachmentDownloadQueue.ts index eab9a1121..f2f45c1c5 100644 --- a/ts/util/attachmentDownloadQueue.ts +++ b/ts/util/attachmentDownloadQueue.ts @@ -2,10 +2,17 @@ // SPDX-License-Identifier: AGPL-3.0-only import type { MessageModel } from '../models/messages'; +import type { MessageAttributesType } from '../model-types'; +import type { AttachmentType } from '../types/Attachment'; + import * as log from '../logging/log'; +import * as MIME from '../types/MIME'; + import { DataWriter } from '../sql/Client'; import { isMoreRecentThan } from './timestamp'; import { isNotNil } from './isNotNil'; +import { queueAttachmentDownloadsForMessage } from './queueAttachmentDownloads'; +import { postSaveUpdates } from './cleanup'; const MAX_ATTACHMENT_DOWNLOAD_AGE = 3600 * 72 * 1000; const MAX_ATTACHMENT_MSGS_TO_DOWNLOAD = 250; @@ -66,10 +73,7 @@ export async function flushAttachmentDownloadQueue(): Promise { let numMessagesQueued = 0; await Promise.all( messageIdsToDownload.map(async messageId => { - const message = window.MessageCache.__DEPRECATED$getById( - messageId, - 'flushAttachmentDownloadQueue' - ); + const message = window.MessageCache.getById(messageId); if (!message) { log.warn( 'attachmentDownloadQueue: message not found in messageCache, maybe it was deleted?' @@ -79,14 +83,14 @@ export async function flushAttachmentDownloadQueue(): Promise { if ( isMoreRecentThan( - message.getReceivedAt(), + message.get('received_at_ms') || message.get('received_at'), MAX_ATTACHMENT_DOWNLOAD_AGE ) || // Stickers and long text attachments has to be downloaded for UI // to display the message properly. - message.hasRequiredAttachmentDownloads() + hasRequiredAttachmentDownloads(message.attributes) ) { - const shouldSave = await message.queueAttachmentDownloads(); + const shouldSave = await queueAttachmentDownloadsForMessage(message); if (shouldSave) { messageIdsToSave.push(messageId); } @@ -101,13 +105,35 @@ export async function flushAttachmentDownloadQueue(): Promise { ); const messagesToSave = messageIdsToSave - .map(messageId => window.MessageCache.accessAttributes(messageId)) + .map(messageId => window.MessageCache.getById(messageId)?.attributes) .filter(isNotNil); await DataWriter.saveMessages(messagesToSave, { ourAci: window.storage.user.getCheckedAci(), + postSaveUpdates, }); attachmentDownloadQueue = undefined; onQueueEmpty(); } + +function hasRequiredAttachmentDownloads( + message: MessageAttributesType +): boolean { + const attachments: ReadonlyArray = message.attachments || []; + + const hasLongMessageAttachments = attachments.some(attachment => { + return MIME.isLongMessage(attachment.contentType); + }); + + if (hasLongMessageAttachments) { + return true; + } + + const { sticker } = message; + if (sticker) { + return !sticker.data || !sticker.data.path; + } + + return false; +} diff --git a/ts/util/callDisposition.ts b/ts/util/callDisposition.ts index 7da397c6a..10d62ebb3 100644 --- a/ts/util/callDisposition.ts +++ b/ts/util/callDisposition.ts @@ -46,10 +46,7 @@ import { incrementMessageCounter } from './incrementMessageCounter'; import { ReadStatus } from '../messages/MessageReadStatus'; import { SeenStatus, maxSeenStatus } from '../MessageSeenStatus'; import { canConversationBeUnarchived } from './canConversationBeUnarchived'; -import type { - ConversationAttributesType, - MessageAttributesType, -} from '../model-types'; +import type { ConversationAttributesType } from '../model-types'; import { singleProtoJobQueue } from '../jobs/singleProtoJobQueue'; import MessageSender from '../textsecure/SendMessage'; import * as Bytes from '../Bytes'; @@ -71,6 +68,8 @@ import { storageServiceUploadJob } from '../services/storage'; import { CallLinkFinalizeDeleteManager } from '../jobs/CallLinkFinalizeDeleteManager'; import { parsePartial, parseStrict } from './schemas'; import { calling } from '../services/calling'; +import { cleanupMessages } from './cleanup'; +import { MessageModel } from '../models/messages'; // utils // ----- @@ -1192,7 +1191,7 @@ async function saveCallHistory({ if (prevMessage != null) { await DataWriter.removeMessage(prevMessage.id, { fromSync: true, - singleProtoJobQueue, + cleanupMessages, }); } return callHistory; @@ -1222,7 +1221,7 @@ async function saveCallHistory({ const { id: newId } = generateMessageId(counter); - const message: MessageAttributesType = { + const message = new MessageModel({ id: prevMessage?.id ?? newId, conversationId: conversation.id, type: 'call-history', @@ -1234,20 +1233,15 @@ async function saveCallHistory({ readStatus: ReadStatus.Read, seenStatus, callId: callHistory.callId, - }; + }); - message.id = await DataWriter.saveMessage(message, { - ourAci: window.textsecure.storage.user.getCheckedAci(), - // We don't want to force save if we're updating an existing message + const id = await window.MessageCache.saveMessage(message, { forceSave: prevMessage == null, }); + message.set({ id }); log.info('saveCallHistory: Saved call history message:', message.id); - window.MessageCache.__DEPRECATED$register( - message.id, - message, - 'callDisposition' - ); + const model = window.MessageCache.register(message); if (prevMessage == null) { if (callHistory.direction === CallDirection.Outgoing) { @@ -1255,7 +1249,7 @@ async function saveCallHistory({ } else { conversation.incrementMessageCount(); } - conversation.trigger('newmessage', message); + drop(conversation.onNewMessage(model)); } await conversation.updateLastMessage().catch(error => { @@ -1356,11 +1350,10 @@ export async function updateCallHistoryFromLocalEvent( export function updateDeletedMessages(messageIds: ReadonlyArray): void { messageIds.forEach(messageId => { - const message = window.MessageCache.__DEPRECATED$getById( - messageId, - 'updateDeletedMessages' + const message = window.MessageCache.getById(messageId); + const conversation = window.ConversationController.get( + message?.get('conversationId') ); - const conversation = message?.getConversation(); if (message == null || conversation == null) { return; } @@ -1369,7 +1362,7 @@ export function updateDeletedMessages(messageIds: ReadonlyArray): void { message.get('conversationId') ); conversation.debouncedUpdateLastMessage(); - window.MessageCache.__DEPRECATED$unregister(messageId); + window.MessageCache.unregister(messageId); }); } diff --git a/ts/util/cleanup.ts b/ts/util/cleanup.ts index 86ae51e31..0157095ec 100644 --- a/ts/util/cleanup.ts +++ b/ts/util/cleanup.ts @@ -5,11 +5,15 @@ import PQueue from 'p-queue'; import { batch } from 'react-redux'; import type { MessageAttributesType } from '../model-types.d'; -import { DataReader } from '../sql/Client'; +import { MessageModel } from '../models/messages'; + +import * as Errors from '../types/errors'; +import * as log from '../logging/log'; + +import { DataReader, DataWriter } from '../sql/Client'; import { deletePackReference } from '../types/Stickers'; import { isStory } from '../messages/helpers'; import { isDirectConversation } from './whatTypeOfConversation'; -import * as log from '../logging/log'; import { getCallHistorySelector } from '../state/selectors/callHistory'; import { DirectCallStatus, @@ -17,20 +21,71 @@ import { AdhocCallStatus, } from '../types/CallDisposition'; import { getMessageIdForLogging } from './idForLogging'; -import type { SingleProtoJobQueue } from '../jobs/singleProtoJobQueue'; +import { singleProtoJobQueue } from '../jobs/singleProtoJobQueue'; import { MINUTE } from './durations'; import { drop } from './drop'; +import { hydrateStoryContext } from './hydrateStoryContext'; +import { update as updateExpiringMessagesService } from '../services/expiringMessagesDeletion'; +import { tapToViewMessagesDeletionService } from '../services/tapToViewMessagesDeletionService'; + +export async function postSaveUpdates(): Promise { + await updateExpiringMessagesService(); + await tapToViewMessagesDeletionService.update(); +} + +export async function eraseMessageContents( + message: MessageModel, + additionalProperties = {}, + shouldPersist = true +): Promise { + log.info( + `Erasing data for message ${getMessageIdForLogging(message.attributes)}` + ); + + // Note: There are cases where we want to re-erase a given message. For example, when + // a viewed (or outgoing) View-Once message is deleted for everyone. + + try { + await deleteMessageData(message.attributes); + } catch (error) { + log.error( + `Error erasing data for message ${getMessageIdForLogging(message.attributes)}:`, + Errors.toLogFormat(error) + ); + } + + message.set({ + attachments: [], + body: '', + bodyRanges: undefined, + contact: [], + editHistory: undefined, + isErased: true, + preview: [], + quote: undefined, + sticker: undefined, + ...additionalProperties, + }); + window.ConversationController.get( + message.attributes.conversationId + )?.debouncedUpdateLastMessage(); + + if (shouldPersist) { + await DataWriter.saveMessage(message.attributes, { + ourAci: window.textsecure.storage.user.getCheckedAci(), + postSaveUpdates, + }); + } + + await DataWriter.deleteSentProtoByMessageId(message.id); +} export async function cleanupMessages( messages: ReadonlyArray, { fromSync, - markCallHistoryDeleted, - singleProtoJobQueue, }: { fromSync?: boolean; - markCallHistoryDeleted: (callId: string) => Promise; - singleProtoJobQueue: SingleProtoJobQueue; } ): Promise { // First, handle any calls that need to be deleted @@ -40,8 +95,6 @@ export async function cleanupMessages( messages.map((message: MessageAttributesType) => async () => { await maybeDeleteCall(message, { fromSync, - markCallHistoryDeleted, - singleProtoJobQueue, }); }) ) @@ -76,7 +129,7 @@ export function cleanupMessageFromMemory(message: MessageAttributesType): void { const parentConversation = window.ConversationController.get(conversationId); parentConversation?.debouncedUpdateLastMessage(); - window.MessageCache.__DEPRECATED$unregister(id); + window.MessageCache.unregister(id); } async function cleanupStoryReplies( @@ -120,24 +173,18 @@ async function cleanupStoryReplies( // Cleanup all group replies await Promise.all( replies.map(reply => { - const replyMessageModel = window.MessageCache.__DEPRECATED$register( - reply.id, - reply, - 'cleanupStoryReplies/group' + const replyMessageModel = window.MessageCache.register( + new MessageModel(reply) ); - return replyMessageModel.eraseContents(); + return eraseMessageContents(replyMessageModel); }) ); } else { // Refresh the storyReplyContext data for 1:1 conversations await Promise.all( replies.map(async reply => { - const model = window.MessageCache.__DEPRECATED$register( - reply.id, - reply, - 'cleanupStoryReplies/1:1' - ); - await model.hydrateStoryContext(story, { + const model = window.MessageCache.register(new MessageModel(reply)); + await hydrateStoryContext(model.id, story, { shouldSave: true, isStoryErased: true, }); @@ -175,12 +222,8 @@ export async function maybeDeleteCall( message: MessageAttributesType, { fromSync, - markCallHistoryDeleted, - singleProtoJobQueue, }: { fromSync?: boolean; - markCallHistoryDeleted: (callId: string) => Promise; - singleProtoJobQueue: SingleProtoJobQueue; } ): Promise { const { callId } = message; @@ -214,6 +257,6 @@ export async function maybeDeleteCall( window.textsecure.MessageSender.getDeleteCallEvent(callHistory) ); } - await markCallHistoryDeleted(callId); + await DataWriter.markCallHistoryDeleted(callId); window.reduxActions.callHistory.removeCallHistory(callId); } diff --git a/ts/util/deleteForEveryone.ts b/ts/util/deleteForEveryone.ts index 4241c72c5..7bc5aa961 100644 --- a/ts/util/deleteForEveryone.ts +++ b/ts/util/deleteForEveryone.ts @@ -8,6 +8,9 @@ import { isMe } from './whatTypeOfConversation'; import { getAuthorId } from '../messages/helpers'; import { isStory } from '../state/selectors/message'; import { isTooOldToModifyMessage } from './isTooOldToModifyMessage'; +import { drop } from './drop'; +import { eraseMessageContents } from './cleanup'; +import { notificationService } from '../services/notifications'; export async function deleteForEveryone( message: MessageModel, @@ -18,7 +21,9 @@ export async function deleteForEveryone( shouldPersist = true ): Promise { if (isDeletionByMe(message, doe)) { - const conversation = message.getConversation(); + const conversation = window.ConversationController.get( + message.get('conversationId') + ); // Our 1:1 stories are deleted through ts/util/onStoryRecipientUpdate.ts if ( @@ -29,7 +34,7 @@ export async function deleteForEveryone( return; } - await message.handleDeleteForEveryone(doe, shouldPersist); + await handleDeleteForEveryone(message, doe, shouldPersist); return; } @@ -44,7 +49,7 @@ export async function deleteForEveryone( return; } - await message.handleDeleteForEveryone(doe, shouldPersist); + await handleDeleteForEveryone(message, doe, shouldPersist); } function isDeletionByMe( @@ -58,3 +63,49 @@ function isDeletionByMe( doe.fromId === ourConversationId ); } + +export async function handleDeleteForEveryone( + message: MessageModel, + del: Pick< + DeleteAttributesType, + 'fromId' | 'targetSentTimestamp' | 'serverTimestamp' + >, + shouldPersist = true +): Promise { + if (message.deletingForEveryone || message.get('deletedForEveryone')) { + return; + } + + log.info('Handling DOE.', { + messageId: message.id, + fromId: del.fromId, + targetSentTimestamp: del.targetSentTimestamp, + messageServerTimestamp: message.get('serverTimestamp'), + deleteServerTimestamp: del.serverTimestamp, + }); + + try { + // eslint-disable-next-line no-param-reassign + message.deletingForEveryone = true; + + // Remove any notifications for this message + notificationService.removeBy({ messageId: message.get('id') }); + + // Erase the contents of this message + await eraseMessageContents( + message, + { deletedForEveryone: true, reactions: [] }, + shouldPersist + ); + + // Update the conversation's last message in case this was the last message + drop( + window.ConversationController.get( + message.attributes.conversationId + )?.updateLastMessage() + ); + } finally { + // eslint-disable-next-line no-param-reassign + message.deletingForEveryone = undefined; + } +} diff --git a/ts/util/deleteForMe.ts b/ts/util/deleteForMe.ts index 910e494e9..6e5e9e60c 100644 --- a/ts/util/deleteForMe.ts +++ b/ts/util/deleteForMe.ts @@ -14,7 +14,6 @@ import { missingCaseError } from './missingCaseError'; import { getMessageSentTimestampSet } from './getMessageSentTimestampSet'; import { getAuthor } from '../messages/helpers'; import { isPniString } from '../types/ServiceId'; -import { singleProtoJobQueue } from '../jobs/singleProtoJobQueue'; import { DataReader, DataWriter, deleteAndCleanup } from '../sql/Client'; import { deleteData } from '../types/Attachment'; @@ -29,7 +28,8 @@ import type { } from '../textsecure/messageReceiverEvents'; import type { AciString, PniString } from '../types/ServiceId'; import type { AttachmentType } from '../types/Attachment'; -import type { MessageModel } from '../models/messages'; +import { MessageModel } from '../models/messages'; +import { cleanupMessages, postSaveUpdates } from './cleanup'; const { getMessagesBySentAt, getMostRecentAddressableMessages } = DataReader; @@ -98,8 +98,8 @@ export async function deleteMessage( return false; } - const message = window.MessageCache.toMessageAttributes(found); - await applyDeleteMessage(message, logId); + const message = window.MessageCache.register(new MessageModel(found)); + await applyDeleteMessage(message.attributes, logId); return true; } @@ -109,7 +109,7 @@ export async function applyDeleteMessage( ): Promise { await deleteAndCleanup([message], logId, { fromSync: true, - singleProtoJobQueue, + cleanupMessages, }); } @@ -141,11 +141,7 @@ export async function deleteAttachmentFromMessage( return false; } - const message = window.MessageCache.__DEPRECATED$register( - found.id, - found, - 'ReadSyncs.onSync' - ); + const message = window.MessageCache.register(new MessageModel(found)); return applyDeleteAttachmentFromMessage(message, deleteAttachmentData, { deleteOnDisk, @@ -209,7 +205,7 @@ export async function applyDeleteAttachmentFromMessage( attachments: attachments?.filter(item => item !== attachment), }); if (shouldSave) { - await saveMessage(message.attributes, { ourAci }); + await saveMessage(message.attributes, { ourAci, postSaveUpdates }); } await deleteData({ deleteOnDisk, deleteDownloadOnDisk })(attachment); @@ -291,10 +287,10 @@ export async function deleteConversation( const { received_at: receivedAt } = newestMessage; await removeMessagesInConversation(conversation.id, { + cleanupMessages, fromSync: true, - receivedAt, logId: `${logId}(receivedAt=${receivedAt})`, - singleProtoJobQueue, + receivedAt, }); } @@ -315,10 +311,10 @@ export async function deleteConversation( const { received_at: receivedAt } = newestNondisappearingMessage; await removeMessagesInConversation(conversation.id, { + cleanupMessages, fromSync: true, - receivedAt, logId: `${logId}(receivedAt=${receivedAt})`, - singleProtoJobQueue, + receivedAt, }); } } diff --git a/ts/util/deleteGroupStoryReplyForEveryone.ts b/ts/util/deleteGroupStoryReplyForEveryone.ts index da4e68bd8..ca2836d7a 100644 --- a/ts/util/deleteGroupStoryReplyForEveryone.ts +++ b/ts/util/deleteGroupStoryReplyForEveryone.ts @@ -3,16 +3,13 @@ import { DAY } from './durations'; import { sendDeleteForEveryoneMessage } from './sendDeleteForEveryoneMessage'; -import { __DEPRECATED$getMessageById } from '../messages/getMessageById'; +import { getMessageById } from '../messages/getMessageById'; import * as log from '../logging/log'; export async function deleteGroupStoryReplyForEveryone( replyMessageId: string ): Promise { - const messageModel = await __DEPRECATED$getMessageById( - replyMessageId, - 'deleteGroupStoryReplyForEveryone' - ); + const messageModel = await getMessageById(replyMessageId); if (!messageModel) { log.warn( @@ -23,7 +20,9 @@ export async function deleteGroupStoryReplyForEveryone( const timestamp = messageModel.get('timestamp'); - const group = messageModel.getConversation(); + const group = window.ConversationController.get( + messageModel.get('conversationId') + ); if (!group) { log.warn( diff --git a/ts/util/deleteStoryForEveryone.ts b/ts/util/deleteStoryForEveryone.ts index 808f2ec72..10a03432d 100644 --- a/ts/util/deleteStoryForEveryone.ts +++ b/ts/util/deleteStoryForEveryone.ts @@ -20,10 +20,11 @@ import { import { onStoryRecipientUpdate } from './onStoryRecipientUpdate'; import { sendDeleteForEveryoneMessage } from './sendDeleteForEveryoneMessage'; import { isGroupV2 } from './whatTypeOfConversation'; -import { __DEPRECATED$getMessageById } from '../messages/getMessageById'; +import { getMessageById } from '../messages/getMessageById'; import { strictAssert } from './assert'; import { repeat, zipObject } from './iterables'; import { isOlderThan } from './timestamp'; +import { postSaveUpdates } from './cleanup'; export async function deleteStoryForEveryone( stories: ReadonlyArray, @@ -47,10 +48,7 @@ export async function deleteStoryForEveryone( } const logId = `deleteStoryForEveryone(${story.messageId})`; - const message = await __DEPRECATED$getMessageById( - story.messageId, - 'deleteStoryForEveryone' - ); + const message = await getMessageById(story.messageId); if (!message) { throw new Error('Story not found'); } @@ -197,6 +195,7 @@ export async function deleteStoryForEveryone( await DataWriter.saveMessage(message.attributes, { jobToInsert, ourAci: window.textsecure.storage.user.getCheckedAci(), + postSaveUpdates, }); }); } catch (error) { diff --git a/ts/util/doubleCheckMissingQuoteReference.ts b/ts/util/doubleCheckMissingQuoteReference.ts new file mode 100644 index 000000000..662a4fbe9 --- /dev/null +++ b/ts/util/doubleCheckMissingQuoteReference.ts @@ -0,0 +1,94 @@ +// Copyright 2019 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +import { getMessageById } from '../messages/getMessageById'; +import type { MessageModel } from '../models/messages'; + +import { hydrateStoryContext } from './hydrateStoryContext'; +import { getMessageIdForLogging } from './idForLogging'; + +import * as log from '../logging/log'; +import { + isQuoteAMatch, + shouldTryToCopyFromQuotedMessage, +} from '../messages/helpers'; +import { copyQuoteContentFromOriginal } from '../messages/copyQuote'; +import { queueUpdateMessage } from './messageBatcher'; + +export async function doubleCheckMissingQuoteReference( + message: MessageModel +): Promise { + const logId = getMessageIdForLogging(message.attributes); + + const storyId = message.get('storyId'); + if (storyId) { + log.warn( + `doubleCheckMissingQuoteReference/${logId}: missing story reference` + ); + + const storyMessage = await getMessageById(storyId); + if (!storyMessage) { + return; + } + + if (message.get('storyReplyContext')) { + message.set({ storyReplyContext: undefined }); + } + await hydrateStoryContext(message.id, storyMessage.attributes, { + shouldSave: true, + }); + return; + } + + const quote = message.get('quote'); + if (!quote) { + log.warn(`doubleCheckMissingQuoteReference/${logId}: Missing quote!`); + return; + } + + const { authorAci, author, id: sentAt, referencedMessageNotFound } = quote; + const contact = window.ConversationController.get(authorAci || author); + + // Is the quote really without a reference? Check with our in memory store + // first to make sure it's not there. + if ( + contact && + shouldTryToCopyFromQuotedMessage({ + referencedMessageNotFound, + quoteAttachment: quote.attachments.at(0), + }) + ) { + const matchingMessage = await window.MessageCache.findBySentAt( + Number(sentAt), + model => + isQuoteAMatch(model.attributes, message.get('conversationId'), quote) + ); + + if (!matchingMessage) { + log.info( + `doubleCheckMissingQuoteReference/${logId}: No match for ${sentAt}.` + ); + return; + } + + message.set({ + quote: { + ...quote, + referencedMessageNotFound: false, + }, + }); + + log.info( + `doubleCheckMissingQuoteReference/${logId}: Found match for ${sentAt}, updating.` + ); + + await copyQuoteContentFromOriginal(matchingMessage, quote); + message.set({ + quote: { + ...quote, + referencedMessageNotFound: false, + }, + }); + queueUpdateMessage(message.attributes); + } +} diff --git a/ts/util/downloadOnboardingStory.ts b/ts/util/downloadOnboardingStory.ts index ae9112413..4d12c788e 100644 --- a/ts/util/downloadOnboardingStory.ts +++ b/ts/util/downloadOnboardingStory.ts @@ -4,8 +4,7 @@ import { v4 as generateUuid } from 'uuid'; import type { AttachmentType } from '../types/Attachment'; -import type { MessageAttributesType } from '../model-types.d'; -import type { MessageModel } from '../models/messages'; +import { MessageModel } from '../models/messages'; import * as log from '../logging/log'; import { IMAGE_JPEG } from '../types/MIME'; import { ReadStatus } from '../messages/MessageReadStatus'; @@ -84,7 +83,7 @@ export async function downloadOnboardingStory(): Promise { (attachment, index) => { const timestamp = Date.now() + index; - const partialMessage: MessageAttributesType = { + const message = new MessageModel({ attachments: [attachment], canReplyToStory: false, conversationId: signalConversation.id, @@ -99,12 +98,8 @@ export async function downloadOnboardingStory(): Promise { sourceServiceId: signalConversation.getServiceId(), timestamp, type: 'story', - }; - return window.MessageCache.__DEPRECATED$register( - partialMessage.id, - partialMessage, - 'downloadOnboardingStory' - ); + }); + return window.MessageCache.register(message); } ); @@ -112,11 +107,6 @@ export async function downloadOnboardingStory(): Promise { storyMessages.map(message => saveNewMessageBatcher.add(message.attributes)) ); - // Sync to redux - storyMessages.forEach(message => { - message.trigger('change'); - }); - await window.storage.put( 'existingOnboardingStoryMessageIds', storyMessages.map(message => message.id) diff --git a/ts/util/findAndDeleteOnboardingStoryIfExists.ts b/ts/util/findAndDeleteOnboardingStoryIfExists.ts index 92fcb3048..8ed031960 100644 --- a/ts/util/findAndDeleteOnboardingStoryIfExists.ts +++ b/ts/util/findAndDeleteOnboardingStoryIfExists.ts @@ -5,7 +5,8 @@ import * as log from '../logging/log'; import { DataWriter } from '../sql/Client'; import { calculateExpirationTimestamp } from './expirationTimer'; import { DAY } from './durations'; -import { singleProtoJobQueue } from '../jobs/singleProtoJobQueue'; +import { cleanupMessages } from './cleanup'; +import { getMessageById } from '../messages/getMessageById'; export async function findAndDeleteOnboardingStoryIfExists(): Promise { const existingOnboardingStoryMessageIds = window.storage.get( @@ -19,12 +20,14 @@ export async function findAndDeleteOnboardingStoryIfExists(): Promise { const hasExpired = await (async () => { const [storyId] = existingOnboardingStoryMessageIds; try { - const messageAttributes = await window.MessageCache.resolveAttributes( - 'findAndDeleteOnboardingStoryIfExists', - storyId - ); + const message = await getMessageById(storyId); + if (!message) { + throw new Error( + `findAndDeleteOnboardingStoryIfExists: Failed to find message ${storyId}` + ); + } - const expires = calculateExpirationTimestamp(messageAttributes) ?? 0; + const expires = calculateExpirationTimestamp(message.attributes) ?? 0; const now = Date.now(); const isExpired = expires < now; @@ -46,7 +49,7 @@ export async function findAndDeleteOnboardingStoryIfExists(): Promise { log.info('findAndDeleteOnboardingStoryIfExists: removing onboarding stories'); await DataWriter.removeMessages(existingOnboardingStoryMessageIds, { - singleProtoJobQueue, + cleanupMessages, }); await window.storage.put('existingOnboardingStoryMessageIds', undefined); diff --git a/ts/util/handleEditMessage.ts b/ts/util/handleEditMessage.ts index 97e383eaa..2587dabb3 100644 --- a/ts/util/handleEditMessage.ts +++ b/ts/util/handleEditMessage.ts @@ -25,6 +25,7 @@ import { isTooOldToModifyMessage } from './isTooOldToModifyMessage'; import { queueAttachmentDownloads } from './queueAttachmentDownloads'; import { modifyTargetMessage } from './modifyTargetMessage'; import { isMessageNoteToSelf } from './isMessageNoteToSelf'; +import { MessageModel } from '../models/messages'; const RECURSION_LIMIT = 15; @@ -103,15 +104,13 @@ export async function handleEditMessage( return; } - const mainMessageModel = window.MessageCache.__DEPRECATED$register( - mainMessage.id, - mainMessage, - 'handleEditMessage' + const mainMessageModel = window.MessageCache.register( + new MessageModel(mainMessage) ); // Pull out the edit history from the main message. If this is the first edit // then the original message becomes the first item in the edit history. - let editHistory: Array = mainMessage.editHistory || [ + let editHistory: ReadonlyArray = mainMessage.editHistory || [ { attachments: mainMessage.attachments, body: mainMessage.body, @@ -215,8 +214,10 @@ export async function handleEditMessage( const { quote: upgradedQuote } = upgradedEditedMessageData; let nextEditedMessageQuote: QuotedMessageType | undefined; if (!upgradedQuote) { - // Quote dropped - log.info(`${idLog}: dropping quote`); + if (mainMessage.quote) { + // Quote dropped + log.info(`${idLog}: dropping quote`); + } } else if (!upgradedQuote.id || upgradedQuote.id === mainMessage.quote?.id) { // Quote preserved nextEditedMessageQuote = mainMessage.quote; @@ -370,7 +371,9 @@ export async function handleEditMessage( conversation.clearContactTypingTimer(typingToken); } - const mainMessageConversation = mainMessageModel.getConversation(); + const mainMessageConversation = window.ConversationController.get( + mainMessageModel.get('conversationId') + ); if (mainMessageConversation) { drop(mainMessageConversation.updateLastMessage()); // Apply any other operations, excluding edits that target this message @@ -386,7 +389,7 @@ export async function handleEditMessage( // Apply any other pending edits that target this message const edits = Edits.forMessage({ - ...mainMessage, + ...mainMessageModel.attributes, sent_at: editedMessage.timestamp, timestamp: editedMessage.timestamp, }); diff --git a/ts/util/hydrateStoryContext.ts b/ts/util/hydrateStoryContext.ts index e739737a9..87931da8c 100644 --- a/ts/util/hydrateStoryContext.ts +++ b/ts/util/hydrateStoryContext.ts @@ -12,6 +12,10 @@ import { softAssert, strictAssert } from './assert'; import { getMessageSentTimestamp } from './getMessageSentTimestamp'; import { isOlderThan } from './timestamp'; import { DAY } from './durations'; +import { getMessageById } from '../messages/getMessageById'; +import { MessageModel } from '../models/messages'; +import { DataWriter } from '../sql/Client'; +import { postSaveUpdates } from './cleanup'; export async function hydrateStoryContext( messageId: string, @@ -24,23 +28,18 @@ export async function hydrateStoryContext( isStoryErased?: boolean; } = {} ): Promise | undefined> { - let messageAttributes: MessageAttributesType; - try { - messageAttributes = await window.MessageCache.resolveAttributes( - 'hydrateStoryContext', - messageId - ); - } catch { + const message = await getMessageById(messageId); + if (!message) { + log.warn(`hydrateStoryContext: Message ${messageId} not found`); return undefined; } - const { storyId } = messageAttributes; + const { storyId, storyReplyContext: context } = message.attributes; if (!storyId) { return undefined; } - const { storyReplyContext: context } = messageAttributes; - const sentTimestamp = getMessageSentTimestamp(messageAttributes, { + const sentTimestamp = getMessageSentTimestamp(message.attributes, { includeEdits: false, log, }); @@ -55,22 +54,19 @@ export async function hydrateStoryContext( return undefined; } - let storyMessage: MessageAttributesType | undefined; + let storyMessage: MessageModel | undefined; try { storyMessage = storyMessageParam === undefined - ? await window.MessageCache.resolveAttributes( - 'hydrateStoryContext/story', - storyId - ) - : window.MessageCache.toMessageAttributes(storyMessageParam); + ? await getMessageById(storyId) + : window.MessageCache.register(new MessageModel(storyMessageParam)); } catch { storyMessage = undefined; } if (!storyMessage || isStoryErased) { const conversation = window.ConversationController.get( - messageAttributes.conversationId + message.attributes.conversationId ); softAssert( conversation && isDirectConversation(conversation.attributes), @@ -84,30 +80,25 @@ export async function hydrateStoryContext( messageId: '', }, }; + message.set(newMessageAttributes); if (shouldSave) { - await window.MessageCache.setAttributes({ - messageId, - messageAttributes: newMessageAttributes, - skipSaveToDatabase: false, - }); - } else { - window.MessageCache.setAttributes({ - messageId, - messageAttributes: newMessageAttributes, - skipSaveToDatabase: true, + const ourAci = window.textsecure.storage.user.getCheckedAci(); + await DataWriter.saveMessage(message.attributes, { + ourAci, + postSaveUpdates, }); } return newMessageAttributes; } - const attachments = getAttachmentsForMessage({ ...storyMessage }); + const attachments = getAttachmentsForMessage({ ...storyMessage.attributes }); let attachment: AttachmentType | undefined = attachments?.[0]; if (attachment && !attachment.url && !attachment.textAttachment) { attachment = undefined; } - const { sourceServiceId: authorAci } = storyMessage; + const { sourceServiceId: authorAci } = storyMessage.attributes; strictAssert(isAciString(authorAci), 'Story message from pni'); const newMessageAttributes: Partial = { storyReplyContext: { @@ -116,18 +107,14 @@ export async function hydrateStoryContext( messageId: storyMessage.id, }, }; + message.set(newMessageAttributes); if (shouldSave) { - await window.MessageCache.setAttributes({ - messageId, - messageAttributes: newMessageAttributes, - skipSaveToDatabase: false, - }); - } else { - window.MessageCache.setAttributes({ - messageId, - messageAttributes: newMessageAttributes, - skipSaveToDatabase: true, + const ourAci = window.textsecure.storage.user.getCheckedAci(); + await DataWriter.saveMessage(message.attributes, { + ourAci, + postSaveUpdates, }); } + return newMessageAttributes; } diff --git a/ts/util/isMessageEmpty.ts b/ts/util/isMessageEmpty.ts new file mode 100644 index 000000000..888b9e5d6 --- /dev/null +++ b/ts/util/isMessageEmpty.ts @@ -0,0 +1,97 @@ +// Copyright 2024 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +import { messageHasPaymentEvent } from '../messages/helpers'; +import type { MessageAttributesType } from '../model-types'; +import { + hasErrors, + isCallHistory, + isChatSessionRefreshed, + isConversationMerge, + isDeliveryIssue, + isEndSession, + isExpirationTimerUpdate, + isGiftBadge, + isGroupUpdate, + isGroupV2Change, + isKeyChange, + isPhoneNumberDiscovery, + isProfileChange, + isTapToView, + isTitleTransitionNotification, + isUniversalTimerNotification, + isUnsupportedMessage, + isVerifiedChange, +} from '../state/selectors/message'; + +export function isMessageEmpty(attributes: MessageAttributesType): boolean { + // Core message types - we check for all four because they can each stand alone + const hasBody = Boolean(attributes.body); + const hasAttachment = (attributes.attachments || []).length > 0; + const hasEmbeddedContact = (attributes.contact || []).length > 0; + const isSticker = Boolean(attributes.sticker); + + // Rendered sync messages + const isCallHistoryValue = isCallHistory(attributes); + const isChatSessionRefreshedValue = isChatSessionRefreshed(attributes); + const isDeliveryIssueValue = isDeliveryIssue(attributes); + const isGiftBadgeValue = isGiftBadge(attributes); + const isGroupUpdateValue = isGroupUpdate(attributes); + const isGroupV2ChangeValue = isGroupV2Change(attributes); + const isEndSessionValue = isEndSession(attributes); + const isExpirationTimerUpdateValue = isExpirationTimerUpdate(attributes); + const isVerifiedChangeValue = isVerifiedChange(attributes); + + // Placeholder messages + const isUnsupportedMessageValue = isUnsupportedMessage(attributes); + const isTapToViewValue = isTapToView(attributes); + + // Errors + const hasErrorsValue = hasErrors(attributes); + + // Locally-generated notifications + const isKeyChangeValue = isKeyChange(attributes); + const isProfileChangeValue = isProfileChange(attributes); + const isUniversalTimerNotificationValue = + isUniversalTimerNotification(attributes); + const isConversationMergeValue = isConversationMerge(attributes); + const isPhoneNumberDiscoveryValue = isPhoneNumberDiscovery(attributes); + const isTitleTransitionNotificationValue = + isTitleTransitionNotification(attributes); + + const isPayment = messageHasPaymentEvent(attributes); + + // Note: not all of these message types go through message.handleDataMessage + + const hasSomethingToDisplay = + // Core message types + hasBody || + hasAttachment || + hasEmbeddedContact || + isSticker || + isPayment || + // Rendered sync messages + isCallHistoryValue || + isChatSessionRefreshedValue || + isDeliveryIssueValue || + isGiftBadgeValue || + isGroupUpdateValue || + isGroupV2ChangeValue || + isEndSessionValue || + isExpirationTimerUpdateValue || + isVerifiedChangeValue || + // Placeholder messages + isUnsupportedMessageValue || + isTapToViewValue || + // Errors + hasErrorsValue || + // Locally-generated notifications + isKeyChangeValue || + isProfileChangeValue || + isUniversalTimerNotificationValue || + isConversationMergeValue || + isPhoneNumberDiscoveryValue || + isTitleTransitionNotificationValue; + + return !hasSomethingToDisplay; +} diff --git a/ts/util/isValidTapToView.ts b/ts/util/isValidTapToView.ts new file mode 100644 index 000000000..85bf5dcf6 --- /dev/null +++ b/ts/util/isValidTapToView.ts @@ -0,0 +1,38 @@ +// Copyright 2024 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +import type { MessageAttributesType } from '../model-types'; +import * as GoogleChrome from './GoogleChrome'; + +export function isValidTapToView(message: MessageAttributesType): boolean { + const { body } = message; + if (body) { + return false; + } + + const { attachments } = message; + if (!attachments || attachments.length !== 1) { + return false; + } + + const firstAttachment = attachments[0]; + if ( + !GoogleChrome.isImageTypeSupported(firstAttachment.contentType) && + !GoogleChrome.isVideoTypeSupported(firstAttachment.contentType) + ) { + return false; + } + + const { quote, sticker, contact, preview } = message; + + if ( + quote || + sticker || + (contact && contact.length > 0) || + (preview && preview.length > 0) + ) { + return false; + } + + return true; +} diff --git a/ts/util/makeQuote.ts b/ts/util/makeQuote.ts index 9154954c4..3fc21667a 100644 --- a/ts/util/makeQuote.ts +++ b/ts/util/makeQuote.ts @@ -55,8 +55,8 @@ export async function makeQuote( } export async function getQuoteAttachment( - attachments?: Array, - preview?: Array, + attachments?: ReadonlyArray, + preview?: ReadonlyArray, sticker?: StickerType ): Promise> { const { loadAttachmentData } = window.Signal.Migrations; diff --git a/ts/util/markConversationRead.ts b/ts/util/markConversationRead.ts index 46332fbc3..5129bb808 100644 --- a/ts/util/markConversationRead.ts +++ b/ts/util/markConversationRead.ts @@ -103,10 +103,7 @@ export async function markConversationRead( const allReadMessagesSync = allUnreadMessages .map(messageSyncData => { - const message = window.MessageCache.__DEPRECATED$getById( - messageSyncData.id, - 'markConversationRead' - ); + const message = window.MessageCache.getById(messageSyncData.id); // we update the in-memory MessageModel with fresh read/seen status if (message) { message.set( diff --git a/ts/util/markOnboardingStoryAsRead.ts b/ts/util/markOnboardingStoryAsRead.ts index a0d181c25..24453a603 100644 --- a/ts/util/markOnboardingStoryAsRead.ts +++ b/ts/util/markOnboardingStoryAsRead.ts @@ -3,11 +3,12 @@ import * as log from '../logging/log'; import { DataWriter } from '../sql/Client'; -import { __DEPRECATED$getMessageById } from '../messages/getMessageById'; +import { getMessageById } from '../messages/getMessageById'; import { isNotNil } from './isNotNil'; import { DurationInSeconds } from './durations'; import { markViewed } from '../services/MessageUpdater'; import { storageServiceUploadJob } from '../services/storage'; +import { postSaveUpdates } from './cleanup'; export async function markOnboardingStoryAsRead(): Promise { const existingOnboardingStoryMessageIds = window.storage.get( @@ -20,9 +21,7 @@ export async function markOnboardingStoryAsRead(): Promise { } const messages = await Promise.all( - existingOnboardingStoryMessageIds.map(id => - __DEPRECATED$getMessageById(id, 'markOnboardingStoryAsRead') - ) + existingOnboardingStoryMessageIds.map(id => getMessageById(id)) ); const storyReadDate = Date.now(); @@ -49,6 +48,7 @@ export async function markOnboardingStoryAsRead(): Promise { await DataWriter.saveMessages(messageAttributes, { ourAci: window.textsecure.storage.user.getCheckedAci(), + postSaveUpdates, }); await window.storage.put('hasViewedOnboardingStory', true); diff --git a/ts/util/messageBatcher.ts b/ts/util/messageBatcher.ts index 51ec5fc38..b8dc723df 100644 --- a/ts/util/messageBatcher.ts +++ b/ts/util/messageBatcher.ts @@ -6,6 +6,8 @@ import { createBatcher } from './batcher'; import { createWaitBatcher } from './waitBatcher'; import { DataWriter } from '../sql/Client'; import * as log from '../logging/log'; +import { postSaveUpdates } from './cleanup'; +import { MessageModel } from '../models/messages'; const updateMessageBatcher = createBatcher({ name: 'messageBatcher.updateMessageBatcher', @@ -16,11 +18,12 @@ const updateMessageBatcher = createBatcher({ // Grab the latest from the cache in case they've changed const messagesToSave = messageAttrs.map( - message => window.MessageCache.accessAttributes(message.id) ?? message + message => window.MessageCache.getById(message.id)?.attributes ?? message ); await DataWriter.saveMessages(messagesToSave, { ourAci: window.textsecure.storage.user.getCheckedAci(), + postSaveUpdates, }); }, }); @@ -35,6 +38,7 @@ export function queueUpdateMessage( } else { void DataWriter.saveMessage(messageAttr, { ourAci: window.textsecure.storage.user.getCheckedAci(), + postSaveUpdates, }); } } @@ -55,12 +59,15 @@ export const saveNewMessageBatcher = // Grab the latest from the cache in case they've changed const messagesToSave = messageAttrs.map( - message => window.MessageCache.accessAttributes(message.id) ?? message + message => + window.MessageCache.register(new MessageModel(message))?.attributes ?? + message ); await DataWriter.saveMessages(messagesToSave, { forceSave: true, ourAci: window.textsecure.storage.user.getCheckedAci(), + postSaveUpdates, }); }, }); diff --git a/ts/util/modifyTargetMessage.ts b/ts/util/modifyTargetMessage.ts index 09154ec74..6e8b20ba4 100644 --- a/ts/util/modifyTargetMessage.ts +++ b/ts/util/modifyTargetMessage.ts @@ -35,6 +35,10 @@ import { applyDeleteAttachmentFromMessage, applyDeleteMessage, } from './deleteForMe'; +import { getMessageIdForLogging } from './idForLogging'; +import { markViewOnceMessageViewed } from '../services/MessageUpdater'; +import { handleReaction } from '../messageModifiers/Reactions'; +import { postSaveUpdates } from './cleanup'; export enum ModifyTargetMessageResult { Modified = 'Modified', @@ -52,7 +56,7 @@ export async function modifyTargetMessage( ): Promise { const { isFirstRun = false, skipEdits = false } = options ?? {}; - const logId = `modifyTargetMessage/${message.idForLogging()}`; + const logId = `modifyTargetMessage/${getMessageIdForLogging(message.attributes)}`; const type = message.get('type'); let changed = false; const ourAci = window.textsecure.storage.user.getCheckedAci(); @@ -157,7 +161,7 @@ export async function modifyTargetMessage( ); if (!isEqual(oldSendStateByConversationId, newSendStateByConversationId)) { - message.set('sendStateByConversationId', newSendStateByConversationId); + message.set({ sendStateByConversationId: newSendStateByConversationId }); changed = true; } } @@ -184,10 +188,12 @@ export async function modifyTargetMessage( const existingExpirationStartTimestamp = message.get( 'expirationStartTimestamp' ); - message.set( - 'expirationStartTimestamp', - Math.min(existingExpirationStartTimestamp ?? Date.now(), markReadAt) - ); + message.set({ + expirationStartTimestamp: Math.min( + existingExpirationStartTimestamp ?? Date.now(), + markReadAt + ), + }); changed = true; } @@ -208,8 +214,10 @@ export async function modifyTargetMessage( }); changed = true; - message.setPendingMarkRead( - Math.min(message.getPendingMarkRead() ?? Date.now(), markReadAt) + // eslint-disable-next-line no-param-reassign + message.pendingMarkRead = Math.min( + message.pendingMarkRead ?? Date.now(), + markReadAt ); } else if ( isFirstRun && @@ -219,9 +227,10 @@ export async function modifyTargetMessage( conversation.setArchived(false); } - if (!isFirstRun && message.getPendingMarkRead()) { - const markReadAt = message.getPendingMarkRead(); - message.setPendingMarkRead(undefined); + if (!isFirstRun && message.pendingMarkRead) { + const markReadAt = message.pendingMarkRead; + // eslint-disable-next-line no-param-reassign + message.pendingMarkRead = undefined; const newestSentAt = maybeSingleReadSync?.readSync.timestamp; // This is primarily to allow the conversation to mark all older @@ -232,9 +241,9 @@ export async function modifyTargetMessage( // message and the other ones accompanying it in the batch are fully in // the database. drop( - message - .getConversation() - ?.onReadMessage(message.attributes, markReadAt, newestSentAt) + window.ConversationController.get( + message.get('conversationId') + )?.onReadMessage(message.attributes, markReadAt, newestSentAt) ); } @@ -242,7 +251,7 @@ export async function modifyTargetMessage( if (isTapToView(message.attributes)) { const viewOnceOpenSync = ViewOnceOpenSyncs.forMessage(message.attributes); if (viewOnceOpenSync) { - await message.markViewOnceMessageViewed({ fromSync: true }); + await markViewOnceMessageViewed(message, { fromSync: true }); changed = true; } } @@ -262,8 +271,10 @@ export async function modifyTargetMessage( Date.now(), ...viewSyncs.map(({ viewSync }) => viewSync.viewedAt) ); - message.setPendingMarkRead( - Math.min(message.getPendingMarkRead() ?? Date.now(), markReadAt) + // eslint-disable-next-line no-param-reassign + message.pendingMarkRead = Math.min( + message.pendingMarkRead ?? Date.now(), + markReadAt ); } @@ -272,7 +283,7 @@ export async function modifyTargetMessage( expirationStartTimestamp: message.get('timestamp'), expireTimer: message.get('expireTimer'), }); - message.set('expirationStartTimestamp', message.get('timestamp')); + message.set({ expirationStartTimestamp: message.get('timestamp') }); changed = true; } } @@ -292,12 +303,12 @@ export async function modifyTargetMessage( generatedMessage, 'Story reactions must provide storyReactionMessage' ); - await generatedMessage.handleReaction(reaction, { + await handleReaction(generatedMessage, reaction, { storyMessage: message.attributes, }); } else { changed = true; - await message.handleReaction(reaction, { shouldPersist: false }); + await handleReaction(message, reaction, { shouldPersist: false }); } }) ); @@ -317,6 +328,7 @@ export async function modifyTargetMessage( log.info(`${logId}: Changes in second run; saving.`); await DataWriter.saveMessage(message.attributes, { ourAci, + postSaveUpdates, }); } diff --git a/ts/util/onStoryRecipientUpdate.ts b/ts/util/onStoryRecipientUpdate.ts index 7de13cf56..a60c4aa44 100644 --- a/ts/util/onStoryRecipientUpdate.ts +++ b/ts/util/onStoryRecipientUpdate.ts @@ -13,6 +13,8 @@ import { isStory } from '../state/selectors/message'; import { queueUpdateMessage } from './messageBatcher'; import { isMe } from './whatTypeOfConversation'; import { drop } from './drop'; +import { handleDeleteForEveryone } from './deleteForEveryone'; +import { MessageModel } from '../models/messages'; export async function onStoryRecipientUpdate( event: StoryRecipientUpdateEvent @@ -162,11 +164,7 @@ export async function onStoryRecipientUpdate( return true; } - const message = window.MessageCache.__DEPRECATED$register( - item.id, - item, - 'onStoryRecipientUpdate' - ); + const message = window.MessageCache.register(new MessageModel(item)); const sendStateConversationIds = new Set( Object.keys(nextSendStateByConversationId) @@ -190,7 +188,7 @@ export async function onStoryRecipientUpdate( // sent timestamp doesn't happen (it would return all copies of the // story, not just the one we want to delete). drop( - message.handleDeleteForEveryone({ + handleDeleteForEveryone(message, { fromId: ourConversationId, serverTimestamp: Number(item.serverTimestamp), targetSentTimestamp: item.timestamp, diff --git a/ts/util/queueAttachmentDownloads.ts b/ts/util/queueAttachmentDownloads.ts index 3ecefec84..9a7d9aeec 100644 --- a/ts/util/queueAttachmentDownloads.ts +++ b/ts/util/queueAttachmentDownloads.ts @@ -33,13 +33,23 @@ import { AttachmentDownloadUrgency, } from '../jobs/AttachmentDownloadManager'; import { AttachmentDownloadSource } from '../sql/Interface'; +import type { MessageModel } from '../models/messages'; +import type { ConversationModel } from '../models/conversations'; +import { isOutgoing, isStory } from '../messages/helpers'; +import { shouldDownloadStory } from './shouldDownloadStory'; +import { hasAttachmentDownloads } from './hasAttachmentDownloads'; +import { + addToAttachmentDownloadQueue, + shouldUseAttachmentDownloadQueue, +} from './attachmentDownloadQueue'; +import { queueUpdateMessage } from './messageBatcher'; export type MessageAttachmentsDownloadedType = { bodyAttachment?: AttachmentType; - attachments: Array; - editHistory?: Array; - preview: Array; - contact: Array; + attachments: ReadonlyArray; + editHistory?: ReadonlyArray; + preview: ReadonlyArray; + contact: ReadonlyArray; quote?: QuotedMessageType; sticker?: StickerType; }; @@ -49,6 +59,50 @@ function getLogger(source: AttachmentDownloadSource) { const log = verbose ? logger : { ...logger, info: () => null }; return log; } + +export async function handleAttachmentDownloadsForNewMessage( + message: MessageModel, + conversation: ConversationModel +): Promise { + const idLog = `handleAttachmentDownloadsForNewMessage/${conversation.idForLogging()} ${getMessageIdForLogging(message.attributes)}`; + + // Only queue attachments for downloads if this is a story (with additional logic), or + // if it's either an outgoing message or we've accepted the conversation + let shouldQueueForDownload = false; + if (isStory(message.attributes)) { + shouldQueueForDownload = await shouldDownloadStory(conversation.attributes); + } else { + shouldQueueForDownload = + hasAttachmentDownloads(message.attributes) && + (conversation.getAccepted() || isOutgoing(message.attributes)); + } + + if (shouldQueueForDownload) { + if (shouldUseAttachmentDownloadQueue()) { + addToAttachmentDownloadQueue(idLog, message); + } else { + await queueAttachmentDownloadsForMessage(message); + } + } +} + +export async function queueAttachmentDownloadsForMessage( + message: MessageModel, + urgency?: AttachmentDownloadUrgency +): Promise { + const updates = await queueAttachmentDownloads(message.attributes, { + urgency, + }); + if (!updates) { + return false; + } + + message.set(updates); + queueUpdateMessage(message.attributes); + + return true; +} + // Receive logic // NOTE: If you're changing any logic in this function that deals with the // count then you'll also have to modify ./hasAttachmentsDownloads diff --git a/ts/util/sendDeleteForEveryoneMessage.ts b/ts/util/sendDeleteForEveryoneMessage.ts index 4ec8d663d..813be9dba 100644 --- a/ts/util/sendDeleteForEveryoneMessage.ts +++ b/ts/util/sendDeleteForEveryoneMessage.ts @@ -16,11 +16,12 @@ import { getConversationIdForLogging, getMessageIdForLogging, } from './idForLogging'; -import { __DEPRECATED$getMessageById } from '../messages/getMessageById'; +import { getMessageById } from '../messages/getMessageById'; import { getRecipientConversationIds } from './getRecipientConversationIds'; import { getRecipients } from './getRecipients'; import { repeat, zipObject } from './iterables'; import { isMe } from './whatTypeOfConversation'; +import { postSaveUpdates } from './cleanup'; export async function sendDeleteForEveryoneMessage( conversationAttributes: ConversationAttributesType, @@ -35,10 +36,7 @@ export async function sendDeleteForEveryoneMessage( timestamp: targetTimestamp, id: messageId, } = options; - const message = await __DEPRECATED$getMessageById( - messageId, - 'sendDeleteForEveryoneMessage' - ); + const message = await getMessageById(messageId); if (!message) { throw new Error('sendDeleteForEveryoneMessage: Cannot find message!'); } @@ -88,6 +86,7 @@ export async function sendDeleteForEveryoneMessage( await DataWriter.saveMessage(message.attributes, { jobToInsert, ourAci: window.textsecure.storage.user.getCheckedAci(), + postSaveUpdates, }); }); } catch (error) { diff --git a/ts/util/sendEditedMessage.ts b/ts/util/sendEditedMessage.ts index b0c61e2c9..63872821f 100644 --- a/ts/util/sendEditedMessage.ts +++ b/ts/util/sendEditedMessage.ts @@ -24,7 +24,7 @@ import { import { concat, filter, map, repeat, zipObject, find } from './iterables'; import { getConversationIdForLogging } from './idForLogging'; import { isQuoteAMatch } from '../messages/helpers'; -import { __DEPRECATED$getMessageById } from '../messages/getMessageById'; +import { getMessageById } from '../messages/getMessageById'; import { handleEditMessage } from './handleEditMessage'; import { incrementMessageCounter } from './incrementMessageCounter'; import { isGroupV1 } from './whatTypeOfConversation'; @@ -34,6 +34,7 @@ import { strictAssert } from './assert'; import { timeAndLogIfTooLong } from './timeAndLogIfTooLong'; import { makeQuote } from './makeQuote'; import { getMessageSentTimestamp } from './getMessageSentTimestamp'; +import { postSaveUpdates } from './cleanup'; const SEND_REPORT_THRESHOLD_MS = 25; @@ -65,10 +66,7 @@ export async function sendEditedMessage( conversation.attributes )})`; - const targetMessage = await __DEPRECATED$getMessageById( - targetMessageId, - 'sendEditedMessage' - ); + const targetMessage = await getMessageById(targetMessageId); strictAssert(targetMessage, 'could not find message to edit'); if (isGroupV1(conversation.attributes)) { @@ -229,6 +227,7 @@ export async function sendEditedMessage( await DataWriter.saveMessage(targetMessage.attributes, { jobToInsert, ourAci: window.textsecure.storage.user.getCheckedAci(), + postSaveUpdates, }); } ), diff --git a/ts/util/sendStoryMessage.ts b/ts/util/sendStoryMessage.ts index bc324bca2..e1516dc6e 100644 --- a/ts/util/sendStoryMessage.ts +++ b/ts/util/sendStoryMessage.ts @@ -31,6 +31,8 @@ import { collect } from './iterables'; import { DurationInSeconds } from './durations'; import { sanitizeLinkPreview } from '../services/LinkPreview'; import type { DraftBodyRanges } from '../types/BodyRange'; +import { postSaveUpdates } from './cleanup'; +import { MessageModel } from '../models/messages'; export async function sendStoryMessage( listIds: Array, @@ -308,11 +310,7 @@ export async function sendStoryMessage( // * Add the message to the conversation await Promise.all( distributionListMessages.map(message => { - window.MessageCache.__DEPRECATED$register( - message.id, - new window.Whisper.Message(message), - 'sendStoryMessage' - ); + window.MessageCache.register(new MessageModel(message)); void ourConversation.addSingleMessage(message, { isJustSent: true }); @@ -320,6 +318,7 @@ export async function sendStoryMessage( return DataWriter.saveMessage(message, { forceSave: true, ourAci: window.textsecure.storage.user.getCheckedAci(), + postSaveUpdates, }); }) ); @@ -359,11 +358,7 @@ export async function sendStoryMessage( timestamp: messageAttributes.timestamp, }, async jobToInsert => { - window.MessageCache.__DEPRECATED$register( - messageAttributes.id, - new window.Whisper.Message(messageAttributes), - 'sendStoryMessage' - ); + window.MessageCache.register(new MessageModel(messageAttributes)); const conversation = window.ConversationController.get(conversationId); void conversation?.addSingleMessage(messageAttributes, { @@ -377,6 +372,7 @@ export async function sendStoryMessage( forceSave: true, jobToInsert, ourAci: window.textsecure.storage.user.getCheckedAci(), + postSaveUpdates, }); } ); diff --git a/ts/window.d.ts b/ts/window.d.ts index dfc765a2e..f6139df89 100644 --- a/ts/window.d.ts +++ b/ts/window.d.ts @@ -32,7 +32,6 @@ import type { Receipt } from './types/Receipt'; import type { ConversationController } from './ConversationController'; import type { ReduxActions } from './state/types'; import type { createApp } from './state/roots/createApp'; -import type { MessageModel } from './models/messages'; import type { ConversationModel } from './models/conversations'; import type { BatcherType } from './util/batcher'; import type { ConfirmationDialog } from './components/ConfirmationDialog'; @@ -319,7 +318,6 @@ declare global { export type WhisperType = { Conversation: typeof ConversationModel; ConversationCollection: typeof ConversationModelCollectionType; - Message: typeof MessageModel; deliveryReceiptQueue: PQueue; deliveryReceiptBatcher: BatcherType; diff --git a/ts/windows/main/start.ts b/ts/windows/main/start.ts index 2c53b1f81..c6c5e2e04 100644 --- a/ts/windows/main/start.ts +++ b/ts/windows/main/start.ts @@ -65,8 +65,7 @@ if ( )?.attributes; }, getConversation: (id: string) => window.ConversationController.get(id), - getMessageById: (id: string) => - window.MessageCache.__DEPRECATED$getById(id, 'SignalDebug'), + getMessageById: (id: string) => window.MessageCache.getById(id), getMessageBySentAt: (timestamp: number) => window.MessageCache.findBySentAt(timestamp, () => true), getReduxState: () => window.reduxStore.getState(),