// Copyright 2023 Signal Messenger, LLC // SPDX-License-Identifier: AGPL-3.0-only import type { AttachmentType } from '../types/Attachment'; import type { EditAttributesType } from '../messageModifiers/Edits'; import type { EditHistoryType, MessageAttributesType, QuotedMessageType, } from '../model-types.d'; import type { LinkPreviewType } from '../types/message/LinkPreviews'; import * as Edits from '../messageModifiers/Edits'; import * as log from '../logging/log'; import { ReadStatus } from '../messages/MessageReadStatus'; import dataInterface from '../sql/Client'; import { drop } from './drop'; import { getAttachmentSignature, isVoiceMessage } from '../types/Attachment'; import { isAciString } from './isAciString'; import { getMessageIdForLogging } from './idForLogging'; import { hasErrors } from '../state/selectors/message'; import { isIncoming, isOutgoing } from '../messages/helpers'; import { isDirectConversation } from './whatTypeOfConversation'; import { isTooOldToModifyMessage } from './isTooOldToModifyMessage'; import { queueAttachmentDownloads } from './queueAttachmentDownloads'; import { modifyTargetMessage } from './modifyTargetMessage'; const RECURSION_LIMIT = 15; function getAttachmentSignatureSafe( attachment: AttachmentType ): string | undefined { try { return getAttachmentSignature(attachment); } catch { log.warn( 'handleEditMessage: attachment was missing digest', attachment.blurHash ); return undefined; } } export async function handleEditMessage( mainMessage: MessageAttributesType, editAttributes: Pick< EditAttributesType, 'message' | 'conversationId' | 'fromDevice' | 'fromId' >, recursionCount = 0 ): Promise { const idLog = `handleEditMessage(edit=${ editAttributes.message.timestamp },original=${getMessageIdForLogging(mainMessage)})`; if (recursionCount >= RECURSION_LIMIT) { log.warn(`${idLog}: Too much recursion`); return; } log.info(idLog); // Use local aci for outgoing messages and sourceServiceId for incoming. const senderAci = isOutgoing(mainMessage) ? window.storage.user.getCheckedAci() : mainMessage.sourceServiceId; if (!isAciString(senderAci)) { log.warn(`${idLog}: Cannot edit a message from PNI source`); return; } // Verify that we can safely apply an edit to this type of message if (mainMessage.deletedForEveryone) { log.warn(`${idLog}: Cannot edit a DOE message`); return; } if (mainMessage.isViewOnce) { log.warn(`${idLog}: Cannot edit an isViewOnce message`); return; } if (mainMessage.contact && mainMessage.contact.length > 0) { log.warn(`${idLog}: Cannot edit a contact share`); return; } const hasVoiceMessage = mainMessage.attachments?.some(isVoiceMessage); if (hasVoiceMessage) { log.warn(`${idLog}: Cannot edit a voice message`); return; } const { serverTimestamp } = editAttributes.message; const isNoteToSelf = mainMessage.conversationId === window.ConversationController.getOurConversationId(); if ( serverTimestamp && !isNoteToSelf && isTooOldToModifyMessage(serverTimestamp, mainMessage) ) { log.warn(`${idLog}: cannot edit message older than 48h`, serverTimestamp); return; } const mainMessageModel = window.MessageCache.__DEPRECATED$register( mainMessage.id, mainMessage, 'handleEditMessage' ); // 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 || [ { attachments: mainMessage.attachments, body: mainMessage.body, bodyRanges: mainMessage.bodyRanges, preview: mainMessage.preview, quote: mainMessage.quote, sendStateByConversationId: { ...mainMessage.sendStateByConversationId }, timestamp: mainMessage.timestamp, }, ]; // Race condition prevention check here. If we already have the timestamp // recorded as an edit we can safely drop handling this edit. const editedMessageExists = editHistory.some( edit => edit.timestamp === editAttributes.message.timestamp ); if (editedMessageExists) { log.warn(`${idLog}: edited message is duplicate. Dropping.`); return; } const upgradedEditedMessageData = await window.Signal.Migrations.upgradeMessageSchema(editAttributes.message); // Copies over the attachments from the main message if they're the same // and they have already been downloaded. const attachmentSignatures: Map = new Map(); const previewSignatures: Map = new Map(); const quoteSignatures: Map = new Map(); mainMessage.attachments?.forEach(attachment => { const signature = getAttachmentSignatureSafe(attachment); if (signature) { attachmentSignatures.set(signature, attachment); } }); mainMessage.preview?.forEach(preview => { if (!preview.image) { return; } const signature = getAttachmentSignatureSafe(preview.image); if (signature) { previewSignatures.set(signature, preview); } }); if (mainMessage.quote) { for (const attachment of mainMessage.quote.attachments) { if (!attachment.thumbnail) { continue; } const signature = getAttachmentSignatureSafe(attachment.thumbnail); if (signature) { quoteSignatures.set(signature, attachment); } } } let newAttachments = 0; const nextEditedMessageAttachments = upgradedEditedMessageData.attachments?.map(attachment => { const signature = getAttachmentSignatureSafe(attachment); const existingAttachment = signature ? attachmentSignatures.get(signature) : undefined; if (existingAttachment) { return existingAttachment; } newAttachments += 1; return attachment; }); let newPreviews = 0; const nextEditedMessagePreview = upgradedEditedMessageData.preview?.map( preview => { if (!preview.image) { return preview; } const signature = getAttachmentSignatureSafe(preview.image); const existingPreview = signature ? previewSignatures.get(signature) : undefined; if (existingPreview) { return existingPreview; } newPreviews += 1; return preview; } ); let newQuoteThumbnails = 0; const { quote: upgradedQuote } = upgradedEditedMessageData; let nextEditedMessageQuote: QuotedMessageType | undefined; if (!upgradedQuote) { // Quote dropped log.info(`${idLog}: dropping quote`); } else if (!upgradedQuote.id || upgradedQuote.id === mainMessage.quote?.id) { // Quote preserved nextEditedMessageQuote = mainMessage.quote; } else { // Quote updated! nextEditedMessageQuote = { ...upgradedQuote, attachments: upgradedQuote.attachments.map(attachment => { if (!attachment.thumbnail) { return attachment; } const signature = getAttachmentSignatureSafe(attachment.thumbnail); const existingThumbnail = signature ? quoteSignatures.get(signature) : undefined; if (existingThumbnail) { return { ...attachment, thumbnail: existingThumbnail, }; } newQuoteThumbnails += 1; return attachment; }), }; } log.info( `${idLog}: editing message, added ${newAttachments} attachments, ` + `${newPreviews} previews, ${newQuoteThumbnails} quote thumbnails` ); const editedMessage: EditHistoryType = { attachments: nextEditedMessageAttachments, body: upgradedEditedMessageData.body, bodyRanges: upgradedEditedMessageData.bodyRanges, preview: nextEditedMessagePreview, sendStateByConversationId: upgradedEditedMessageData.sendStateByConversationId, timestamp: upgradedEditedMessageData.timestamp, quote: nextEditedMessageQuote, }; // The edit history works like a queue where the newest edits are at the top. // Here we unshift the latest edit onto the edit history. editHistory = [editedMessage, ...editHistory]; // Update all the editable attributes on the main message also updating the // edit history. mainMessageModel.set({ attachments: editedMessage.attachments, body: editedMessage.body, bodyRanges: editedMessage.bodyRanges, editHistory, editMessageTimestamp: upgradedEditedMessageData.timestamp, preview: editedMessage.preview, quote: editedMessage.quote, }); // Queue up any downloads in case they're different, update the fields if so. const updatedFields = await queueAttachmentDownloads( mainMessageModel.attributes ); if (updatedFields) { mainMessageModel.set(updatedFields); } const conversation = window.ConversationController.get( editAttributes.conversationId ); // Send delivery receipts, but only for non-story sealed sender messages // and not for messages from unaccepted conversations. if ( isIncoming(upgradedEditedMessageData) && upgradedEditedMessageData.unidentifiedDeliveryReceived && !hasErrors(upgradedEditedMessageData) && 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(() => { window.Whisper.deliveryReceiptBatcher.add({ messageId: mainMessage.id, conversationId: editAttributes.conversationId, senderE164: editAttributes.message.source, senderAci, timestamp: editAttributes.message.timestamp, isDirectConversation: isDirectConversation(conversation.attributes), }); }) ); } // For incoming edits, we mark the message as unread so that we're able to // send a read receipt for the message. In case we had already sent one for // the original message. const readStatus = isOutgoing(mainMessageModel.attributes) ? ReadStatus.Read : ReadStatus.Unread; // Save both the main message and the edited message for fast lookups drop( dataInterface.saveEditedMessage( mainMessageModel.attributes, window.textsecure.storage.user.getCheckedAci(), { conversationId: editAttributes.conversationId, messageId: mainMessage.id, readStatus, sentAt: upgradedEditedMessageData.timestamp, } ) ); if (conversation) { // Clear typing indicator const typingToken = `${editAttributes.fromId}.${editAttributes.fromDevice}`; conversation.clearContactTypingTimer(typingToken); } const mainMessageConversation = mainMessageModel.getConversation(); if (mainMessageConversation) { drop(mainMessageConversation.updateLastMessage()); // Apply any other operations, excluding edits that target this message await modifyTargetMessage(mainMessageModel, mainMessageConversation, { isFirstRun: false, skipEdits: true, }); } // Apply any other pending edits that target this message const edits = Edits.forMessage({ ...mainMessage, sent_at: editedMessage.timestamp, timestamp: editedMessage.timestamp, }); log.info(`${idLog}: ${edits.length} edits`); await Promise.all( edits.map(edit => handleEditMessage(mainMessageModel.attributes, edit, recursionCount + 1) ) ); }