// 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 } from '../model-types.d'; import type { LinkPreviewType } from '../types/message/LinkPreviews'; import * as log from '../logging/log'; import { ReadStatus } from '../messages/MessageReadStatus'; import dataInterface from '../sql/Client'; import { drop } from './drop'; import { getAttachmentSignature, isDownloaded, isVoiceMessage, } from '../types/Attachment'; import { getMessageIdForLogging } from './idForLogging'; import { isOutgoing } from '../messages/helpers'; import { queueAttachmentDownloads } from './queueAttachmentDownloads'; import { shouldReplyNotifyUser } from './shouldReplyNotifyUser'; export async function handleEditMessage( mainMessage: MessageAttributesType, editAttributes: EditAttributesType ): Promise { const idLog = `handleEditMessage(${getMessageIdForLogging(mainMessage)})`; // 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 mainMessageModel = window.MessageController.register( mainMessage.id, 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. const editHistory: Array = mainMessage.editHistory || [ { attachments: mainMessage.attachments, body: mainMessage.body, bodyRanges: mainMessage.bodyRanges, preview: mainMessage.preview, 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 messageAttributesForUpgrade: MessageAttributesType = { ...editAttributes.message, ...editAttributes.dataMessage, // There are type conflicts between MessageAttributesType and protos passed in here // eslint-disable-next-line @typescript-eslint/no-explicit-any } as any as MessageAttributesType; const upgradedEditedMessageData = await window.Signal.Migrations.upgradeMessageSchema( messageAttributesForUpgrade ); // 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(); mainMessage.attachments?.forEach(attachment => { if (!isDownloaded(attachment)) { return; } const signature = getAttachmentSignature(attachment); attachmentSignatures.set(signature, attachment); }); mainMessage.preview?.forEach(preview => { if (!preview.image || !isDownloaded(preview.image)) { return; } const signature = getAttachmentSignature(preview.image); previewSignatures.set(signature, preview); }); const nextEditedMessageAttachments = upgradedEditedMessageData.attachments?.map(attachment => { const signature = getAttachmentSignature(attachment); const existingAttachment = attachmentSignatures.get(signature); return existingAttachment || attachment; }); const nextEditedMessagePreview = upgradedEditedMessageData.preview?.map( preview => { if (!preview.image) { return preview; } const signature = getAttachmentSignature(preview.image); const existingPreview = previewSignatures.get(signature); return existingPreview || preview; } ); const editedMessage: EditHistoryType = { attachments: nextEditedMessageAttachments, body: upgradedEditedMessageData.body, bodyRanges: upgradedEditedMessageData.bodyRanges, preview: nextEditedMessagePreview, timestamp: upgradedEditedMessageData.timestamp, }; // 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.unshift(editedMessage); // 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, }); // 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); } // 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.getCheckedUuid().toString(), { fromId: editAttributes.fromId, messageId: mainMessage.id, readStatus, sentAt: upgradedEditedMessageData.timestamp, } ) ); drop(mainMessageModel.getConversation()?.updateLastMessage()); // Update notifications const conversation = mainMessageModel.getConversation(); if (!conversation) { return; } if (await shouldReplyNotifyUser(mainMessageModel, conversation)) { await conversation.notify(mainMessageModel); } }