From 36e21c0134607d7cc8d29eaa6ef06fe9e9a385f9 Mon Sep 17 00:00:00 2001 From: Josh Perez <60019601+josh-signal@users.noreply.github.com> Date: Mon, 27 Mar 2023 19:48:57 -0400 Subject: [PATCH] Receive support for editing messages --- _locales/en/messages.json | 8 + package.json | 2 +- protos/SignalService.proto | 7 + stylesheets/_modules.scss | 17 ++ ts/background.ts | 54 ++++ ts/components/EditHistoryMessagesModal.tsx | 112 +++++++ ts/components/GlobalModalContainer.tsx | 17 +- ts/components/conversation/Message.tsx | 6 + .../conversation/MessageMetadata.tsx | 13 + ts/messageModifiers/AttachmentDownloads.ts | 103 ++++++- ts/messageModifiers/Edits.ts | 104 +++++++ ts/messageModifiers/MessageReceipts.ts | 7 +- ts/messageModifiers/ReadSyncs.ts | 7 +- ts/messageModifiers/ViewSyncs.ts | 7 +- ts/model-types.d.ts | 10 + ts/models/conversations.ts | 122 +------- ts/models/messages.ts | 113 ++----- ts/services/MessageUpdater.ts | 3 +- ts/services/notifications.ts | 3 + ts/sql/Interface.ts | 22 ++ ts/sql/Server.ts | 138 +++++++++ ts/sql/migrations/80-edited-messages.ts | 34 +++ ts/sql/migrations/index.ts | 3 + ts/state/ducks/composer.ts | 3 +- ts/state/ducks/conversations.ts | 10 +- ts/state/ducks/globalModals.ts | 247 ++++++++++++--- ts/state/selectors/message.ts | 5 + ts/state/smart/EditHistoryMessagesModal.tsx | 57 ++++ ts/state/smart/GlobalModalContainer.tsx | 8 + ts/state/smart/TimelineItem.tsx | 2 + ts/test-mock/messaging/edit_test.ts | 126 ++++++++ ts/textsecure/MessageReceiver.ts | 155 +++++++++- ts/textsecure/Types.d.ts | 3 +- ts/textsecure/downloadAttachment.ts | 2 +- ts/types/Attachment.ts | 10 +- ts/types/Message2.ts | 46 ++- ts/util/attachments.ts | 4 +- ts/util/getQuoteBodyText.ts | 28 ++ ts/util/handleEditMessage.ts | 191 ++++++++++++ ts/util/hasAttachmentDownloads.ts | 66 ++-- ts/util/lint/exceptions.json | 21 +- ts/util/makeQuote.ts | 149 +++++++++ ts/util/markConversationRead.ts | 39 ++- ts/util/queueAttachmentDownloads.ts | 284 +++++++++++++----- ts/util/shouldReplyNotifyUser.ts | 82 +++++ yarn.lock | 8 +- 46 files changed, 2053 insertions(+), 405 deletions(-) create mode 100644 ts/components/EditHistoryMessagesModal.tsx create mode 100644 ts/messageModifiers/Edits.ts create mode 100644 ts/sql/migrations/80-edited-messages.ts create mode 100644 ts/state/smart/EditHistoryMessagesModal.tsx create mode 100644 ts/test-mock/messaging/edit_test.ts create mode 100644 ts/util/getQuoteBodyText.ts create mode 100644 ts/util/handleEditMessage.ts create mode 100644 ts/util/makeQuote.ts create mode 100644 ts/util/shouldReplyNotifyUser.ts diff --git a/_locales/en/messages.json b/_locales/en/messages.json index cb1ba6eae..461ab88d4 100644 --- a/_locales/en/messages.json +++ b/_locales/en/messages.json @@ -6463,6 +6463,14 @@ "messageformat": "Signal desktop no longer works on this computer. To use Signal desktop again, update your computer’s version of {OS}.", "description": "Body of a dialog displayed on unsupported operating systems" }, + "icu:MessageMetadata__edited": { + "messageformat": "edited", + "description": "label for an edited message" + }, + "icu:EditHistoryMessagesModal__title": { + "messageformat": "Edit history", + "description": "Modal title for the edit history messages modal" + }, "WhatsNew__modal-title": { "message": "What's New", "description": "Title for the whats new modal" diff --git a/package.json b/package.json index e9d6ec5bd..b2bff193a 100644 --- a/package.json +++ b/package.json @@ -182,7 +182,7 @@ "@electron/fuses": "1.5.0", "@formatjs/intl": "2.6.7", "@mixer/parallel-prettier": "2.0.3", - "@signalapp/mock-server": "2.15.0", + "@signalapp/mock-server": "2.17.0", "@storybook/addon-a11y": "6.5.6", "@storybook/addon-actions": "6.5.6", "@storybook/addon-controls": "6.5.6", diff --git a/protos/SignalService.proto b/protos/SignalService.proto index 82daffc90..5e5dcf1b9 100644 --- a/protos/SignalService.proto +++ b/protos/SignalService.proto @@ -51,6 +51,7 @@ message Content { optional bytes decryptionErrorMessage = 8; optional StoryMessage storyMessage = 9; optional PniSignatureMessage pniSignatureMessage = 10; + optional EditMessage editMessage = 11; } // Everything in CallingMessage must be kept in sync with RingRTC (ringrtc-node). @@ -464,6 +465,7 @@ message SyncMessage { optional bool isRecipientUpdate = 6 [default = false]; optional StoryMessage storyMessage = 8; repeated StoryMessageRecipient storyMessageRecipients = 9; + optional EditMessage editMessage = 10; } message Contacts { @@ -720,3 +722,8 @@ message PniSignatureMessage { // Signature *by* the PNI identity key *of* the ACI identity key optional bytes signature = 2; } + +message EditMessage { + optional uint64 targetSentTimestamp = 1; + optional DataMessage dataMessage = 2; +} diff --git a/stylesheets/_modules.scss b/stylesheets/_modules.scss index 138ec5044..d3bd1d98d 100644 --- a/stylesheets/_modules.scss +++ b/stylesheets/_modules.scss @@ -1112,6 +1112,23 @@ $message-padding-horizontal: 12px; } } +.module-message__metadata__edited { + @include button-reset; + @include font-caption; + color: $color-gray-60; + cursor: pointer; + margin-right: 6px; + z-index: $z-index-base; + + @include dark-theme { + color: $color-gray-25; + } +} + +.module-message__container--outgoing .module-message__metadata__edited { + color: $color-white-alpha-80; +} + // With an image and no caption, this section needs to be on top of the image overlay .module-message__metadata--with-image-no-caption { position: absolute; diff --git a/ts/background.ts b/ts/background.ts index 94ee1d23a..27129d876 100644 --- a/ts/background.ts +++ b/ts/background.ts @@ -114,6 +114,8 @@ import { areAnyCallsActiveOrRinging } from './state/selectors/calling'; import { badgeImageFileDownloader } from './badges/badgeImageFileDownloader'; import { actionCreators } from './state/actions'; import { Deletes } from './messageModifiers/Deletes'; +import type { EditAttributesType } from './messageModifiers/Edits'; +import * as Edits from './messageModifiers/Edits'; import { MessageReceipts, MessageReceiptType, @@ -3069,6 +3071,35 @@ export async function startApp(): Promise { return; } + if (data.message.editedMessageTimestamp) { + const { editedMessageTimestamp } = data.message; + + strictAssert(editedMessageTimestamp, 'Edit missing targetSentTimestamp'); + const fromConversation = window.ConversationController.lookupOrCreate({ + e164: data.source, + uuid: data.sourceUuid, + reason: 'onMessageReceived:edit', + }); + strictAssert(fromConversation, 'Edit missing fromConversation'); + + log.info('Queuing incoming edit for', { + editedMessageTimestamp, + sentAt: data.timestamp, + }); + + const editAttributes: EditAttributesType = { + dataMessage: data.message, + fromId: fromConversation.id, + message: message.attributes, + targetSentTimestamp: editedMessageTimestamp, + }; + + drop(Edits.onEdit(editAttributes)); + + confirm(); + return; + } + if (handleGroupCallUpdateMessage(data.message, messageDescriptor)) { confirm(); return; @@ -3415,6 +3446,29 @@ export async function startApp(): Promise { return; } + if (data.message.editedMessageTimestamp) { + const { editedMessageTimestamp } = data.message; + + strictAssert(editedMessageTimestamp, 'Edit missing targetSentTimestamp'); + + log.info('Queuing sent edit for', { + editedMessageTimestamp, + sentAt: data.timestamp, + }); + + const editAttributes: EditAttributesType = { + dataMessage: data.message, + fromId: window.ConversationController.getOurConversationIdOrThrow(), + message: message.attributes, + targetSentTimestamp: editedMessageTimestamp, + }; + + drop(Edits.onEdit(editAttributes)); + + confirm(); + return; + } + if (handleGroupCallUpdateMessage(data.message, messageDescriptor)) { event.confirm(); return; diff --git a/ts/components/EditHistoryMessagesModal.tsx b/ts/components/EditHistoryMessagesModal.tsx new file mode 100644 index 000000000..9a57b8b50 --- /dev/null +++ b/ts/components/EditHistoryMessagesModal.tsx @@ -0,0 +1,112 @@ +// Copyright 2023 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +import React, { useCallback, useRef } from 'react'; +import { noop } from 'lodash'; + +import type { AttachmentType } from '../types/Attachment'; +import type { LocalizerType } from '../types/Util'; +import type { MessagePropsType } from '../state/selectors/message'; +import type { PreferredBadgeSelectorType } from '../state/selectors/badges'; +import { Message, TextDirection } from './conversation/Message'; +import { Modal } from './Modal'; +import { WidthBreakpoint } from './_util'; +import { shouldNeverBeCalled } from '../util/shouldNeverBeCalled'; +import { useTheme } from '../hooks/useTheme'; + +export type PropsType = { + closeEditHistoryModal: () => unknown; + editHistoryMessages: Array; + getPreferredBadge: PreferredBadgeSelectorType; + i18n: LocalizerType; + kickOffAttachmentDownload: (options: { + attachment: AttachmentType; + messageId: string; + }) => void; + showLightbox: (options: { + attachment: AttachmentType; + messageId: string; + }) => void; +}; + +const MESSAGE_DEFAULT_PROPS = { + canDeleteForEveryone: false, + checkForAccount: shouldNeverBeCalled, + clearSelectedMessage: shouldNeverBeCalled, + clearTargetedMessage: shouldNeverBeCalled, + containerWidthBreakpoint: WidthBreakpoint.Medium, + doubleCheckMissingQuoteReference: shouldNeverBeCalled, + interactionMode: 'mouse' as const, + isBlocked: false, + isMessageRequestAccepted: true, + markAttachmentAsCorrupted: shouldNeverBeCalled, + messageExpanded: shouldNeverBeCalled, + onReplyToMessage: shouldNeverBeCalled, + onToggleSelect: shouldNeverBeCalled, + openGiftBadge: shouldNeverBeCalled, + openLink: shouldNeverBeCalled, + previews: [], + pushPanelForConversation: shouldNeverBeCalled, + renderAudioAttachment: () =>
, + renderingContext: 'EditHistoryMessagesModal', + saveAttachment: shouldNeverBeCalled, + scrollToQuotedMessage: shouldNeverBeCalled, + shouldCollapseAbove: false, + shouldCollapseBelow: false, + shouldHideMetadata: true, + showContactModal: shouldNeverBeCalled, + showConversation: noop, + showEditHistoryModal: shouldNeverBeCalled, + showExpiredIncomingTapToViewToast: shouldNeverBeCalled, + showExpiredOutgoingTapToViewToast: shouldNeverBeCalled, + showLightboxForViewOnceMedia: shouldNeverBeCalled, + startConversation: shouldNeverBeCalled, + textDirection: TextDirection.Default, + viewStory: shouldNeverBeCalled, +}; + +export function EditHistoryMessagesModal({ + closeEditHistoryModal, + getPreferredBadge, + editHistoryMessages, + i18n, + kickOffAttachmentDownload, + showLightbox, +}: PropsType): JSX.Element { + const containerElementRef = useRef(null); + const theme = useTheme(); + + const closeAndShowLightbox = useCallback( + (options: { attachment: AttachmentType; messageId: string }) => { + closeEditHistoryModal(); + showLightbox(options); + }, + [closeEditHistoryModal, showLightbox] + ); + + return ( + +
+ {editHistoryMessages.map(messageAttributes => ( + + ))} +
+
+ ); +} diff --git a/ts/components/GlobalModalContainer.tsx b/ts/components/GlobalModalContainer.tsx index 7bf25ae0c..1ccd82a06 100644 --- a/ts/components/GlobalModalContainer.tsx +++ b/ts/components/GlobalModalContainer.tsx @@ -3,11 +3,12 @@ import React from 'react'; import type { - ContactModalStateType, - UserNotFoundModalStateType, - SafetyNumberChangedBlockingDataType, AuthorizeArtCreatorDataType, + ContactModalStateType, + EditHistoryMessagesType, ForwardMessagesPropsType, + SafetyNumberChangedBlockingDataType, + UserNotFoundModalStateType, } from '../state/ducks/globalModals'; import type { LocalizerType, ThemeType } from '../types/Util'; import { missingCaseError } from '../util/missingCaseError'; @@ -28,6 +29,9 @@ export type PropsType = { // ContactModal contactModalState: ContactModalStateType | undefined; renderContactModal: () => JSX.Element; + // EditHistoryMessagesModal + editHistoryMessages: EditHistoryMessagesType | undefined; + renderEditHistoryMessagesModal: () => JSX.Element; // ErrorModal errorModalProps: { description?: string; title?: string } | undefined; renderErrorModal: (opts: { @@ -82,6 +86,9 @@ export function GlobalModalContainer({ // ContactModal contactModalState, renderContactModal, + // EditHistoryMessages + editHistoryMessages, + renderEditHistoryMessagesModal, // ErrorModal errorModalProps, renderErrorModal, @@ -147,6 +154,10 @@ export function GlobalModalContainer({ return renderContactModal(); } + if (editHistoryMessages) { + return renderEditHistoryMessagesModal(); + } + if (forwardMessagesProps) { return renderForwardMessagesModal(); } diff --git a/ts/components/conversation/Message.tsx b/ts/components/conversation/Message.tsx index 56e76913f..d5f4bdb53 100644 --- a/ts/components/conversation/Message.tsx +++ b/ts/components/conversation/Message.tsx @@ -207,6 +207,7 @@ export type PropsData = { text?: string; textDirection: TextDirection; textAttachment?: AttachmentType; + isEditedMessage?: boolean; isSticker?: boolean; isTargeted?: boolean; isTargetedCounter?: number; @@ -338,6 +339,7 @@ export type PropsActions = { }) => void; targetMessage?: (messageId: string, conversationId: string) => unknown; + showEditHistoryModal?: (id: string) => unknown; showExpiredIncomingTapToViewToast: () => unknown; showExpiredOutgoingTapToViewToast: () => unknown; viewStory: ViewStoryActionCreatorType; @@ -768,9 +770,11 @@ export class Message extends React.PureComponent { expirationTimestamp, i18n, id, + isEditedMessage, isSticker, isTapToViewExpired, pushPanelForConversation, + showEditHistoryModal, status, text, textAttachment, @@ -788,12 +792,14 @@ export class Message extends React.PureComponent { hasText={Boolean(text)} i18n={i18n} id={id} + isEditedMessage={isEditedMessage} isInline={isInline} isShowingImage={this.isShowingImage()} isSticker={isStickerLike} isTapToViewExpired={isTapToViewExpired} onWidthMeasured={isInline ? this.updateMetadataWidth : undefined} pushPanelForConversation={pushPanelForConversation} + showEditHistoryModal={showEditHistoryModal} status={status} textPending={textAttachment?.pending} timestamp={timestamp} diff --git a/ts/components/conversation/MessageMetadata.tsx b/ts/components/conversation/MessageMetadata.tsx index 9a612c0fb..79f167a19 100644 --- a/ts/components/conversation/MessageMetadata.tsx +++ b/ts/components/conversation/MessageMetadata.tsx @@ -22,12 +22,14 @@ type PropsType = { hasText: boolean; i18n: LocalizerType; id: string; + isEditedMessage?: boolean; isInline?: boolean; isShowingImage: boolean; isSticker?: boolean; isTapToViewExpired?: boolean; onWidthMeasured?: (width: number) => unknown; pushPanelForConversation: PushPanelForConversationActionType; + showEditHistoryModal?: (id: string) => unknown; status?: MessageStatusType; textPending?: boolean; timestamp: number; @@ -41,12 +43,14 @@ export function MessageMetadata({ hasText, i18n, id, + isEditedMessage, isInline, isShowingImage, isSticker, isTapToViewExpired, onWidthMeasured, pushPanelForConversation, + showEditHistoryModal, status, textPending, timestamp, @@ -130,6 +134,15 @@ export function MessageMetadata({ ); const children = ( <> + {isEditedMessage && showEditHistoryModal && ( + + )} {timestampNode} {expirationLength ? ( { + if (!edit.attachments) { + return edit; + } + + return { + ...edit, + // Loop through all the attachments to find the attachment we intend + // to replace. + attachments: edit.attachments.map(editAttachment => { + if (isDownloaded(editAttachment)) { + return editAttachment; + } + + if ( + attachmentSignature !== getAttachmentSignature(editAttachment) + ) { + return editAttachment; + } + + handledInEditHistory = true; + + return attachment; + }), + }; + }); + + if (newEditHistory !== editHistory) { + message.set({ editHistory: newEditHistory }); + } + } + if (!attachments || attachments.length <= index) { throw new Error( - `_addAttachmentToMessage: attachments didn't exist or ${index} was too large` + `_addAttachmentToMessage: attachments didn't exist or index(${index}) was too large` ); } + + // Verify attachment is still valid + const isSameAttachment = + attachments[index] && + getAttachmentSignature(attachments[index]) === attachmentSignature; + if (handledInEditHistory && !isSameAttachment) { + return; + } + strictAssert(isSameAttachment, `${logPrefix} mismatched attachment`); _checkOldAttachment(attachments, index.toString(), logPrefix); + // Replace attachment const newAttachments = [...attachments]; newAttachments[index] = attachment; @@ -499,6 +548,48 @@ async function _addAttachmentToMessage( if (type === 'preview') { const preview = message.get('preview'); + + let handledInEditHistory = false; + + const editHistory = message.get('editHistory'); + if (preview && editHistory) { + const newEditHistory = editHistory.map(edit => { + if (!edit.preview || edit.preview.length <= index) { + return edit; + } + + const item = edit.preview[index]; + if (!item) { + return edit; + } + + if ( + item.image && + (isDownloaded(item.image) || + attachmentSignature !== getAttachmentSignature(item.image)) + ) { + return edit; + } + + const newPreview = [...edit.preview]; + newPreview[index] = { + ...edit.preview[index], + image: attachment, + }; + + handledInEditHistory = true; + + return { + ...edit, + preview: newPreview, + }; + }); + + if (newEditHistory !== editHistory) { + message.set({ editHistory: newEditHistory }); + } + } + if (!preview || preview.length <= index) { throw new Error( `_addAttachmentToMessage: preview didn't exist or ${index} was too large` @@ -509,8 +600,16 @@ async function _addAttachmentToMessage( throw new Error(`_addAttachmentToMessage: preview ${index} was falsey`); } + // Verify attachment is still valid + const isSameAttachment = + item.image && getAttachmentSignature(item.image) === attachmentSignature; + if (handledInEditHistory && !isSameAttachment) { + return; + } + strictAssert(isSameAttachment, `${logPrefix} mismatched attachment`); _checkOldAttachment(item, 'image', logPrefix); + // Replace attachment const newPreview = [...preview]; newPreview[index] = { ...preview[index], diff --git a/ts/messageModifiers/Edits.ts b/ts/messageModifiers/Edits.ts new file mode 100644 index 000000000..18247cc14 --- /dev/null +++ b/ts/messageModifiers/Edits.ts @@ -0,0 +1,104 @@ +// Copyright 2023 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +import type { MessageAttributesType } from '../model-types.d'; +import type { MessageModel } from '../models/messages'; +import type { ProcessedDataMessage } from '../textsecure/Types.d'; +import * as Errors from '../types/errors'; +import * as log from '../logging/log'; +import { drop } from '../util/drop'; +import { filter, size } from '../util/iterables'; +import { getContactId } from '../messages/helpers'; +import { handleEditMessage } from '../util/handleEditMessage'; + +export type EditAttributesType = { + dataMessage: ProcessedDataMessage; + fromId: string; + message: MessageAttributesType; + targetSentTimestamp: number; +}; + +const edits = new Set(); + +export function forMessage(message: MessageModel): Array { + const matchingEdits = filter(edits, item => { + return ( + item.targetSentTimestamp === message.get('sent_at') && + item.fromId === getContactId(message.attributes) + ); + }); + + if (size(matchingEdits) > 0) { + log.info('Edits.forMessage: Found early edit for message'); + filter(matchingEdits, item => edits.delete(item)); + return Array.from(matchingEdits); + } + + return []; +} + +export async function onEdit(edit: EditAttributesType): Promise { + edits.add(edit); + + try { + // The conversation the deleted message was in; we have to find it in the database + // to to figure that out. + const targetConversation = + await window.ConversationController.getConversationForTargetMessage( + edit.fromId, + edit.targetSentTimestamp + ); + + if (!targetConversation) { + log.info( + 'No target conversation for edit', + edit.fromId, + edit.targetSentTimestamp + ); + + return; + } + + // Do not await, since this can deadlock the queue + drop( + targetConversation.queueJob('Edits.onEdit', async () => { + log.info('Handling edit for', { + targetSentTimestamp: edit.targetSentTimestamp, + sentAt: edit.dataMessage.timestamp, + }); + + const messages = await window.Signal.Data.getMessagesBySentAt( + edit.targetSentTimestamp + ); + + // Verify authorship + const targetMessage = messages.find( + m => + edit.message.conversationId === m.conversationId && + edit.fromId === getContactId(m) + ); + + if (!targetMessage) { + log.info( + 'No message for edit', + edit.fromId, + edit.targetSentTimestamp + ); + + return; + } + + const message = window.MessageController.register( + targetMessage.id, + targetMessage + ); + + await handleEditMessage(message.attributes, edit); + + edits.delete(edit); + }) + ); + } catch (error) { + log.error('Edits.onEdit error:', Errors.toLogFormat(error)); + } +} diff --git a/ts/messageModifiers/MessageReceipts.ts b/ts/messageModifiers/MessageReceipts.ts index 95f86367b..c930897d6 100644 --- a/ts/messageModifiers/MessageReceipts.ts +++ b/ts/messageModifiers/MessageReceipts.ts @@ -285,9 +285,10 @@ export class MessageReceipts extends Collection { const type = receipt.get('type'); try { - const messages = await window.Signal.Data.getMessagesBySentAt( - messageSentAt - ); + const messages = + await window.Signal.Data.getMessagesIncludingEditedBySentAt( + messageSentAt + ); const message = await getTargetMessage( sourceConversationId, diff --git a/ts/messageModifiers/ReadSyncs.ts b/ts/messageModifiers/ReadSyncs.ts index bebe0ee13..9ee8642c0 100644 --- a/ts/messageModifiers/ReadSyncs.ts +++ b/ts/messageModifiers/ReadSyncs.ts @@ -82,9 +82,10 @@ export class ReadSyncs extends Collection { async onSync(sync: ReadSyncModel): Promise { try { - const messages = await window.Signal.Data.getMessagesBySentAt( - sync.get('timestamp') - ); + const messages = + await window.Signal.Data.getMessagesIncludingEditedBySentAt( + sync.get('timestamp') + ); const found = messages.find(item => { const sender = window.ConversationController.lookupOrCreate({ diff --git a/ts/messageModifiers/ViewSyncs.ts b/ts/messageModifiers/ViewSyncs.ts index a8630f42d..63202ea47 100644 --- a/ts/messageModifiers/ViewSyncs.ts +++ b/ts/messageModifiers/ViewSyncs.ts @@ -98,7 +98,12 @@ export class ViewSyncs extends Collection { const attachments = message.get('attachments'); if (!attachments?.every(isDownloaded)) { - void queueAttachmentDownloads(message.attributes); + const updatedFields = await queueAttachmentDownloads( + message.attributes + ); + if (updatedFields) { + message.set(updatedFields); + } } } diff --git a/ts/model-types.d.ts b/ts/model-types.d.ts index 9473713d6..c43070da1 100644 --- a/ts/model-types.d.ts +++ b/ts/model-types.d.ts @@ -120,6 +120,14 @@ export type MessageReactionType = { isSentByConversationId?: Record; }; +export type EditHistoryType = { + attachments?: Array; + body?: string; + bodyRanges?: BodyRangesType; + preview?: Array; + timestamp: number; +}; + export type MessageAttributesType = { bodyAttachment?: AttachmentType; bodyRanges?: BodyRangesType; @@ -141,6 +149,8 @@ export type MessageAttributesType = { isErased?: boolean; isTapToViewInvalid?: boolean; isViewOnce?: boolean; + editHistory?: Array; + editMessageTimestamp?: number; key_changed?: string; local?: boolean; logger?: unknown; diff --git a/ts/models/conversations.ts b/ts/models/conversations.ts index 5bc82131a..1206c627f 100644 --- a/ts/models/conversations.ts +++ b/ts/models/conversations.ts @@ -31,7 +31,7 @@ import { normalizeUuid } from '../util/normalizeUuid'; import { clearTimeoutIfNecessary } from '../util/clearTimeoutIfNecessary'; import type { AttachmentType, ThumbnailType } from '../types/Attachment'; import { toDayMillis } from '../util/timestamp'; -import { isGIF, isVoiceMessage } from '../types/Attachment'; +import { isVoiceMessage } from '../types/Attachment'; import type { CallHistoryDetailsType } from '../types/Calling'; import { CallMode } from '../types/Calling'; import * as Conversation from '../types/Conversation'; @@ -73,7 +73,7 @@ import { sniffImageMimeType } from '../util/sniffImageMimeType'; import { isValidE164 } from '../util/isValidE164'; import { canConversationBeUnarchived } from '../util/canConversationBeUnarchived'; import type { MIMEType } from '../types/MIME'; -import { IMAGE_JPEG, IMAGE_GIF, IMAGE_WEBP } from '../types/MIME'; +import { IMAGE_JPEG, IMAGE_WEBP } from '../types/MIME'; import { UUID, UUIDKind } from '../types/UUID'; import type { UUIDStringType } from '../types/UUID'; import { @@ -108,15 +108,7 @@ import { ReadStatus } from '../messages/MessageReadStatus'; import { SendStatus } from '../messages/MessageSendState'; import type { LinkPreviewType } from '../types/message/LinkPreviews'; import { MINUTE, SECOND, DurationInSeconds } from '../util/durations'; -import { - concat, - filter, - map, - take, - repeat, - zipObject, - collect, -} from '../util/iterables'; +import { concat, filter, map, repeat, zipObject } from '../util/iterables'; import * as universalExpireTimer from '../util/universalExpireTimer'; import type { GroupNameCollisionsWithIdsByTitle } from '../util/groupMemberNameCollisions'; import { @@ -130,10 +122,8 @@ import { SignalService as Proto } from '../protobuf'; import { getMessagePropStatus, hasErrors, - isGiftBadge, isIncoming, isStory, - isTapToView, } from '../state/selectors/message'; import { conversationJobQueue, @@ -162,6 +152,7 @@ import { removePendingMember } from '../util/removePendingMember'; import { isMemberPending } from '../util/isMemberPending'; import { imageToBlurHash } from '../util/imageToBlurHash'; import { ReceiptType } from '../types/Receipt'; +import { getQuoteAttachment } from '../util/makeQuote'; const EMPTY_ARRAY: Readonly<[]> = []; const EMPTY_GROUP_COLLISIONS: GroupNameCollisionsWithIdsByTitle = {}; @@ -175,7 +166,6 @@ const { deleteAttachmentData, doesAttachmentExist, getAbsoluteAttachmentPath, - loadAttachmentData, readStickerData, upgradeMessageSchema, writeNewAttachmentData, @@ -3860,109 +3850,7 @@ export class ConversationModel extends window.Backbone thumbnail: ThumbnailType | null; }> > { - if (attachments && attachments.length) { - const attachmentsToUse = Array.from(take(attachments, 1)); - const isGIFQuote = isGIF(attachmentsToUse); - - return Promise.all( - map(attachmentsToUse, async attachment => { - const { path, fileName, thumbnail, contentType } = attachment; - - if (!path) { - return { - contentType: isGIFQuote ? IMAGE_GIF : contentType, - // Our protos library complains about this field being undefined, so we - // force it to null - fileName: fileName || null, - thumbnail: null, - }; - } - - return { - contentType: isGIFQuote ? IMAGE_GIF : contentType, - // Our protos library complains about this field being undefined, so we force - // it to null - fileName: fileName || null, - thumbnail: thumbnail - ? { - ...(await loadAttachmentData(thumbnail)), - objectUrl: thumbnail.path - ? getAbsoluteAttachmentPath(thumbnail.path) - : undefined, - } - : null, - }; - }) - ); - } - - if (preview && preview.length) { - const previewImages = collect(preview, prev => prev.image); - const previewImagesToUse = take(previewImages, 1); - - return Promise.all( - map(previewImagesToUse, async image => { - const { contentType } = image; - - return { - contentType, - // Our protos library complains about this field being undefined, so we - // force it to null - fileName: null, - thumbnail: image - ? { - ...(await loadAttachmentData(image)), - objectUrl: image.path - ? getAbsoluteAttachmentPath(image.path) - : undefined, - } - : null, - }; - }) - ); - } - - if (sticker && sticker.data && sticker.data.path) { - const { path, contentType } = sticker.data; - - return [ - { - contentType, - // Our protos library complains about this field being undefined, so we - // force it to null - fileName: null, - thumbnail: { - ...(await loadAttachmentData(sticker.data)), - objectUrl: path ? getAbsoluteAttachmentPath(path) : undefined, - }, - }, - ]; - } - - return []; - } - - async makeQuote(quotedMessage: MessageModel): Promise { - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - const contact = getContact(quotedMessage.attributes)!; - const attachments = quotedMessage.get('attachments'); - const preview = quotedMessage.get('preview'); - const sticker = quotedMessage.get('sticker'); - - return { - authorUuid: contact.get('uuid'), - attachments: isTapToView(quotedMessage.attributes) - ? [{ contentType: IMAGE_JPEG, fileName: null }] - : await this.getQuoteAttachment(attachments, preview, sticker), - payment: quotedMessage.get('payment'), - bodyRanges: quotedMessage.get('bodyRanges'), - id: quotedMessage.get('sent_at'), - isViewOnce: isTapToView(quotedMessage.attributes), - isGiftBadge: isGiftBadge(quotedMessage.attributes), - messageId: quotedMessage.get('id'), - referencedMessageNotFound: false, - text: quotedMessage.getQuoteBodyText(), - }; + return getQuoteAttachment(attachments, preview, sticker); } async sendStickerMessage(packId: string, stickerId: number): Promise { diff --git a/ts/models/messages.ts b/ts/models/messages.ts index 7c1edb9e5..afcc29a22 100644 --- a/ts/models/messages.ts +++ b/ts/models/messages.ts @@ -80,7 +80,6 @@ import { migrateLegacySendAttributes } from '../messages/migrateLegacySendAttrib import { getOwn } from '../util/getOwn'; import { markRead, markViewed } from '../services/MessageUpdater'; import { scheduleOptimizeFTS } from '../services/ftsOptimizer'; -import { isMessageUnread } from '../util/isMessageUnread'; import { isDirectConversation, isGroup, @@ -181,78 +180,10 @@ import { } from '../util/attachmentDownloadQueue'; import { getTitleNoDefault, getNumber } from '../util/getTitle'; import dataInterface from '../sql/Client'; - -function isSameUuid( - a: UUID | string | null | undefined, - b: UUID | string | null | undefined -): boolean { - return a != null && b != null && String(a) === String(b); -} - -async function shouldReplyNotifyUser( - message: MessageModel, - conversation: ConversationModel -): Promise { - // Don't notify if the message has already been read - if (!isMessageUnread(message.attributes)) { - return false; - } - - const storyId = message.get('storyId'); - - // If this is not a reply to a story, always notify. - if (storyId == null) { - return true; - } - - // Always notify if this is not a group - if (!isGroup(conversation.attributes)) { - return true; - } - - const matchedStory = window.reduxStore - .getState() - .stories.stories.find(story => { - return story.messageId === storyId; - }); - - // If we can't find the story, don't notify - if (matchedStory == null) { - log.warn("Couldn't find story for reply"); - return false; - } - - const currentUserId = window.textsecure.storage.user.getUuid(); - const storySourceId = matchedStory.sourceUuid; - - const currentUserIdSource = isSameUuid(storySourceId, currentUserId); - - // If the story is from the current user, always notify - if (currentUserIdSource) { - return true; - } - - // If the story is from a different user, only notify if the user has - // replied or reacted to the story - - const replies = await dataInterface.getOlderMessagesByConversation({ - conversationId: conversation.id, - limit: 9000, - storyId, - includeStoryReplies: true, - }); - - const prevCurrentUserReply = replies.find(replyMessage => { - return replyMessage.type === 'outgoing'; - }); - - if (prevCurrentUserReply != null) { - return true; - } - - // Otherwise don't notify - return false; -} +import * as Edits from '../messageModifiers/Edits'; +import { handleEditMessage } from '../util/handleEditMessage'; +import { getQuoteBodyText } from '../util/getQuoteBodyText'; +import { shouldReplyNotifyUser } from '../util/shouldReplyNotifyUser'; /* eslint-disable more/no-then */ @@ -1184,14 +1115,15 @@ export class MessageModel extends window.Backbone.Model { } this.set({ - isErased: true, + attachments: [], body: '', bodyRanges: undefined, - attachments: [], - quote: undefined, contact: [], - sticker: undefined, + editHistory: undefined, + isErased: true, preview: [], + quote: undefined, + sticker: undefined, ...additionalProperties, }); this.getConversation()?.debouncedUpdateLastMessage?.(); @@ -2045,7 +1977,8 @@ export class MessageModel extends window.Backbone.Model { queryMessage = matchingMessage; } else { log.info('copyFromQuotedMessage: db lookup needed', id); - const messages = await window.Signal.Data.getMessagesBySentAt(id); + const messages = + await window.Signal.Data.getMessagesIncludingEditedBySentAt(id); const found = messages.find(item => isQuoteAMatch(item, conversationId, result) ); @@ -2065,18 +1998,6 @@ export class MessageModel extends window.Backbone.Model { return result; } - getQuoteBodyText(): string | undefined { - const storyReactionEmoji = this.get('storyReaction')?.emoji; - const body = this.get('body'); - const embeddedContact = this.get('contact'); - const embeddedContactName = - embeddedContact && embeddedContact.length > 0 - ? EmbeddedContact.getName(embeddedContact[0]) - : ''; - - return body || embeddedContactName || storyReactionEmoji; - } - async copyQuoteContentFromOriginal( originalMessage: MessageModel, quote: QuotedMessageType @@ -2125,7 +2046,7 @@ export class MessageModel extends window.Backbone.Model { quote.isViewOnce = false; // eslint-disable-next-line no-param-reassign - quote.text = originalMessage.getQuoteBodyText(); + quote.text = getQuoteBodyText(originalMessage.attributes, quote.id); if (firstAttachment) { firstAttachment.thumbnail = null; } @@ -3338,6 +3259,16 @@ export class MessageModel extends window.Backbone.Model { }) ); + // We want to make sure the message is saved first before applying any edits + if (!isFirstRun) { + const edits = Edits.forMessage(message); + await Promise.all( + edits.map(editAttributes => + handleEditMessage(message.attributes, editAttributes) + ) + ); + } + if (changed && !isFirstRun) { log.info( `modifyTargetMessage/${this.idForLogging()}: Changes in second run; saving.` diff --git a/ts/services/MessageUpdater.ts b/ts/services/MessageUpdater.ts index 23a3063b8..4839228da 100644 --- a/ts/services/MessageUpdater.ts +++ b/ts/services/MessageUpdater.ts @@ -5,6 +5,7 @@ import type { MessageAttributesType } from '../model-types.d'; import { ReadStatus, maxReadStatus } from '../messages/MessageReadStatus'; import { notificationService } from './notifications'; import { SeenStatus } from '../MessageSeenStatus'; +import { queueUpdateMessage } from '../util/messageBatcher'; function markReadOrViewed( messageAttrs: Readonly, @@ -34,7 +35,7 @@ function markReadOrViewed( notificationService.removeBy({ messageId }); if (!skipSave) { - window.Signal.Util.queueUpdateMessage(nextMessageAttributes); + queueUpdateMessage(nextMessageAttributes); } return nextMessageAttributes; diff --git a/ts/services/notifications.ts b/ts/services/notifications.ts index ab2bc6ad4..d8162366d 100644 --- a/ts/services/notifications.ts +++ b/ts/services/notifications.ts @@ -128,12 +128,14 @@ class NotificationService extends EventEmitter { public notify({ icon, message, + messageId, onNotificationClick, silent, title, }: Readonly<{ icon?: string; message: string; + messageId?: string; onNotificationClick: () => void; silent: boolean; title: string; @@ -149,6 +151,7 @@ class NotificationService extends EventEmitter { icon, silent: silent || audioNotificationSupport !== AudioNotificationSupport.Native, + tag: messageId, }); notification.onclick = onNotificationClick; diff --git a/ts/sql/Interface.ts b/ts/sql/Interface.ts index f03119238..122c56eb2 100644 --- a/ts/sql/Interface.ts +++ b/ts/sql/Interface.ts @@ -387,6 +387,13 @@ export type FTSOptimizationStateType = Readonly<{ done?: boolean; }>; +export type EditedMessageType = Readonly<{ + fromId: string; + messageId: string; + sentAt: number; + readStatus: MessageType['readStatus']; +}>; + export type DataInterface = { close: () => Promise; removeDB: () => Promise; @@ -514,6 +521,10 @@ export type DataInterface = { readAt?: number; storyId?: string; }) => Promise; + getUnreadEditedMessagesAndMarkRead: (options: { + fromId: string; + newestUnreadAt: number; + }) => Promise; getUnreadReactionsAndMarkRead: (options: { conversationId: string; newestUnreadAt: number; @@ -543,9 +554,15 @@ export type DataInterface = { messageIds: ReadonlyArray ) => Promise>; _getAllMessages: () => Promise>; + _getAllEditedMessages: () => Promise< + Array<{ messageId: string; sentAt: number }> + >; _removeAllMessages: () => Promise; getAllMessageIds: () => Promise>; getMessagesBySentAt: (sentAt: number) => Promise>; + getMessagesIncludingEditedBySentAt: ( + sentAt: number + ) => Promise>; getExpiredMessages: () => Promise>; getMessagesUnexpectedlyMissingExpirationStartTimestamp: () => Promise< Array @@ -592,6 +609,11 @@ export type DataInterface = { getNearbyMessageFromDeletedSet: ( options: GetNearbyMessageFromDeletedSetOptionsType ) => Promise; + saveEditedMessage: ( + mainMessage: MessageType, + ourUuid: UUIDStringType, + opts: EditedMessageType + ) => Promise; getUnprocessedCount: () => Promise; getUnprocessedByIdsAndIncrementAttempts: ( ids: ReadonlyArray diff --git a/ts/sql/Server.ts b/ts/sql/Server.ts index e5cfbbc12..df88b1d94 100644 --- a/ts/sql/Server.ts +++ b/ts/sql/Server.ts @@ -87,6 +87,7 @@ import type { ConversationType, DeleteSentProtoRecipientOptionsType, DeleteSentProtoRecipientResultType, + EditedMessageType, EmojiType, FTSOptimizationStateType, GetAllStoriesResultType, @@ -252,9 +253,12 @@ const dataInterface: ServerInterface = { getMessageById, getMessagesById, _getAllMessages, + _getAllEditedMessages, _removeAllMessages, getAllMessageIds, getMessagesBySentAt, + getMessagesIncludingEditedBySentAt, + getUnreadEditedMessagesAndMarkRead, getExpiredMessages, getMessagesUnexpectedlyMissingExpirationStartTimestamp, getSoonestMessageExpiry, @@ -273,6 +277,7 @@ const dataInterface: ServerInterface = { migrateConversationMessages, getMessagesBetween, getNearbyMessageFromDeletedSet, + saveEditedMessage, getUnprocessedCount, getUnprocessedByIdsAndIncrementAttempts, @@ -5679,3 +5684,136 @@ async function removeAllProfileKeyCredentials(): Promise { ` ); } + +async function saveEditedMessage( + mainMessage: MessageType, + ourUuid: UUIDStringType, + { fromId, messageId, readStatus, sentAt }: EditedMessageType +): Promise { + const db = getInstance(); + + db.transaction(() => { + assertSync( + saveMessageSync(mainMessage, { + ourUuid, + alreadyInTransaction: true, + }) + ); + + const [query, params] = sql` + INSERT INTO edited_messages ( + fromId, + messageId, + sentAt, + readStatus + ) VALUES ( + ${fromId}, + ${messageId}, + ${sentAt}, + ${readStatus} + ); + `; + + db.prepare(query).run(params); + })(); +} + +async function getMessagesIncludingEditedBySentAt( + sentAt: number +): Promise> { + const db = getInstance(); + + const [query, params] = sql` + SELECT messages.json, received_at, sent_at FROM edited_messages + INNER JOIN messages ON + messages.id = edited_messages.messageId + WHERE edited_messages.sentAt = ${sentAt} + UNION + SELECT json, received_at, sent_at FROM messages + WHERE sent_at = ${sentAt} + ORDER BY messages.received_at DESC, messages.sent_at DESC; + `; + + const rows = db.prepare(query).all(params); + + return rows.map(row => jsonToObject(row.json)); +} + +async function _getAllEditedMessages(): Promise< + Array<{ messageId: string; sentAt: number }> +> { + const db = getInstance(); + + return db + .prepare( + ` + SELECT * FROM edited_messages; + ` + ) + .all({}); +} + +async function getUnreadEditedMessagesAndMarkRead({ + fromId, + newestUnreadAt, +}: { + fromId: string; + newestUnreadAt: number; +}): Promise { + const db = getInstance(); + + return db.transaction(() => { + const [selectQuery, selectParams] = sql` + SELECT + messages.id, + messages.json, + edited_messages.sentAt, + edited_messages.readStatus + FROM edited_messages + JOIN messages + ON messages.id = edited_messages.messageId + WHERE + edited_messages.readStatus = ${ReadStatus.Unread} AND + edited_messages.fromId = ${fromId} AND + received_at <= ${newestUnreadAt} + ORDER BY messages.received_at DESC, messages.sent_at DESC; + `; + + const rows = db.prepare(selectQuery).all(selectParams); + + if (rows.length) { + const newestSentAt = rows[0].sentAt; + + const [updateStatusQuery, updateStatusParams] = sql` + UPDATE edited_messages + SET + readStatus = ${ReadStatus.Read} + WHERE + readStatus = ${ReadStatus.Unread} AND + fromId = ${fromId} AND + sentAt <= ${newestSentAt}; + `; + + db.prepare(updateStatusQuery).run(updateStatusParams); + } + + return rows.map(row => { + const json = jsonToObject(row.json); + return { + originalReadStatus: row.readStatus, + readStatus: ReadStatus.Read, + seenStatus: SeenStatus.Seen, + ...pick(json, [ + 'expirationStartTimestamp', + 'id', + 'sent_at', + 'source', + 'sourceUuid', + 'type', + ]), + // Use the edited message timestamp + sent_at: row.sentAt, + }; + }); + })(); +} diff --git a/ts/sql/migrations/80-edited-messages.ts b/ts/sql/migrations/80-edited-messages.ts new file mode 100644 index 000000000..95de47e87 --- /dev/null +++ b/ts/sql/migrations/80-edited-messages.ts @@ -0,0 +1,34 @@ +// Copyright 2023 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +import type { Database } from '@signalapp/better-sqlite3'; + +import type { LoggerType } from '../../types/Logging'; + +export default function updateToSchemaVersion80( + currentVersion: number, + db: Database, + logger: LoggerType +): void { + if (currentVersion >= 80) { + return; + } + + db.transaction(() => { + db.exec(` + CREATE TABLE edited_messages( + fromId STRING, + messageId STRING REFERENCES messages(id) + ON DELETE CASCADE, + sentAt INTEGER, + readStatus INTEGER + ); + + CREATE INDEX edited_messages_sent_at ON edited_messages (sentAt); + `); + + db.pragma('user_version = 80'); + })(); + + logger.info('updateToSchemaVersion80: success!'); +} diff --git a/ts/sql/migrations/index.ts b/ts/sql/migrations/index.ts index 40636eda9..e0763f3de 100644 --- a/ts/sql/migrations/index.ts +++ b/ts/sql/migrations/index.ts @@ -55,6 +55,7 @@ import updateToSchemaVersion76 from './76-optimize-convo-open-2'; import updateToSchemaVersion77 from './77-signal-tokenizer'; import updateToSchemaVersion78 from './78-merge-receipt-jobs'; import updateToSchemaVersion79 from './79-paging-lightbox'; +import updateToSchemaVersion80 from './80-edited-messages'; function updateToSchemaVersion1( currentVersion: number, @@ -1979,6 +1980,8 @@ export const SCHEMA_VERSIONS = [ updateToSchemaVersion77, updateToSchemaVersion78, updateToSchemaVersion79, + + updateToSchemaVersion80, ]; export function updateSchema(db: Database, logger: LoggerType): void { diff --git a/ts/state/ducks/composer.ts b/ts/state/ducks/composer.ts index 4354bc849..a6b35570b 100644 --- a/ts/state/ducks/composer.ts +++ b/ts/state/ducks/composer.ts @@ -88,6 +88,7 @@ import type { import { longRunningTaskWrapper } from '../../util/longRunningTaskWrapper'; import { drop } from '../../util/drop'; import { strictAssert } from '../../util/assert'; +import { makeQuote } from '../../util/makeQuote'; // State // eslint-disable-next-line local-rules/type-alias-readonlydeep @@ -630,7 +631,7 @@ export function setQuoteByMessageId( } if (message) { - const quote = await conversation.makeQuote(message); + const quote = await makeQuote(message.attributes); // In case the conversation changed while we were about to set the quote if (getState().conversations.selectedConversationId !== conversationId) { diff --git a/ts/state/ducks/conversations.ts b/ts/state/ducks/conversations.ts index 47dc2c080..9af6fdcee 100644 --- a/ts/state/ducks/conversations.ts +++ b/ts/state/ducks/conversations.ts @@ -1991,7 +1991,15 @@ function kickOffAttachmentDownload( `kickOffAttachmentDownload: Message ${options.messageId} missing!` ); } - await message.queueAttachmentDownloads(); + const didUpdateValues = await message.queueAttachmentDownloads(); + + if (didUpdateValues) { + drop( + window.Signal.Data.saveMessage(message.attributes, { + ourUuid: window.textsecure.storage.user.getCheckedUuid().toString(), + }) + ); + } dispatch({ type: 'NOOP', diff --git a/ts/state/ducks/globalModals.ts b/ts/state/ducks/globalModals.ts index 9c4c6d0e4..c229453d9 100644 --- a/ts/state/ducks/globalModals.ts +++ b/ts/state/ducks/globalModals.ts @@ -4,15 +4,24 @@ import type { ThunkAction } from 'redux-thunk'; import type { ReadonlyDeep } from 'type-fest'; import type { ExplodePromiseResultType } from '../../util/explodePromise'; -import type { GroupV2PendingMemberType } from '../../model-types.d'; -import type { PropsForMessage } from '../selectors/message'; +import type { + GroupV2PendingMemberType, + MessageAttributesType, +} from '../../model-types.d'; +import type { + MessageChangedActionType, + MessageDeletedActionType, + MessageExpiredActionType, +} from './conversations'; +import type { MessagePropsType } from '../selectors/message'; import type { RecipientsByConversation } from './stories'; import type { SafetyNumberChangeSource } from '../../components/SafetyNumberChangeDialog'; import type { StateType as RootStateType } from '../reducer'; import type { UUIDStringType } from '../../types/UUID'; +import * as Errors from '../../types/errors'; import * as SingleServePromise from '../../services/singleServePromise'; import * as Stickers from '../../types/Stickers'; -import * as Errors from '../../types/errors'; +import * as log from '../../logging/log'; import { getMessageById } from '../../messages/getMessageById'; import { getMessagePropsSelector } from '../selectors/message'; import type { BoundActionCreatorsMapObject } from '../../hooks/useBoundActions'; @@ -22,19 +31,24 @@ import { isGroupV1 } from '../../util/whatTypeOfConversation'; import { authorizeArtCreator } from '../../textsecure/authorizeArtCreator'; import type { AuthorizeArtCreatorOptionsType } from '../../textsecure/authorizeArtCreator'; import { getGroupMigrationMembers } from '../../groups'; -import * as log from '../../logging/log'; import { ToastType } from '../../types/Toast'; +import { + MESSAGE_CHANGED, + MESSAGE_DELETED, + MESSAGE_EXPIRED, +} from './conversations'; import { SHOW_TOAST } from './toast'; import type { ShowToastActionType } from './toast'; // State +export type EditHistoryMessagesType = ReadonlyDeep< + Array +>; export type ConfirmDeleteForMeModalProps = ReadonlyDeep<{ count: number; }>; -export type ForwardMessagePropsType = ReadonlyDeep< - Omit ->; +export type ForwardMessagePropsType = ReadonlyDeep; export type ForwardMessagesPropsType = ReadonlyDeep<{ messages: Array; onForward?: () => void; @@ -57,6 +71,7 @@ type MigrateToGV2PropsType = ReadonlyDeep<{ export type GlobalModalsStateType = ReadonlyDeep<{ addUserToAnotherGroupModalContactId?: string; contactModalState?: ContactModalStateType; + editHistoryMessages?: EditHistoryMessagesType; errorModalProps?: { description?: string; title?: string; @@ -115,6 +130,8 @@ const CONFIRM_AUTH_ART_CREATOR_PENDING = 'globalModals/CONFIRM_AUTH_ART_CREATOR_PENDING'; const CONFIRM_AUTH_ART_CREATOR_FULFILLED = 'globalModals/CONFIRM_AUTH_ART_CREATOR_FULFILLED'; +const SHOW_EDIT_HISTORY_MODAL = 'globalModals/SHOW_EDIT_HISTORY_MODAL'; +const CLOSE_EDIT_HISTORY_MODAL = 'globalModals/CLOSE_EDIT_HISTORY_MODAL'; export type ContactModalStateType = ReadonlyDeep<{ contactId: string; @@ -264,34 +281,50 @@ type ConfirmAuthArtCreatorFulfilledActionType = ReadonlyDeep<{ type: typeof CONFIRM_AUTH_ART_CREATOR_FULFILLED; }>; +type ShowEditHistoryModalActionType = ReadonlyDeep<{ + type: typeof SHOW_EDIT_HISTORY_MODAL; + payload: { + messages: EditHistoryMessagesType; + }; +}>; + +type CloseEditHistoryModalActionType = ReadonlyDeep<{ + type: typeof CLOSE_EDIT_HISTORY_MODAL; +}>; + export type GlobalModalsActionType = ReadonlyDeep< - | StartMigrationToGV2ActionType - | CloseGV2MigrationDialogActionType - | HideContactModalActionType - | ShowContactModalActionType - | HideWhatsNewModalActionType - | ShowWhatsNewModalActionType - | HideUserNotFoundModalActionType - | ShowUserNotFoundModalActionType - | HideStoriesSettingsActionType - | ShowStoriesSettingsActionType - | HideSendAnywayDialogActiontype - | ShowSendAnywayDialogActionType - | CloseStickerPackPreviewActionType - | ShowStickerPackPreviewActionType - | CloseErrorModalActionType - | ShowErrorModalActionType - | CloseShortcutGuideModalActionType - | ShowShortcutGuideModalActionType | CancelAuthArtCreatorActionType - | ConfirmAuthArtCreatorPendingActionType + | CloseEditHistoryModalActionType + | CloseErrorModalActionType + | CloseGV2MigrationDialogActionType + | CloseShortcutGuideModalActionType + | CloseStickerPackPreviewActionType | ConfirmAuthArtCreatorFulfilledActionType + | ConfirmAuthArtCreatorPendingActionType + | HideContactModalActionType + | HideSendAnywayDialogActiontype + | HideStoriesSettingsActionType + | HideUserNotFoundModalActionType + | HideWhatsNewModalActionType + | MessageChangedActionType + | MessageDeletedActionType + | MessageExpiredActionType | ShowAuthArtCreatorActionType + | ShowContactModalActionType + | ShowEditHistoryModalActionType + | ShowErrorModalActionType + | ShowSendAnywayDialogActionType + | ShowShortcutGuideModalActionType + | ShowStickerPackPreviewActionType + | ShowStoriesSettingsActionType + | ShowUserNotFoundModalActionType + | ShowWhatsNewModalActionType + | StartMigrationToGV2ActionType + | ToggleAddUserToAnotherGroupModalActionType | ToggleForwardMessagesModalActionType | ToggleProfileEditorActionType | ToggleProfileEditorErrorActionType | ToggleSafetyNumberModalActionType - | ToggleAddUserToAnotherGroupModalActionType | ToggleSignalConnectionsModalActionType | ToggleConfirmationModalActionType >; @@ -299,34 +332,36 @@ export type GlobalModalsActionType = ReadonlyDeep< // Action Creators export const actions = { - hideContactModal, - showContactModal, - hideWhatsNewModal, - showWhatsNewModal, - hideUserNotFoundModal, - showUserNotFoundModal, - hideStoriesSettings, - showStoriesSettings, + cancelAuthorizeArtCreator, + closeEditHistoryModal, + closeErrorModal, + closeGV2MigrationDialog, + closeShortcutGuideModal, + closeStickerPackPreview, + confirmAuthorizeArtCreator, hideBlockingSafetyNumberChangeDialog, + hideContactModal, + hideStoriesSettings, + hideUserNotFoundModal, + hideWhatsNewModal, + showAuthorizeArtCreator, showBlockingSafetyNumberChangeDialog, + showContactModal, + showEditHistoryModal, + showErrorModal, + showGV2MigrationDialog, + showShortcutGuideModal, + showStickerPackPreview, + showStoriesSettings, + showUserNotFoundModal, + showWhatsNewModal, + toggleAddUserToAnotherGroupModal, + toggleConfirmationModal, toggleForwardMessagesModal, toggleProfileEditor, toggleProfileEditorHasError, toggleSafetyNumberModal, - toggleAddUserToAnotherGroupModal, toggleSignalConnectionsModal, - toggleConfirmationModal, - showGV2MigrationDialog, - closeGV2MigrationDialog, - showStickerPackPreview, - closeStickerPackPreview, - closeErrorModal, - showErrorModal, - closeShortcutGuideModal, - showShortcutGuideModal, - showAuthorizeArtCreator, - cancelAuthorizeArtCreator, - confirmAuthorizeArtCreator, }; export const useGlobalModalActions = (): BoundActionCreatorsMapObject< @@ -632,6 +667,56 @@ function cancelAuthorizeArtCreator(): ThunkAction< }; } +function copyOverMessageAttributesIntoEditHistory( + messageAttributes: ReadonlyDeep +): EditHistoryMessagesType | undefined { + if (!messageAttributes.editHistory) { + return; + } + + return messageAttributes.editHistory.map(editedMessageAttributes => ({ + ...messageAttributes, + ...editedMessageAttributes, + // For timestamp uniqueness of messages + sent_at: editedMessageAttributes.timestamp, + })); +} + +function showEditHistoryModal( + messageId: string +): ThunkAction { + return async dispatch => { + const message = await getMessageById(messageId); + + if (!message) { + log.warn('showEditHistoryModal: no message found'); + return; + } + + const messageAttributes = message.attributes; + const nextEditHistoryMessages = + copyOverMessageAttributesIntoEditHistory(messageAttributes); + + if (!nextEditHistoryMessages) { + log.warn('showEditHistoryModal: no edit history for message'); + return; + } + + dispatch({ + type: SHOW_EDIT_HISTORY_MODAL, + payload: { + messages: nextEditHistoryMessages, + }, + }); + }; +} + +function closeEditHistoryModal(): CloseEditHistoryModalActionType { + return { + type: CLOSE_EDIT_HISTORY_MODAL, + }; +} + export function showAuthorizeArtCreator( data: AuthorizeArtCreatorDataType ): ShowAuthArtCreatorActionType { @@ -896,5 +981,71 @@ export function reducer( }; } + if (action.type === SHOW_EDIT_HISTORY_MODAL) { + return { + ...state, + editHistoryMessages: action.payload.messages, + }; + } + + if (action.type === CLOSE_EDIT_HISTORY_MODAL) { + return { + ...state, + editHistoryMessages: undefined, + }; + } + + if ( + action.type === MESSAGE_CHANGED || + action.type === MESSAGE_DELETED || + action.type === MESSAGE_EXPIRED + ) { + if (!state.editHistoryMessages) { + return state; + } + + if (action.type === MESSAGE_DELETED || action.type === MESSAGE_EXPIRED) { + const hasMessageId = state.editHistoryMessages.some( + edit => edit.id === action.payload.id + ); + + if (!hasMessageId) { + return state; + } + + return { + ...state, + editHistoryMessages: undefined, + }; + } + + if (action.type === MESSAGE_CHANGED) { + if (!action.payload.data.editHistory) { + return state; + } + + const hasMessageId = state.editHistoryMessages.some( + edit => edit.id === action.payload.id + ); + + if (!hasMessageId) { + return state; + } + + const nextEditHistoryMessages = copyOverMessageAttributesIntoEditHistory( + action.payload.data + ); + + if (!nextEditHistoryMessages) { + return state; + } + + return { + ...state, + editHistoryMessages: nextEditHistoryMessages, + }; + } + } + return state; } diff --git a/ts/state/selectors/message.ts b/ts/state/selectors/message.ts index bdbcc3441..5b7d0179a 100644 --- a/ts/state/selectors/message.ts +++ b/ts/state/selectors/message.ts @@ -136,6 +136,10 @@ type FormattedContact = Partial & | 'unblurredAvatarPath' >; export type PropsForMessage = Omit; +export type MessagePropsType = Omit< + PropsForMessage, + 'renderingContext' | 'menu' | 'contextMenu' +>; type PropsForUnsupportedMessage = { canProcessNow: boolean; contact: FormattedContact; @@ -718,6 +722,7 @@ export const getPropsForMessage = ( giftBadge: message.giftBadge, id: message.id, isBlocked: conversation.isBlocked || false, + isEditedMessage: Boolean(message.editHistory), isMessageRequestAccepted: conversation?.acceptedMessageRequest ?? true, isTargeted, isTargetedCounter: isTargeted ? targetedMessageCounter : undefined, diff --git a/ts/state/smart/EditHistoryMessagesModal.tsx b/ts/state/smart/EditHistoryMessagesModal.tsx new file mode 100644 index 000000000..dcfb183ec --- /dev/null +++ b/ts/state/smart/EditHistoryMessagesModal.tsx @@ -0,0 +1,57 @@ +// Copyright 2023 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +import React, { useMemo } from 'react'; +import { useSelector } from 'react-redux'; +import type { GlobalModalsStateType } from '../ducks/globalModals'; +import type { MessageAttributesType } from '../../model-types.d'; +import type { StateType } from '../reducer'; +import { EditHistoryMessagesModal } from '../../components/EditHistoryMessagesModal'; +import { getIntl } from '../selectors/user'; +import { getMessagePropsSelector } from '../selectors/message'; +import { getPreferredBadgeSelector } from '../selectors/badges'; +import { useConversationsActions } from '../ducks/conversations'; +import { useGlobalModalActions } from '../ducks/globalModals'; +import { useLightboxActions } from '../ducks/lightbox'; +import { strictAssert } from '../../util/assert'; + +export function SmartEditHistoryMessagesModal(): JSX.Element { + const i18n = useSelector(getIntl); + + const { closeEditHistoryModal } = useGlobalModalActions(); + + const { kickOffAttachmentDownload } = useConversationsActions(); + const { showLightbox } = useLightboxActions(); + + const getPreferredBadge = useSelector(getPreferredBadgeSelector); + + const { editHistoryMessages: messagesAttributes } = useSelector< + StateType, + GlobalModalsStateType + >(state => state.globalModals); + + const messagePropsSelector = useSelector(getMessagePropsSelector); + + strictAssert(messagesAttributes, 'messages not provided'); + + const editHistoryMessages = useMemo(() => { + return messagesAttributes.map(messageAttributes => ({ + ...messagePropsSelector(messageAttributes as MessageAttributesType), + // Make sure the messages don't get an "edited" badge + editHistory: undefined, + // Do not show the same reactions in the message history UI + reactions: undefined, + })); + }, [messagesAttributes, messagePropsSelector]); + + return ( + + ); +} diff --git a/ts/state/smart/GlobalModalContainer.tsx b/ts/state/smart/GlobalModalContainer.tsx index e8bc01d9d..dfa5cdf3c 100644 --- a/ts/state/smart/GlobalModalContainer.tsx +++ b/ts/state/smart/GlobalModalContainer.tsx @@ -10,6 +10,7 @@ import { ErrorModal } from '../../components/ErrorModal'; import { GlobalModalContainer } from '../../components/GlobalModalContainer'; import { SmartAddUserToAnotherGroupModal } from './AddUserToAnotherGroupModal'; import { SmartContactModal } from './ContactModal'; +import { SmartEditHistoryMessagesModal } from './EditHistoryMessagesModal'; import { SmartForwardMessagesModal } from './ForwardMessagesModal'; import { SmartProfileEditorModal } from './ProfileEditorModal'; import { SmartSafetyNumberModal } from './SafetyNumberModal'; @@ -21,6 +22,10 @@ import { getConversationsStoppingSend } from '../selectors/conversations'; import { getIntl, getTheme } from '../selectors/user'; import { useGlobalModalActions } from '../ducks/globalModals'; +function renderEditHistoryMessagesModal(): JSX.Element { + return ; +} + function renderProfileEditor(): JSX.Element { return ; } @@ -55,6 +60,7 @@ export function SmartGlobalModalContainer(): JSX.Element { const { addUserToAnotherGroupModalContactId, contactModalState, + editHistoryMessages, errorModalProps, forwardMessagesProps, isProfileEditorVisible, @@ -120,6 +126,7 @@ export function SmartGlobalModalContainer(): JSX.Element { { + bootstrap = new Bootstrap(); + await bootstrap.init(); + app = await bootstrap.link(); + }); + + afterEach(async function after() { + if (!bootstrap) { + return; + } + + if (this.currentTest?.state !== 'passed') { + await bootstrap.saveLogs(app); + } + + await app.close(); + await bootstrap.teardown(); + }); + + it('should edit a message', async () => { + const { phone, desktop } = bootstrap; + + const window = await app.getWindow(); + + const originalMessage = createMessage(); + + debug('sending message'); + { + const sendOptions = { + timestamp: Number(originalMessage.timestamp), + }; + await phone.sendRaw( + desktop, + wrap({ dataMessage: originalMessage }), + sendOptions + ); + } + + debug('opening conversation'); + const leftPane = window.locator('.left-pane-wrapper'); + await leftPane + .locator('.module-conversation-list__item--contact-or-conversation') + .first() + .click(); + await window.locator('.module-conversation-hero').waitFor(); + + debug('checking for message'); + await window.locator('.module-message__text >> "hey yhere"').waitFor(); + + debug('sending edited message'); + { + const editedMessage = createEditedMessage(originalMessage); + const sendOptions = { + timestamp: Number(editedMessage.dataMessage?.timestamp), + }; + await phone.sendRaw( + desktop, + wrap({ editMessage: editedMessage }), + sendOptions + ); + } + + debug('checking for edited message'); + await window.locator('.module-message__text >> "hey there"').waitFor(); + + const messages = window.locator('.module-message__text'); + assert.strictEqual(await messages.count(), 1, 'message count'); + }); +}); diff --git a/ts/textsecure/MessageReceiver.ts b/ts/textsecure/MessageReceiver.ts index 21c963f94..c36fdba66 100644 --- a/ts/textsecure/MessageReceiver.ts +++ b/ts/textsecure/MessageReceiver.ts @@ -122,6 +122,7 @@ import type { SendTypesType } from '../util/handleMessageSend'; import { getStoriesBlocked } from '../util/stories'; import { isNotNil } from '../util/isNotNil'; import { chunk } from '../util/iterables'; +import { isOlderThan } from '../util/timestamp'; const GROUPV1_ID_LENGTH = 16; const GROUPV2_ID_LENGTH = 32; @@ -2242,6 +2243,84 @@ export default class MessageReceiver return this.dispatchAndWait(logId, ev); } + private async handleEditMessage( + envelope: UnsealedEnvelope, + msg: Proto.IEditMessage + ): Promise { + const logId = `MessageReceiver.handleEditMessage(${getEnvelopeId( + envelope + )})`; + log.info(logId); + + if (!msg.targetSentTimestamp) { + log.info(`${logId}: cannot edit message. No targetSentTimestamp`); + this.removeFromCache(envelope); + return; + } + + if (!msg.dataMessage) { + log.info(`${logId}: cannot edit message. No dataMessage`); + this.removeFromCache(envelope); + return; + } + + // Timing check + if (isOlderThan(envelope.serverTimestamp, durations.DAY)) { + log.info( + 'MessageReceiver.handleEditMessage: cannot edit message older than 24h', + logId + ); + this.removeFromCache(envelope); + return; + } + + const message = this.processDecrypted(envelope, msg.dataMessage); + const groupId = this.getProcessedGroupId(message); + const isBlocked = groupId ? this.isGroupBlocked(groupId) : false; + const { source, sourceUuid } = envelope; + const ourE164 = this.storage.user.getNumber(); + const ourUuid = this.storage.user.getCheckedUuid().toString(); + const isMe = + (source && ourE164 && source === ourE164) || + (sourceUuid && ourUuid && sourceUuid === ourUuid); + const isLeavingGroup = Boolean( + !message.groupV2 && + message.group && + message.group.type === Proto.GroupContext.Type.QUIT + ); + + if (groupId && isBlocked && !(isMe && isLeavingGroup)) { + log.warn( + `Message ${getEnvelopeId(envelope)} ignored; destined for blocked group` + ); + this.removeFromCache(envelope); + return; + } + + const ev = new MessageEvent( + { + source: envelope.source, + sourceUuid: envelope.sourceUuid, + sourceDevice: envelope.sourceDevice, + destinationUuid: envelope.destinationUuid.toString(), + timestamp: envelope.timestamp, + serverGuid: envelope.serverGuid, + serverTimestamp: envelope.serverTimestamp, + unidentifiedDeliveryReceived: Boolean( + envelope.unidentifiedDeliveryReceived + ), + message: { + ...message, + editedMessageTimestamp: msg.targetSentTimestamp.toNumber(), + }, + receivedAtCounter: envelope.receivedAtCounter, + receivedAtDate: envelope.receivedAtDate, + }, + this.removeFromCache.bind(this, envelope) + ); + return this.dispatchAndWait(logId, ev); + } + private async handleDataMessage( envelope: UnsealedEnvelope, msg: Proto.IDataMessage @@ -2358,6 +2437,7 @@ export default class MessageReceiver }, this.removeFromCache.bind(this, envelope) ); + return this.dispatchAndWait(logId, ev); } @@ -2463,6 +2543,11 @@ export default class MessageReceiver return; } + if (content.editMessage) { + await this.handleEditMessage(envelope, content.editMessage); + return; + } + this.removeFromCache(envelope); if (Bytes.isEmpty(content.senderKeyDistributionMessage)) { @@ -2859,6 +2944,10 @@ export default class MessageReceiver if (syncMessage.sent) { const sentMessage = syncMessage.sent; + if (sentMessage.editMessage) { + return this.handleSentEditMessage(envelope, sentMessage); + } + if (sentMessage.storyMessageRecipients && sentMessage.isRecipientUpdate) { if (getStoriesBlocked()) { log.info( @@ -2886,12 +2975,11 @@ export default class MessageReceiver } if (sentMessage.storyMessage) { - void this.handleStoryMessage( + return this.handleStoryMessage( envelope, sentMessage.storyMessage, sentMessage ); - return; } if (!sentMessage || !sentMessage.message) { @@ -2916,6 +3004,7 @@ export default class MessageReceiver 'from', getEnvelopeId(envelope) ); + return this.handleSentMessage(envelope, sentMessage); } if (syncMessage.contacts) { @@ -2984,6 +3073,68 @@ export default class MessageReceiver ); } + private async handleSentEditMessage( + envelope: UnsealedEnvelope, + sentMessage: ProcessedSent + ): Promise { + const logId = `MessageReceiver.handleSentEditMessage(${getEnvelopeId( + envelope + )})`; + log.info(logId); + + const { editMessage } = sentMessage; + + if (!editMessage) { + log.warn(`${logId}: cannot edit message. No editMessage in proto`); + this.removeFromCache(envelope); + return; + } + + if (!editMessage.targetSentTimestamp) { + log.warn(`${logId}: cannot edit message. No targetSentTimestamp`); + this.removeFromCache(envelope); + return; + } + + if (!editMessage.dataMessage) { + log.warn(`${logId}: cannot edit message. No dataMessage`); + this.removeFromCache(envelope); + return; + } + + const { + destination, + destinationUuid, + expirationStartTimestamp, + unidentifiedStatus, + isRecipientUpdate, + } = sentMessage; + + const message = this.processDecrypted(envelope, editMessage.dataMessage); + + const ev = new SentEvent( + { + destination: dropNull(destination), + destinationUuid: + dropNull(destinationUuid) || envelope.destinationUuid.toString(), + timestamp: envelope.timestamp, + serverTimestamp: envelope.serverTimestamp, + device: envelope.sourceDevice, + unidentifiedStatus, + message: { + ...message, + editedMessageTimestamp: editMessage.targetSentTimestamp.toNumber(), + }, + isRecipientUpdate: Boolean(isRecipientUpdate), + receivedAtCounter: envelope.receivedAtCounter, + receivedAtDate: envelope.receivedAtDate, + expirationStartTimestamp: expirationStartTimestamp?.toNumber(), + }, + this.removeFromCache.bind(this, envelope) + ); + return this.dispatchAndWait(getEnvelopeId(envelope), ev); + } + private async handleConfiguration( envelope: ProcessedEnvelope, configuration: Proto.SyncMessage.IConfiguration diff --git a/ts/textsecure/Types.d.ts b/ts/textsecure/Types.d.ts index fae60799b..879599b13 100644 --- a/ts/textsecure/Types.d.ts +++ b/ts/textsecure/Types.d.ts @@ -150,7 +150,7 @@ export type ProcessedQuote = { authorUuid?: string; text?: string; attachments: ReadonlyArray; - bodyRanges: ReadonlyArray; + bodyRanges: ReadonlyArray; type: Proto.DataMessage.Quote.Type; }; @@ -219,6 +219,7 @@ export type ProcessedDataMessage = { preview?: ReadonlyArray; sticker?: ProcessedSticker; requiredProtocolVersion?: number; + editedMessageTimestamp?: number; isStory?: boolean; isViewOnce: boolean; reaction?: ProcessedReaction; diff --git a/ts/textsecure/downloadAttachment.ts b/ts/textsecure/downloadAttachment.ts index d8543de15..68ce665c0 100644 --- a/ts/textsecure/downloadAttachment.ts +++ b/ts/textsecure/downloadAttachment.ts @@ -50,7 +50,7 @@ export async function downloadAttachment( const data = getFirstBytes(paddedData, size); return { - ...omit(attachment, 'digest', 'key'), + ...omit(attachment, 'key'), size, contentType: contentType diff --git a/ts/types/Attachment.ts b/ts/types/Attachment.ts index d3c0d5c08..6fb6d57ef 100644 --- a/ts/types/Attachment.ts +++ b/ts/types/Attachment.ts @@ -27,6 +27,7 @@ import { ThemeType } from './Util'; import * as GoogleChrome from '../util/GoogleChrome'; import { ReadStatus } from '../messages/MessageReadStatus'; import type { MessageStatusType } from '../components/conversation/Message'; +import { softAssert } from '../util/assert'; const MAX_WIDTH = 300; const MAX_HEIGHT = MAX_WIDTH * 1.5; @@ -40,7 +41,9 @@ export type AttachmentType = { blurHash?: string; caption?: string; contentType: MIME.MIMEType; + digest?: string; fileName?: string; + uploadTimestamp?: number; /** Not included in protobuf, needs to be pulled from flags */ isVoiceMessage?: boolean; /** For messages not already on disk, this will be a data url */ @@ -78,7 +81,6 @@ export type AttachmentType = { schemaVersion?: number; /** Removed once we download the attachment */ - digest?: string; key?: string; }; @@ -187,6 +189,7 @@ export async function migrateDataToFileSystem( const { data } = attachment; const attachmentHasData = !isUndefined(data); const shouldSkipSchemaUpgrade = !attachmentHasData; + if (shouldSkipSchemaUpgrade) { return attachment; } @@ -1001,3 +1004,8 @@ export const canBeDownloaded = ( ): boolean => { return Boolean(attachment.key && attachment.digest); }; + +export function getAttachmentSignature(attachment: AttachmentType): string { + softAssert(attachment.digest, 'attachment missing digest'); + return attachment.digest || String(attachment.blurHash); +} diff --git a/ts/types/Message2.ts b/ts/types/Message2.ts index 9453b49bc..93838790a 100644 --- a/ts/types/Message2.ts +++ b/ts/types/Message2.ts @@ -824,7 +824,8 @@ export const deleteAllExternalFiles = ({ } return async (message: MessageAttributesType) => { - const { attachments, quote, contact, preview, sticker } = message; + const { attachments, editHistory, quote, contact, preview, sticker } = + message; if (attachments && attachments.length) { await Promise.all(attachments.map(deleteAttachmentData)); @@ -858,15 +859,7 @@ export const deleteAllExternalFiles = ({ } if (preview && preview.length) { - await Promise.all( - preview.map(async item => { - const { image } = item; - - if (image && image.path) { - await deleteOnDisk(image.path); - } - }) - ); + await deletePreviews(preview, deleteOnDisk); } if (sticker && sticker.data && sticker.data.path) { @@ -876,9 +869,42 @@ export const deleteAllExternalFiles = ({ await deleteOnDisk(sticker.data.thumbnail.path); } } + + if (editHistory && editHistory.length) { + await editHistory.map(edit => { + if (!edit.attachments || !edit.attachments.length) { + return; + } + return Promise.all(edit.attachments.map(deleteAttachmentData)); + }); + await editHistory.map(edit => deletePreviews(edit.preview, deleteOnDisk)); + } }; }; +async function deletePreviews( + preview: MessageAttributesType['preview'], + deleteOnDisk: (path: string) => Promise +): Promise> { + if (!preview) { + return []; + } + + return Promise.all( + preview.map(async item => { + const { image } = item; + + if (image && image.path) { + await deleteOnDisk(image.path); + } + + if (image?.thumbnail?.path) { + await deleteOnDisk(image.thumbnail.path); + } + }) + ); +} + // createAttachmentDataWriter :: (RelativePath -> IO Unit) // Message -> // IO (Promise Message) diff --git a/ts/util/attachments.ts b/ts/util/attachments.ts index 44b67aeb3..17236f778 100644 --- a/ts/util/attachments.ts +++ b/ts/util/attachments.ts @@ -1,7 +1,6 @@ // Copyright 2021 Signal Messenger, LLC // SPDX-License-Identifier: AGPL-3.0-only -import { omit } from 'lodash'; import { blobToArrayBuffer } from 'blob-util'; import { scaleImageToLevel } from './scaleImageToLevel'; @@ -59,8 +58,7 @@ export async function autoOrientJPEG( // by potentially doubling stored image data. // See: https://github.com/signalapp/Signal-Desktop/issues/1589 const xcodedAttachment = { - // `digest` is no longer valid for auto-oriented image data, so we discard it: - ...omit(attachment, 'digest'), + ...attachment, data: new Uint8Array(xcodedDataArrayBuffer), size: xcodedDataArrayBuffer.byteLength, }; diff --git a/ts/util/getQuoteBodyText.ts b/ts/util/getQuoteBodyText.ts new file mode 100644 index 000000000..e262feafc --- /dev/null +++ b/ts/util/getQuoteBodyText.ts @@ -0,0 +1,28 @@ +// Copyright 2023 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +import type { MessageAttributesType } from '../model-types.d'; +import * as EmbeddedContact from '../types/EmbeddedContact'; + +export function getQuoteBodyText( + messageAttributes: MessageAttributesType, + id: number +): string | undefined { + const storyReactionEmoji = messageAttributes.storyReaction?.emoji; + + const { editHistory } = messageAttributes; + const editedMessage = + editHistory && editHistory.find(edit => edit.timestamp === id); + + if (editedMessage && editedMessage.body) { + return editedMessage.body; + } + + const { body, contact: embeddedContact } = messageAttributes; + const embeddedContactName = + embeddedContact && embeddedContact.length > 0 + ? EmbeddedContact.getName(embeddedContact[0]) + : ''; + + return body || embeddedContactName || storyReactionEmoji; +} diff --git a/ts/util/handleEditMessage.ts b/ts/util/handleEditMessage.ts new file mode 100644 index 000000000..f86cf7acd --- /dev/null +++ b/ts/util/handleEditMessage.ts @@ -0,0 +1,191 @@ +// 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); + } +} diff --git a/ts/util/hasAttachmentDownloads.ts b/ts/util/hasAttachmentDownloads.ts index 5d753241d..3398a0dbe 100644 --- a/ts/util/hasAttachmentDownloads.ts +++ b/ts/util/hasAttachmentDownloads.ts @@ -21,31 +21,12 @@ export function hasAttachmentDownloads( return true; } - const hasNormalAttachments = normalAttachments.some(attachment => { - if (!attachment) { - return false; - } - // We've already downloaded this! - if (attachment.path) { - return false; - } - return true; - }); + const hasNormalAttachments = hasNormalAttachmentDownloads(normalAttachments); if (hasNormalAttachments) { return true; } - const previews = message.preview || []; - const hasPreviews = previews.some(item => { - if (!item.image) { - return false; - } - // We've already downloaded this! - if (item.image.path) { - return false; - } - return true; - }); + const hasPreviews = hasPreviewDownloads(message.preview); if (hasPreviews) { return true; } @@ -85,5 +66,48 @@ export function hasAttachmentDownloads( return !sticker.data || (sticker.data && !sticker.data.path); } + const { editHistory } = message; + if (editHistory) { + const hasAttachmentsWithinEditHistory = editHistory.some( + edit => + hasNormalAttachmentDownloads(edit.attachments) || + hasPreviewDownloads(edit.preview) + ); + + if (hasAttachmentsWithinEditHistory) { + return true; + } + } + return false; } + +function hasPreviewDownloads( + previews: MessageAttributesType['preview'] +): boolean { + return (previews || []).some(item => { + if (!item.image) { + return false; + } + // We've already downloaded this! + if (item.image.path) { + return false; + } + return true; + }); +} + +function hasNormalAttachmentDownloads( + attachments: MessageAttributesType['attachments'] +): boolean { + return (attachments || []).some(attachment => { + if (!attachment) { + return false; + } + // We've already downloaded this! + if (attachment.path) { + return false; + } + return true; + }); +} diff --git a/ts/util/lint/exceptions.json b/ts/util/lint/exceptions.json index 15a9aa216..7bc34b53c 100644 --- a/ts/util/lint/exceptions.json +++ b/ts/util/lint/exceptions.json @@ -2011,6 +2011,13 @@ "reasonCategory": "usageTrusted", "updated": "2021-07-30T16:57:33.618Z" }, + { + "rule": "React-useRef", + "path": "ts/components/EditHistoryMessagesModal.tsx", + "line": " const containerElementRef = useRef(null);", + "reasonCategory": "usageTrusted", + "updated": "2023-03-25T01:59:04.590Z" + }, { "rule": "React-useRef", "path": "ts/components/ForwardMessagesModal.tsx", @@ -2399,6 +2406,13 @@ "reasonCategory": "usageTrusted", "updated": "2022-11-03T14:21:47.456Z" }, + { + "rule": "React-useRef", + "path": "ts/components/conversation/WaveformScrubber.tsx", + "line": " const waveformRef = useRef(null);", + "reasonCategory": "usageTrusted", + "updated": "2023-02-26T23:20:28.848Z" + }, { "rule": "React-useRef", "path": "ts/components/conversation/conversation-details/AddGroupMembersModal/ChooseGroupMembersModal.tsx", @@ -2435,13 +2449,6 @@ "updated": "2019-11-01T22:46:33.013Z", "reasonDetail": "Used for setting focus only" }, - { - "rule": "React-useRef", - "path": "ts/components/conversation/WaveformScrubber.tsx", - "line": " const waveformRef = useRef(null);", - "reasonCategory": "usageTrusted", - "updated": "2023-02-26T23:20:28.848Z" - }, { "rule": "React-useRef", "path": "ts/components/emoji/EmojiButton.tsx", diff --git a/ts/util/makeQuote.ts b/ts/util/makeQuote.ts new file mode 100644 index 000000000..7a1d261fd --- /dev/null +++ b/ts/util/makeQuote.ts @@ -0,0 +1,149 @@ +// Copyright 2023 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +import type { AttachmentType, ThumbnailType } from '../types/Attachment'; +import type { + MessageAttributesType, + QuotedMessageType, +} from '../model-types.d'; +import type { MIMEType } from '../types/MIME'; +import type { LinkPreviewType } from '../types/message/LinkPreviews'; +import type { StickerType } from '../types/Stickers'; +import { IMAGE_JPEG, IMAGE_GIF } from '../types/MIME'; +import { getContact } from '../messages/helpers'; +import { getQuoteBodyText } from './getQuoteBodyText'; +import { isGIF } from '../types/Attachment'; +import { isGiftBadge, isTapToView } from '../state/selectors/message'; +import { map, take, collect } from './iterables'; +import { strictAssert } from './assert'; + +export async function makeQuote( + quotedMessage: MessageAttributesType +): Promise { + const contact = getContact(quotedMessage); + + strictAssert(contact, 'makeQuote: no contact'); + + const { + attachments, + bodyRanges, + editMessageTimestamp, + id: messageId, + payment, + preview, + sticker, + } = quotedMessage; + + const quoteId = editMessageTimestamp || quotedMessage.sent_at; + + return { + authorUuid: contact.get('uuid'), + attachments: isTapToView(quotedMessage) + ? [{ contentType: IMAGE_JPEG, fileName: null }] + : await getQuoteAttachment(attachments, preview, sticker), + payment, + bodyRanges, + id: quoteId, + isViewOnce: isTapToView(quotedMessage), + isGiftBadge: isGiftBadge(quotedMessage), + messageId, + referencedMessageNotFound: false, + text: getQuoteBodyText(quotedMessage, quoteId), + }; +} + +export async function getQuoteAttachment( + attachments?: Array, + preview?: Array, + sticker?: StickerType +): Promise< + Array<{ + contentType: MIMEType; + fileName: string | null; + thumbnail: ThumbnailType | null; + }> +> { + const { getAbsoluteAttachmentPath, loadAttachmentData } = + window.Signal.Migrations; + + if (attachments && attachments.length) { + const attachmentsToUse = Array.from(take(attachments, 1)); + const isGIFQuote = isGIF(attachmentsToUse); + + return Promise.all( + map(attachmentsToUse, async attachment => { + const { path, fileName, thumbnail, contentType } = attachment; + + if (!path) { + return { + contentType: isGIFQuote ? IMAGE_GIF : contentType, + // Our protos library complains about this field being undefined, so we + // force it to null + fileName: fileName || null, + thumbnail: null, + }; + } + + return { + contentType: isGIFQuote ? IMAGE_GIF : contentType, + // Our protos library complains about this field being undefined, so we force + // it to null + fileName: fileName || null, + thumbnail: thumbnail + ? { + ...(await loadAttachmentData(thumbnail)), + objectUrl: thumbnail.path + ? getAbsoluteAttachmentPath(thumbnail.path) + : undefined, + } + : null, + }; + }) + ); + } + + if (preview && preview.length) { + const previewImages = collect(preview, prev => prev.image); + const previewImagesToUse = take(previewImages, 1); + + return Promise.all( + map(previewImagesToUse, async image => { + const { contentType } = image; + + return { + contentType, + // Our protos library complains about this field being undefined, so we + // force it to null + fileName: null, + thumbnail: image + ? { + ...(await loadAttachmentData(image)), + objectUrl: image.path + ? getAbsoluteAttachmentPath(image.path) + : undefined, + } + : null, + }; + }) + ); + } + + if (sticker && sticker.data && sticker.data.path) { + const { path, contentType } = sticker.data; + + return [ + { + contentType, + // Our protos library complains about this field being undefined, so we + // force it to null + fileName: null, + thumbnail: { + ...(await loadAttachmentData(sticker.data)), + objectUrl: path ? getAbsoluteAttachmentPath(path) : undefined, + }, + }, + ]; + } + + return []; +} diff --git a/ts/util/markConversationRead.ts b/ts/util/markConversationRead.ts index 54a3bea9a..459da550b 100644 --- a/ts/util/markConversationRead.ts +++ b/ts/util/markConversationRead.ts @@ -34,18 +34,23 @@ export async function markConversationRead( ): Promise { const { id: conversationId } = conversationAttrs; - const [unreadMessages, unreadReactions] = await Promise.all([ - window.Signal.Data.getUnreadByConversationAndMarkRead({ - conversationId, - newestUnreadAt, - readAt: options.readAt, - includeStoryReplies: !isGroup(conversationAttrs), - }), - window.Signal.Data.getUnreadReactionsAndMarkRead({ - conversationId, - newestUnreadAt, - }), - ]); + const [unreadMessages, unreadEditedMessages, unreadReactions] = + await Promise.all([ + window.Signal.Data.getUnreadByConversationAndMarkRead({ + conversationId, + newestUnreadAt, + readAt: options.readAt, + includeStoryReplies: !isGroup(conversationAttrs), + }), + window.Signal.Data.getUnreadEditedMessagesAndMarkRead({ + fromId: conversationId, + newestUnreadAt, + }), + window.Signal.Data.getUnreadReactionsAndMarkRead({ + conversationId, + newestUnreadAt, + }), + ]); log.info('markConversationRead', { conversationId: getConversationIdForLogging(conversationAttrs), @@ -55,7 +60,11 @@ export async function markConversationRead( unreadReactions: unreadReactions.length, }); - if (!unreadMessages.length && !unreadReactions.length) { + if ( + !unreadMessages.length && + !unreadEditedMessages.length && + !unreadReactions.length + ) { return false; } @@ -83,7 +92,9 @@ export async function markConversationRead( }); }); - const allReadMessagesSync = unreadMessages.map(messageSyncData => { + const allUnreadMessages = [...unreadMessages, ...unreadEditedMessages]; + + const allReadMessagesSync = allUnreadMessages.map(messageSyncData => { const message = window.MessageController.getById(messageSyncData.id); // we update the in-memory MessageModel with the fresh database call data if (message) { diff --git a/ts/util/queueAttachmentDownloads.ts b/ts/util/queueAttachmentDownloads.ts index 5824f08f9..e1370e46c 100644 --- a/ts/util/queueAttachmentDownloads.ts +++ b/ts/util/queueAttachmentDownloads.ts @@ -16,16 +16,23 @@ import dataInterface from '../sql/Client'; import type { AttachmentType } from '../types/Attachment'; import type { EmbeddedContactType } from '../types/EmbeddedContact'; import type { + EditHistoryType, MessageAttributesType, QuotedMessageType, } from '../model-types.d'; import * as Errors from '../types/errors'; +import { + getAttachmentSignature, + isDownloading, + isDownloaded, +} from '../types/Attachment'; import type { StickerType } from '../types/Stickers'; import type { LinkPreviewType } from '../types/message/LinkPreviews'; type ReturnType = { bodyAttachment?: AttachmentType; attachments: Array; + editHistory?: Array; preview: Array; contact: Array; quote?: QuotedMessageType; @@ -45,8 +52,10 @@ export async function queueAttachmentDownloads( let count = 0; let bodyAttachment; + const idLog = `queueAttachmentDownloads(${idForLogging}})`; + log.info( - `Queueing ${attachmentsToQueue.length} attachment downloads for message ${idForLogging}` + `${idLog}: Queueing ${attachmentsToQueue.length} attachment downloads` ); const [longMessageAttachments, normalAttachments] = partition( @@ -55,13 +64,11 @@ export async function queueAttachmentDownloads( ); if (longMessageAttachments.length > 1) { - log.error( - `Received more than one long message attachment in message ${idForLogging}` - ); + log.error(`${idLog}: Received more than one long message attachment`); } log.info( - `Queueing ${longMessageAttachments.length} long message attachment downloads for message ${idForLogging}` + `${idLog}: Queueing ${longMessageAttachments.length} long message attachment downloads` ); if (longMessageAttachments.length > 0) { @@ -82,63 +89,31 @@ export async function queueAttachmentDownloads( } log.info( - `Queueing ${normalAttachments.length} normal attachment downloads for message ${idForLogging}` + `${idLog}: Queueing ${normalAttachments.length} normal attachment downloads` ); - const attachments = await Promise.all( - normalAttachments.map((attachment, index) => { - if (!attachment) { - return attachment; - } - // We've already downloaded this! - if (attachment.path || attachment.textAttachment) { - log.info( - `Normal attachment already downloaded for message ${idForLogging}` - ); - return attachment; - } - - count += 1; - - return AttachmentDownloads.addJob(attachment, { - messageId, - type: 'attachment', - index, - }); - }) + const { attachments, count: attachmentsCount } = await queueNormalAttachments( + idLog, + messageId, + normalAttachments, + message.editHistory?.flatMap(x => x.attachments ?? []) ); + count += attachmentsCount; const previewsToQueue = message.preview || []; log.info( - `Queueing ${previewsToQueue.length} preview attachment downloads for message ${idForLogging}` + `${idLog}: Queueing ${previewsToQueue.length} preview attachment downloads` ); - const preview = await Promise.all( - previewsToQueue.map(async (item, index) => { - if (!item.image) { - return item; - } - // We've already downloaded this! - if (item.image.path) { - log.info( - `Preview attachment already downloaded for message ${idForLogging}` - ); - return item; - } - - count += 1; - return { - ...item, - image: await AttachmentDownloads.addJob(item.image, { - messageId, - type: 'preview', - index, - }), - }; - }) + const { preview, count: previewCount } = await queuePreviews( + idLog, + messageId, + previewsToQueue, + message.editHistory?.flatMap(x => x.preview ?? []) ); + count += previewCount; const contactsToQueue = message.contact || []; log.info( - `Queueing ${contactsToQueue.length} contact attachment downloads for message ${idForLogging}` + `${idLog}: Queueing ${contactsToQueue.length} contact attachment downloads` ); const contact = await Promise.all( contactsToQueue.map(async (item, index) => { @@ -147,9 +122,7 @@ export async function queueAttachmentDownloads( } // We've already downloaded this! if (item.avatar.avatar.path) { - log.info( - `Contact attachment already downloaded for message ${idForLogging}` - ); + log.info(`${idLog}: Contact attachment already downloaded`); return item; } @@ -172,7 +145,7 @@ export async function queueAttachmentDownloads( const quoteAttachmentsToQueue = quote && quote.attachments ? quote.attachments : []; log.info( - `Queueing ${quoteAttachmentsToQueue.length} quote attachment downloads for message ${idForLogging}` + `${idLog}: Queueing ${quoteAttachmentsToQueue.length} quote attachment downloads` ); if (quote && quoteAttachmentsToQueue.length > 0) { quote = { @@ -184,9 +157,7 @@ export async function queueAttachmentDownloads( } // We've already downloaded this! if (item.thumbnail.path) { - log.info( - `Quote attachment already downloaded for message ${idForLogging}` - ); + log.info(`${idLog}: Quote attachment already downloaded`); return item; } @@ -206,11 +177,9 @@ export async function queueAttachmentDownloads( let { sticker } = message; if (sticker && sticker.data && sticker.data.path) { - log.info( - `Sticker attachment already downloaded for message ${idForLogging}` - ); + log.info(`${idLog}: Sticker attachment already downloaded`); } else if (sticker) { - log.info(`Queueing sticker download for message ${idForLogging}`); + log.info(`${idLog}: Queueing sticker download`); count += 1; const { packId, stickerId, packKey } = sticker; @@ -222,7 +191,7 @@ export async function queueAttachmentDownloads( data = await copyStickerToAttachments(packId, stickerId); } catch (error) { log.error( - `Problem copying sticker (${packId}, ${stickerId}) to attachments:`, + `${idLog}: Problem copying sticker (${packId}, ${stickerId}) to attachments:`, Errors.toLogFormat(error) ); } @@ -252,20 +221,197 @@ export async function queueAttachmentDownloads( }; } - log.info( - `Queued ${count} total attachment downloads for message ${idForLogging}` - ); + let { editHistory } = message; + if (editHistory) { + log.info(`${idLog}: Looping through ${editHistory.length} edits`); + editHistory = await Promise.all( + editHistory.map(async edit => { + const editAttachmentsToQueue = edit.attachments || []; + log.info( + `${idLog}: Queueing ${editAttachmentsToQueue.length} normal attachment downloads (edited:${edit.timestamp})` + ); + + const { attachments: editAttachments, count: editAttachmentsCount } = + await queueNormalAttachments( + idLog, + messageId, + edit.attachments, + attachments + ); + count += editAttachmentsCount; + + log.info( + `${idLog}: Queueing ${ + (edit.preview || []).length + } preview attachment downloads (edited:${edit.timestamp})` + ); + const { preview: editPreview, count: editPreviewCount } = + await queuePreviews(idLog, messageId, edit.preview, preview); + count += editPreviewCount; + + return { + ...edit, + attachments: editAttachments, + preview: editPreview, + }; + }) + ); + } + + log.info(`${idLog}: Queued ${count} total attachment downloads`); if (count <= 0) { return; } return { - bodyAttachment, attachments, - preview, + bodyAttachment, contact, + editHistory, + preview, quote, sticker, }; } + +async function queueNormalAttachments( + idLog: string, + messageId: string, + attachments: MessageAttributesType['attachments'] = [], + otherAttachments: MessageAttributesType['attachments'] +): Promise<{ + attachments: Array; + count: number; +}> { + // Look through "otherAttachments" which can either be attachments in the + // edit history or the message's attachments and see if any of the attachments + // are the same. If they are let's replace it so that we don't download more + // than once. + // We don't also register the signatures for "attachments" because they would + // then not be added to the AttachmentDownloads job. + const attachmentSignatures: Map = new Map(); + otherAttachments?.forEach(attachment => { + const signature = getAttachmentSignature(attachment); + attachmentSignatures.set(signature, attachment); + }); + + let count = 0; + const nextAttachments = await Promise.all( + attachments.map((attachment, index) => { + if (!attachment) { + return attachment; + } + // We've already downloaded this! + if (isDownloaded(attachment)) { + log.info(`${idLog}: Normal attachment already downloaded`); + return attachment; + } + + const signature = getAttachmentSignature(attachment); + const existingAttachment = signature + ? attachmentSignatures.get(signature) + : undefined; + + // We've already downloaded this elsewhere! + if ( + existingAttachment && + (isDownloading(existingAttachment) || isDownloaded(existingAttachment)) + ) { + log.info( + `${idLog}: Normal attachment already downloaded in other attachments. Replacing` + ); + // Incrementing count so that we update the message's fields downstream + count += 1; + return existingAttachment; + } + + count += 1; + + return AttachmentDownloads.addJob(attachment, { + messageId, + type: 'attachment', + index, + }); + }) + ); + + return { + attachments: nextAttachments, + count, + }; +} + +function getLinkPreviewSignature(preview: LinkPreviewType): string | undefined { + const { image, url } = preview; + + if (!image) { + return; + } + + return `<${url}>${getAttachmentSignature(image)}`; +} + +async function queuePreviews( + idLog: string, + messageId: string, + previews: MessageAttributesType['preview'] = [], + otherPreviews: MessageAttributesType['preview'] +): Promise<{ preview: Array; count: number }> { + // Similar to queueNormalAttachments' logic for detecting same attachments + // except here we also pick by link preview URL. + const previewSignatures: Map = new Map(); + otherPreviews?.forEach(preview => { + const signature = getLinkPreviewSignature(preview); + if (!signature) { + return; + } + previewSignatures.set(signature, preview); + }); + + let count = 0; + + const preview = await Promise.all( + previews.map(async (item, index) => { + if (!item.image) { + return item; + } + // We've already downloaded this! + if (isDownloaded(item.image)) { + log.info(`${idLog}: Preview attachment already downloaded`); + return item; + } + const signature = getLinkPreviewSignature(item); + const existingPreview = signature + ? previewSignatures.get(signature) + : undefined; + + // We've already downloaded this elsewhere! + if ( + existingPreview && + (isDownloading(existingPreview.image) || + isDownloaded(existingPreview.image)) + ) { + log.info(`${idLog}: Preview already downloaded elsewhere. Replacing`); + // Incrementing count so that we update the message's fields downstream + count += 1; + return existingPreview; + } + + count += 1; + return { + ...item, + image: await AttachmentDownloads.addJob(item.image, { + messageId, + type: 'preview', + index, + }), + }; + }) + ); + + return { + preview, + count, + }; +} diff --git a/ts/util/shouldReplyNotifyUser.ts b/ts/util/shouldReplyNotifyUser.ts new file mode 100644 index 000000000..036bbefd8 --- /dev/null +++ b/ts/util/shouldReplyNotifyUser.ts @@ -0,0 +1,82 @@ +// Copyright 2023 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +import type { ConversationModel } from '../models/conversations'; +import type { UUID } from '../types/UUID'; +import type { MessageModel } from '../models/messages'; +import * as log from '../logging/log'; +import dataInterface from '../sql/Client'; +import { isGroup } from './whatTypeOfConversation'; +import { isMessageUnread } from './isMessageUnread'; + +function isSameUuid( + a: UUID | string | null | undefined, + b: UUID | string | null | undefined +): boolean { + return a != null && b != null && String(a) === String(b); +} + +export async function shouldReplyNotifyUser( + message: MessageModel, + conversation: ConversationModel +): Promise { + // Don't notify if the message has already been read + if (!isMessageUnread(message.attributes)) { + return false; + } + + const storyId = message.get('storyId'); + + // If this is not a reply to a story, always notify. + if (storyId == null) { + return true; + } + + // Always notify if this is not a group + if (!isGroup(conversation.attributes)) { + return true; + } + + const matchedStory = window.reduxStore + .getState() + .stories.stories.find(story => { + return story.messageId === storyId; + }); + + // If we can't find the story, don't notify + if (matchedStory == null) { + log.warn("Couldn't find story for reply"); + return false; + } + + const currentUserId = window.textsecure.storage.user.getUuid(); + const storySourceId = matchedStory.sourceUuid; + + const currentUserIdSource = isSameUuid(storySourceId, currentUserId); + + // If the story is from the current user, always notify + if (currentUserIdSource) { + return true; + } + + // If the story is from a different user, only notify if the user has + // replied or reacted to the story + + const replies = await dataInterface.getOlderMessagesByConversation({ + conversationId: conversation.id, + limit: 9000, + storyId, + includeStoryReplies: true, + }); + + const prevCurrentUserReply = replies.find(replyMessage => { + return replyMessage.type === 'outgoing'; + }); + + if (prevCurrentUserReply != null) { + return true; + } + + // Otherwise don't notify + return false; +} diff --git a/yarn.lock b/yarn.lock index 3b8791895..4b2a81650 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2252,10 +2252,10 @@ node-gyp-build "^4.2.3" uuid "^8.3.0" -"@signalapp/mock-server@2.15.0": - version "2.15.0" - resolved "https://registry.yarnpkg.com/@signalapp/mock-server/-/mock-server-2.15.0.tgz#de86ddc4c3f7cbe1e91941832c4b317946e90364" - integrity sha512-bxu4hpnEAAvDT7Yg2LZQNIL/9ciNrGG0hPJlj+dT2iwsHo2AAP8Ej4sLfAiy0O2kYbf2bKcvfTE9C+XwkdAW+w== +"@signalapp/mock-server@2.17.0": + version "2.17.0" + resolved "https://registry.yarnpkg.com/@signalapp/mock-server/-/mock-server-2.17.0.tgz#070eb1e0ea33f5947450ac54fe86ade78e589447" + integrity sha512-qhvhRvvWAlpR2lCGKLJWUSf5fpx//ZljwxqoQy1FZVnRYy6kEPsEUiO3oNE5Y+7HqFlMgD0Yt2OJbamvbQKngg== dependencies: "@signalapp/libsignal-client" "^0.22.0" debug "^4.3.2"