diff --git a/_locales/en/messages.json b/_locales/en/messages.json index d20cae89f06e..2f19682b7d71 100644 --- a/_locales/en/messages.json +++ b/_locales/en/messages.json @@ -2161,6 +2161,9 @@ "icu:accept": { "messageformat": "Accept" }, + "icu:edit": { + "messageformat": "Edit" + }, "forward": { "message": "Forward", "description": "(deleted 03/29/2023)" @@ -3538,6 +3541,10 @@ "messageformat": "Delete failed", "description": "Shown on a message which was deleted for everyone if the delete wasn't successfully sent to anyone" }, + "icu:editFailed": { + "messageformat": "Edit failed, click for details", + "description": "Shown on a message which was edited if the edit wasn't successfully sent to anyone" + }, "sendPaused": { "message": "Send paused", "description": "(deleted 03/29/2023) Shown on outgoing message if it cannot be sent immediately" @@ -4102,6 +4109,10 @@ "messageformat": "Failed to fetch phone number. Check your connection and try again.", "description": "Shown if request to Signal servers to find phone number fails" }, + "icu:ToastManager__CannotEditMessage": { + "messageformat": "Edits can only be applied within 3 hours from the time you sent this message.", + "description": "Error message when you try to send an edit after message becomes too old" + }, "startConversation--username-not-found": { "message": "User not found. $atUsername$ is not a Signal user; make sure you’ve entered the complete username.", "description": "(deleted 03/29/2023) Shown in dialog if username is not found. Note that 'username' will be the output of at-username" @@ -8355,6 +8366,18 @@ "messageformat": "Checking contact's registration status", "description": "Displayed while checking if the contact is SMS-only" }, + "icu:CompositionArea__edit-action--discard": { + "messageformat": "Discard message", + "description": "aria-label for discard edit button" + }, + "icu:CompositionArea__edit-action--send": { + "messageformat": "Send edited message", + "description": "aria-label for send edit button" + }, + "icu:CompositionInput__editing-message": { + "messageformat": "Edit message", + "description": "Status text displayed above composition input when editing a message" + }, "countMutedConversationsDescription": { "message": "Include muted conversations in badge count", "description": "(deleted 03/29/2023) Description for counting muted conversations in badge setting" @@ -12363,6 +12386,14 @@ "messageformat": "Edit history", "description": "Modal title for the edit history messages modal" }, + "icu:ResendMessageEdit__body": { + "messageformat": "This edit could not be sent. Check your connection and try again", + "description": "Modal body for the confirmation dialog shown to user when attempting to resend message edit" + }, + "icu:ResendMessageEdit__button": { + "messageformat": "Send again", + "description": "Button text for the confirmation dialog shown to user when attempting to resend message edit" + }, "WhatsNew__modal-title": { "message": "What's New", "description": "(deleted 03/29/2023) Title for the whats new modal" diff --git a/images/icons/v3/check.svg b/images/icons/v3/check.svg new file mode 100644 index 000000000000..15a9195b67a3 --- /dev/null +++ b/images/icons/v3/check.svg @@ -0,0 +1,3 @@ + + + diff --git a/images/icons/v3/edit.svg b/images/icons/v3/edit.svg new file mode 100644 index 000000000000..7c2af321a0e6 --- /dev/null +++ b/images/icons/v3/edit.svg @@ -0,0 +1,3 @@ + + + diff --git a/images/icons/v3/x.svg b/images/icons/v3/x.svg new file mode 100644 index 000000000000..78ee455e34dd --- /dev/null +++ b/images/icons/v3/x.svg @@ -0,0 +1,3 @@ + + + diff --git a/stylesheets/_modules.scss b/stylesheets/_modules.scss index b07a0d4cfcb6..52a065a69467 100644 --- a/stylesheets/_modules.scss +++ b/stylesheets/_modules.scss @@ -7711,6 +7711,19 @@ button.module-image__border-overlay:focus { } } + &__edit-message::before { + @include light-theme { + @include color-svg('../images/icons/v2/edit-16.svg', $color-black); + } + + @include dark-theme { + @include color-svg( + '../images/icons/v2/edit-solid-16.svg', + $color-gray-15 + ); + } + } + &__delete-message::before { @include light-theme { @include color-svg( diff --git a/stylesheets/components/CompositionArea.scss b/stylesheets/components/CompositionArea.scss index 1013adf19523..8984d52461d4 100644 --- a/stylesheets/components/CompositionArea.scss +++ b/stylesheets/components/CompositionArea.scss @@ -49,6 +49,37 @@ margin-right: 12px; } } + + &__edit-button { + @include button-reset; + @include rounded-corners; + align-items: center; + background-color: $color-gray-45; + display: flex; + height: 28px; + justify-content: center; + width: 28px; + + &::before { + content: ''; + height: 20px; + width: 20px; + } + + &--discard { + &::before { + @include color-svg('../images/icons/v3/x.svg', $color-white); + } + } + &--accept { + background-color: $color-ultramarine; + margin-left: 16px; + &::before { + @include color-svg('../images/icons/v3/check.svg', $color-white); + } + } + } + &__send-button { display: flex; justify-content: center; @@ -69,6 +100,7 @@ &__input { flex-grow: 1; margin: 0 6px; + position: relative; &--large { margin: 0; diff --git a/stylesheets/components/CompositionInput.scss b/stylesheets/components/CompositionInput.scss index 3c2709444043..79890f2f6179 100644 --- a/stylesheets/components/CompositionInput.scss +++ b/stylesheets/components/CompositionInput.scss @@ -4,7 +4,7 @@ .module-composition-input { &__quill { height: 100%; - padding-left: 6px; + padding-left: 12px; .ql-editor { caret-color: transparent; @@ -81,7 +81,7 @@ &__scroller { $padding-top: 6px; - padding: $padding-top; + padding: $padding-top 0; min-height: calc(32px - 2 * $border-size); max-height: calc(72px - 2 * $border-size); @@ -333,6 +333,35 @@ stroke: $color-white; } + + &__editing-message { + @include font-body-2-bold; + margin-top: 10px; + user-select: none; + + &::before { + content: ''; + display: inline-block; + height: 16px; + margin: 0 8px 0 10px; + width: 16px; + vertical-align: middle; + + @include color-svg('../images/icons/v3/edit.svg', $color-black); + + @include dark-theme { + @include color-svg('../images/icons/v3/edit.svg', $color-gray-15); + } + } + + &__attachment img { + height: 18px; + position: absolute; + right: 8px; + top: 8px; + width: 18px; + } + } } div.CompositionInput__link-preview { diff --git a/stylesheets/components/EditHistoryMessagesModal.scss b/stylesheets/components/EditHistoryMessagesModal.scss new file mode 100644 index 000000000000..e8bc592f33b4 --- /dev/null +++ b/stylesheets/components/EditHistoryMessagesModal.scss @@ -0,0 +1,21 @@ +// Copyright 2023 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +.EditHistoryMessagesModal { + .module-message { + padding-left: 0; + padding-right: 0; + + &__link-preview__content { + @include dark-theme { + background-color: $color-gray-75; + } + } + + &__container--incoming { + @include dark-theme { + background-color: $color-gray-65; + } + } + } +} diff --git a/stylesheets/manifest.scss b/stylesheets/manifest.scss index 4836c9b26016..a7a49d733f1c 100644 --- a/stylesheets/manifest.scss +++ b/stylesheets/manifest.scss @@ -75,6 +75,7 @@ @import './components/DisappearingTimeDialog.scss'; @import './components/DisappearingTimerSelect.scss'; @import './components/EditConversationAttributesModal.scss'; +@import './components/EditHistoryMessagesModal.scss'; @import './components/EditUsernameModalBody.scss'; @import './components/ForwardMessageModal.scss'; @import './components/GradientDial.scss'; diff --git a/ts/Crypto.ts b/ts/Crypto.ts index 157eec8c2112..587a08d7af7f 100644 --- a/ts/Crypto.ts +++ b/ts/Crypto.ts @@ -454,8 +454,8 @@ export function decryptAttachment( } export function encryptAttachment( - plaintext: Uint8Array, - keys: Uint8Array + plaintext: Readonly, + keys: Readonly ): EncryptedAttachment { if (!(plaintext instanceof Uint8Array)) { throw new TypeError( @@ -485,6 +485,24 @@ export function encryptAttachment( }; } +export function getAttachmentSizeBucket(size: number): number { + return Math.max( + 541, + Math.floor(1.05 ** Math.ceil(Math.log(size) / Math.log(1.05))) + ); +} + +export function padAndEncryptAttachment( + data: Readonly, + keys: Readonly +): EncryptedAttachment { + const size = data.byteLength; + const paddedSize = getAttachmentSizeBucket(size); + const padding = getZeroes(paddedSize - size); + + return encryptAttachment(Bytes.concatenate([data, padding]), keys); +} + export function encryptProfile(data: Uint8Array, key: Uint8Array): Uint8Array { const iv = getRandomBytes(PROFILE_IV_LENGTH); if (key.byteLength !== PROFILE_KEY_LENGTH) { diff --git a/ts/RemoteConfig.ts b/ts/RemoteConfig.ts index d366f09a4860..99372e459576 100644 --- a/ts/RemoteConfig.ts +++ b/ts/RemoteConfig.ts @@ -17,6 +17,7 @@ export type ConfigKeyType = | 'desktop.calling.audioLevelForSpeaking' | 'desktop.cdsi.returnAcisWithoutUaks' | 'desktop.clientExpiration' + | 'desktop.editMessageSend' | 'desktop.contactManagement.beta' | 'desktop.contactManagement' | 'desktop.groupCallOutboundRing2.beta' diff --git a/ts/background.ts b/ts/background.ts index a5e4b34c4907..4ab09c3d908c 100644 --- a/ts/background.ts +++ b/ts/background.ts @@ -176,6 +176,7 @@ import { showConfirmationDialog } from './util/showConfirmationDialog'; import { onCallEventSync } from './util/onCallEventSync'; import { sleeper } from './util/sleeper'; import { MINUTE } from './util/durations'; +import { copyDataMessageIntoMessage } from './util/copyDataMessageIntoMessage'; import { flushMessageCounter, incrementMessageCounter, @@ -3123,9 +3124,9 @@ export async function startApp(): Promise { }); const editAttributes: EditAttributesType = { - dataMessage: data.message, + conversationId: message.attributes.conversationId, fromId: fromConversation.id, - message: message.attributes, + message: copyDataMessageIntoMessage(data.message, message.attributes), targetSentTimestamp: editedMessageTimestamp, }; @@ -3446,9 +3447,9 @@ export async function startApp(): Promise { }); const editAttributes: EditAttributesType = { - dataMessage: data.message, + conversationId: message.attributes.conversationId, fromId: window.ConversationController.getOurConversationIdOrThrow(), - message: message.attributes, + message: copyDataMessageIntoMessage(data.message, message.attributes), targetSentTimestamp: editedMessageTimestamp, }; diff --git a/ts/components/CompositionArea.stories.tsx b/ts/components/CompositionArea.stories.tsx index 983772bee6ed..9d01937e5f7d 100644 --- a/ts/components/CompositionArea.stories.tsx +++ b/ts/components/CompositionArea.stories.tsx @@ -34,6 +34,7 @@ export default { const useProps = (overrideProps: Partial = {}): Props => ({ addAttachment: action('addAttachment'), conversationId: '123', + discardEditMessage: action('discardEditMessage'), focusCounter: 0, sendCounter: 0, i18n, @@ -47,6 +48,7 @@ const useProps = (overrideProps: Partial = {}): Props => ({ ? overrideProps.isFormattingEnabled : true, messageCompositionId: '456', + sendEditedMessage: action('sendEditedMessage'), sendMultiMediaMessage: action('sendMultiMediaMessage'), processAttachments: action('processAttachments'), removeAttachment: action('removeAttachment'), diff --git a/ts/components/CompositionArea.tsx b/ts/components/CompositionArea.tsx index e61001da6f6d..efb68175e137 100644 --- a/ts/components/CompositionArea.tsx +++ b/ts/components/CompositionArea.tsx @@ -4,6 +4,8 @@ import React, { useCallback, useEffect, useRef, useState } from 'react'; import { get } from 'lodash'; import classNames from 'classnames'; +import type { ReadonlyDeep } from 'type-fest'; + import type { DraftBodyRanges } from '../types/BodyRange'; import type { LocalizerType, ThemeType } from '../types/Util'; import type { ErrorDialogAudioRecorderType } from '../types/AudioRecorder'; @@ -64,6 +66,7 @@ import { useEscapeHandling } from '../hooks/useEscapeHandling'; import type { SmartCompositionRecordingProps } from '../state/smart/CompositionRecording'; import SelectModeActions from './conversation/SelectModeActions'; import type { ShowToastAction } from '../state/ducks/toast'; +import type { DraftEditMessageType } from '../model-types.d'; export type OwnProps = Readonly<{ acceptedMessageRequest?: boolean; @@ -82,6 +85,8 @@ export type OwnProps = Readonly<{ onRecordingComplete: (rec: InMemoryAttachmentDraftType) => unknown ) => unknown; conversationId: string; + discardEditMessage: (id: string) => unknown; + draftEditMessage?: DraftEditMessageType; uuid?: string; draftAttachments: ReadonlyArray; errorDialogAudioRecorderType?: ErrorDialogAudioRecorderType; @@ -117,6 +122,16 @@ export type OwnProps = Readonly<{ id: string, opts: { packId: string; stickerId: number } ): unknown; + sendEditedMessage( + conversationId: string, + options: { + bodyRanges?: DraftBodyRanges; + message?: string; + quoteAuthorUuid?: string; + quoteSentAt?: number; + targetMessageId: string; + } + ): unknown; sendMultiMediaMessage( conversationId: string, options: { @@ -128,10 +143,15 @@ export type OwnProps = Readonly<{ } ): unknown; quotedMessageId?: string; - quotedMessageProps?: Omit< - QuoteProps, - 'i18n' | 'onClick' | 'onClose' | 'withContentAbove' + quotedMessageProps?: ReadonlyDeep< + Omit< + QuoteProps, + 'i18n' | 'onClick' | 'onClose' | 'withContentAbove' | 'isCompose' + > >; + quotedMessageAuthorUuid?: string; + quotedMessageSentAt?: number; + removeAttachment: (conversationId: string, filePath: string) => unknown; scrollToMessage: (conversationId: string, messageId: string) => unknown; setComposerFocus: (conversationId: string) => unknown; @@ -196,6 +216,8 @@ export function CompositionArea({ // Base props addAttachment, conversationId, + discardEditMessage, + draftEditMessage, focusCounter, i18n, imageToBlurHash, @@ -206,6 +228,7 @@ export function CompositionArea({ pushPanelForConversation, processAttachments, removeAttachment, + sendEditedMessage, sendMultiMediaMessage, setComposerFocus, setQuoteByMessageId, @@ -224,6 +247,8 @@ export function CompositionArea({ // Quote quotedMessageId, quotedMessageProps, + quotedMessageAuthorUuid, + quotedMessageSentAt, scrollToMessage, // MediaQualitySelector setMediaQualitySetting, @@ -308,18 +333,42 @@ export function CompositionArea({ } }, [inputApiRef, setLarge]); + const draftEditMessageBody = draftEditMessage?.body; + const editedMessageId = draftEditMessage?.targetMessageId; + const handleSubmit = useCallback( (message: string, bodyRanges: DraftBodyRanges, timestamp: number) => { emojiButtonRef.current?.close(); - sendMultiMediaMessage(conversationId, { - draftAttachments, - bodyRanges, - message, - timestamp, - }); + + if (editedMessageId) { + sendEditedMessage(conversationId, { + bodyRanges, + message, + // sent timestamp for the quote + quoteSentAt: quotedMessageSentAt, + quoteAuthorUuid: quotedMessageAuthorUuid, + targetMessageId: editedMessageId, + }); + } else { + sendMultiMediaMessage(conversationId, { + draftAttachments, + bodyRanges, + message, + timestamp, + }); + } setLarge(false); }, - [conversationId, draftAttachments, sendMultiMediaMessage, setLarge] + [ + conversationId, + draftAttachments, + editedMessageId, + quotedMessageSentAt, + quotedMessageAuthorUuid, + sendEditedMessage, + sendMultiMediaMessage, + setLarge, + ] ); const launchAttachmentPicker = useCallback(() => { @@ -414,11 +463,35 @@ export function CompositionArea({ inputApiRef.current?.setContents(draftText, draftBodyRanges, true); }, [conversationId, draftBodyRanges, draftText, previousConversationId]); + // We want to reset the state of Quill only if: + // + // - Our other device edits the message (edit history length would change) + // - User begins editing another message. + const editHistoryLength = draftEditMessage?.editHistoryLength; + const hasEditHistoryChanged = + usePrevious(editHistoryLength, editHistoryLength) !== editHistoryLength; + const hasEditedMessageChanged = + usePrevious(editedMessageId, editedMessageId) !== editedMessageId; + + const hasEditDraftChanged = hasEditHistoryChanged || hasEditedMessageChanged; + useEffect(() => { + if (!hasEditDraftChanged) { + return; + } + + inputApiRef.current?.setContents( + draftEditMessageBody ?? '', + draftBodyRanges, + true + ); + }, [draftBodyRanges, draftEditMessageBody, hasEditDraftChanged]); + const handleToggleLarge = useCallback(() => { setLarge(l => !l); }, [setLarge]); - const shouldShowMicrophone = !large && !draftAttachments.length && !draftText; + const shouldShowMicrophone = + !large && !draftAttachments.length && !draftText && !draftEditMessage; const showMediaQualitySelector = draftAttachments.some(isImageAttachment); @@ -460,9 +533,29 @@ export function CompositionArea({ ) : null; + const editMessageFragment = draftEditMessage ? ( + <> + {large &&
} +
+
+ + ) : null; + const isRecording = recordingState === RecordingState.Recording; const attButton = - linkPreviewResult || isRecording ? undefined : ( + draftEditMessage || linkPreviewResult || isRecording ? undefined : (
+ ); + } else { + statusInfo = i18n('icu:sendFailed'); + } } else if (isPaused) { statusInfo = i18n('icu:sendPaused'); } else { @@ -126,6 +155,35 @@ export function MessageMetadata({ } } + let confirmation: JSX.Element | undefined; + if (confirmationType === undefined) { + // no-op + } else if (confirmationType === ConfirmationType.EditError) { + confirmation = ( + { + retryMessageSend(id); + setConfirmationType(undefined); + }, + style: 'negative', + text: i18n('icu:ResendMessageEdit__button'), + }, + ]} + i18n={i18n} + onClose={() => { + setConfirmationType(undefined); + }} + > + {i18n('icu:ResendMessageEdit__body')} + + ); + } else { + throw missingCaseError(confirmationType); + } + const className = classNames( 'module-message__metadata', isInline && 'module-message__metadata--inline', @@ -184,17 +242,20 @@ export function MessageMetadata({ )} /> ) : null} + {confirmation} ); + const onResize = useCallback( + ({ bounds }: ContentRect) => { + onWidthMeasured?.(bounds?.width || 0); + }, + [onWidthMeasured] + ); + if (onWidthMeasured) { return ( - { - onWidthMeasured(bounds?.width || 0); - }} - > + {({ measureRef }) => (
{children} diff --git a/ts/components/conversation/Quote.stories.tsx b/ts/components/conversation/Quote.stories.tsx index 9a5639074438..498e13991911 100644 --- a/ts/components/conversation/Quote.stories.tsx +++ b/ts/components/conversation/Quote.stories.tsx @@ -83,6 +83,7 @@ const defaultMessageProps: TimelineMessagesProps = { id: 'some-id', title: 'Person X', }), + canEditMessage: true, canReact: true, canReply: true, canRetry: true, @@ -125,6 +126,7 @@ const defaultMessageProps: TimelineMessagesProps = { renderEmojiPicker: () =>
, renderReactionPicker: () =>
, renderAudioAttachment: () =>
*AudioAttachment*
, + setMessageToEdit: action('setMessageToEdit'), setQuoteByMessageId: action('default--setQuoteByMessageId'), retryMessageSend: action('default--retryMessageSend'), retryDeleteForEveryone: action('default--retryDeleteForEveryone'), diff --git a/ts/components/conversation/Timeline.stories.tsx b/ts/components/conversation/Timeline.stories.tsx index 3a5a692a85c8..1d5685d42cb9 100644 --- a/ts/components/conversation/Timeline.stories.tsx +++ b/ts/components/conversation/Timeline.stories.tsx @@ -49,6 +49,7 @@ function mockMessageTimelineItem( author: getDefaultConversation({}), canDeleteForEveryone: false, canDownload: true, + canEditMessage: true, canReact: true, canReply: true, canRetry: true, @@ -279,6 +280,7 @@ const actions = () => ({ updateSharedGroups: action('updateSharedGroups'), reactToMessage: action('reactToMessage'), + setMessageToEdit: action('setMessageToEdit'), setQuoteByMessageId: action('setQuoteByMessageId'), retryDeleteForEveryone: action('retryDeleteForEveryone'), retryMessageSend: action('retryMessageSend'), diff --git a/ts/components/conversation/TimelineItem.stories.tsx b/ts/components/conversation/TimelineItem.stories.tsx index 96e0e1e3ff7b..09c51e44e2b5 100644 --- a/ts/components/conversation/TimelineItem.stories.tsx +++ b/ts/components/conversation/TimelineItem.stories.tsx @@ -67,6 +67,7 @@ const getDefaultProps = () => ({ reactToMessage: action('reactToMessage'), checkForAccount: action('checkForAccount'), clearTargetedMessage: action('clearTargetedMessage'), + setMessageToEdit: action('setMessageToEdit'), setQuoteByMessageId: action('setQuoteByMessageId'), retryDeleteForEveryone: action('retryDeleteForEveryone'), retryMessageSend: action('retryMessageSend'), diff --git a/ts/components/conversation/TimelineItem.tsx b/ts/components/conversation/TimelineItem.tsx index 94fb17f40a3e..3b84a336306e 100644 --- a/ts/components/conversation/TimelineItem.tsx +++ b/ts/components/conversation/TimelineItem.tsx @@ -197,6 +197,7 @@ export const TimelineItem = memo(function TimelineItem({ renderUniversalTimerNotification, returnToActiveCall, targetMessage, + setMessageToEdit, shouldCollapseAbove, shouldCollapseBelow, shouldHideMetadata, @@ -223,6 +224,7 @@ export const TimelineItem = memo(function TimelineItem({ {...item.data} isTargeted={isTargeted} targetMessage={targetMessage} + setMessageToEdit={setMessageToEdit} shouldCollapseAbove={shouldCollapseAbove} shouldCollapseBelow={shouldCollapseBelow} shouldHideMetadata={shouldHideMetadata} diff --git a/ts/components/conversation/TimelineMessage.stories.tsx b/ts/components/conversation/TimelineMessage.stories.tsx index 76c0943e2bf2..cbd457042760 100644 --- a/ts/components/conversation/TimelineMessage.stories.tsx +++ b/ts/components/conversation/TimelineMessage.stories.tsx @@ -245,6 +245,7 @@ const createProps = (overrideProps: Partial = {}): Props => ({ attachments: overrideProps.attachments, author: overrideProps.author || getDefaultConversation(), bodyRanges: overrideProps.bodyRanges, + canEditMessage: true, canReact: true, canReply: true, canDownload: true, @@ -330,6 +331,7 @@ const createProps = (overrideProps: Partial = {}): Props => ({ overrideProps.toggleSelectMessage == null ? action('toggleSelectMessage') : overrideProps.toggleSelectMessage, + setMessageToEdit: action('setMessageToEdit'), shouldCollapseAbove: isBoolean(overrideProps.shouldCollapseAbove) ? overrideProps.shouldCollapseAbove : false, @@ -878,6 +880,13 @@ Error.args = { text: 'I hope you get this.', }; +export const EditError = Template.bind({}); +EditError.args = { + status: 'error', + isEditedMessage: true, + text: 'I hope you get this.', +}; + export const Paused = Template.bind({}); Paused.args = { status: 'paused', diff --git a/ts/components/conversation/TimelineMessage.tsx b/ts/components/conversation/TimelineMessage.tsx index 5a44d16c91ff..28d4075a760b 100644 --- a/ts/components/conversation/TimelineMessage.tsx +++ b/ts/components/conversation/TimelineMessage.tsx @@ -32,6 +32,7 @@ import type { DeleteMessagesPropsType } from '../../state/ducks/globalModals'; export type PropsData = { canDownload: boolean; + canEditMessage: boolean; canRetry: boolean; canRetryDeleteForEveryone: boolean; canReact: boolean; @@ -50,6 +51,7 @@ export type PropsActions = { ) => void; retryMessageSend: (id: string) => void; retryDeleteForEveryone: (id: string) => void; + setMessageToEdit: (conversationId: string, messageId: string) => unknown; setQuoteByMessageId: (conversationId: string, messageId: string) => void; toggleSelectMessage: ( conversationId: string, @@ -80,6 +82,7 @@ export function TimelineMessage(props: Props): JSX.Element { attachments, author, canDownload, + canEditMessage, canReact, canReply, canRetry, @@ -107,6 +110,7 @@ export function TimelineMessage(props: Props): JSX.Element { saveAttachment, selectedReaction, setQuoteByMessageId, + setMessageToEdit, text, timestamp, toggleDeleteMessagesModal, @@ -350,6 +354,11 @@ export function TimelineMessage(props: Props): JSX.Element { triggerId={triggerId} shouldShowAdditional={shouldShowAdditional} onDownload={handleDownload} + onEdit={ + canEditMessage + ? () => setMessageToEdit(conversationId, id) + : undefined + } onReplyToMessage={handleReplyToMessage} onReact={handleReact} onRetryMessageSend={canRetry ? () => retryMessageSend(id) : undefined} @@ -540,6 +549,7 @@ type MessageContextProps = { shouldShowAdditional: boolean; onDownload: (() => void) | undefined; + onEdit: (() => void) | undefined; onReplyToMessage: (() => void) | undefined; onReact: (() => void) | undefined; onRetryMessageSend: (() => void) | undefined; @@ -555,6 +565,7 @@ const MessageContextMenu = ({ triggerId, shouldShowAdditional, onDownload, + onEdit, onReplyToMessage, onReact, onMoreInfo, @@ -686,6 +697,22 @@ const MessageContextMenu = ({ {i18n('icu:forwardMessage')} )} + {onEdit && ( + { + event.stopPropagation(); + event.preventDefault(); + + onEdit(); + }} + > + {i18n('icu:edit')} + + )} ): Promise<{ - attachments: Array; + attachments: Array; body: undefined | string; - contact?: Array; + contact?: Array; deletedForEveryoneTimestamp: undefined | number; + editedMessageTimestamp: number | undefined; expireTimer: undefined | DurationInSeconds; bodyRanges: undefined | ReadonlyArray; messageTimestamp: number; - preview: Array; - quote: QuotedMessageType | null; - sticker: StickerWithHydratedData | undefined; + preview: Array | undefined; + quote: OutgoingQuoteType | undefined; + sticker: OutgoingStickerType | undefined; reaction: ReactionType | undefined; storyMessage?: MessageModel; storyContext?: StoryContextType; }> { - const { - loadAttachmentData, - loadContactData, - loadPreviewData, - loadQuoteData, - loadStickerData, - } = window.Signal.Migrations; - - let messageTimestamp: number; + const editMessageTimestamp = message.get('editMessageTimestamp'); const sentAt = message.get('sent_at'); const timestamp = message.get('timestamp'); + + let mainMessageTimestamp: number; if (sentAt) { - messageTimestamp = sentAt; + mainMessageTimestamp = sentAt; } else if (timestamp) { log.error('message lacked sent_at. Falling back to timestamp'); - messageTimestamp = timestamp; + mainMessageTimestamp = timestamp; } else { log.error( 'message lacked sent_at and timestamp. Falling back to current time' ); - messageTimestamp = Date.now(); + mainMessageTimestamp = Date.now(); } + const messageTimestamp = editMessageTimestamp || mainMessageTimestamp; + const storyId = message.get('storyId'); - const [attachmentsWithData, contact, preview, quote, sticker, storyMessage] = - await Promise.all([ - // We don't update the caches here because (1) we expect the caches to be populated - // on initial send, so they should be there in the 99% case (2) if you're retrying - // a failed message across restarts, we don't touch the cache for simplicity. If - // sends are failing, let's not add the complication of a cache. - Promise.all((message.get('attachments') ?? []).map(loadAttachmentData)), - message.cachedOutgoingContactData || - loadContactData(message.get('contact')), - message.cachedOutgoingPreviewData || - loadPreviewData(message.get('preview')), - message.cachedOutgoingQuoteData || loadQuoteData(message.get('quote')), - message.cachedOutgoingStickerData || - loadStickerData(message.get('sticker')), - storyId ? getMessageById(storyId) : undefined, - ]); + // Figure out if we need to upload message body as an attachment. + let body = message.get('body'); + let maybeLongAttachment: AttachmentWithHydratedData | undefined; + if (body && body.length > LONG_ATTACHMENT_LIMIT) { + const data = Bytes.fromString(body); - const { body, attachments } = window.Whisper.Message.getLongMessageAttachment( - { - body: message.get('body'), - attachments: attachmentsWithData, - now: messageTimestamp, - } - ); + maybeLongAttachment = { + contentType: LONG_MESSAGE, + fileName: `long-message-${messageTimestamp}.txt`, + data, + size: data.byteLength, + }; + body = body.slice(0, LONG_ATTACHMENT_LIMIT); + } + + const uploadQueue = new PQueue({ + concurrency: MAX_CONCURRENT_ATTACHMENT_UPLOADS, + }); + + const [ + uploadedAttachments, + maybeUploadedLongAttachment, + contact, + preview, + quote, + sticker, + storyMessage, + ] = await Promise.all([ + uploadQueue.addAll( + (message.get('attachments') ?? []).map( + attachment => () => uploadSingleAttachment(message, attachment) + ) + ), + uploadQueue.add(async () => + maybeLongAttachment ? uploadAttachment(maybeLongAttachment) : undefined + ), + uploadMessageContacts(message, uploadQueue), + uploadMessagePreviews(message, uploadQueue), + uploadMessageQuote(message, uploadQueue), + uploadMessageSticker(message, uploadQueue), + storyId ? getMessageById(storyId) : undefined, + ]); + + // Save message after uploading attachments + await window.Signal.Data.saveMessage(message.attributes, { + ourUuid: window.textsecure.storage.user.getCheckedUuid().toString(), + }); const storyReaction = message.get('storyReaction'); + const isEditedMessage = Boolean(message.get('editHistory')); + return { - attachments, + attachments: [ + ...(maybeUploadedLongAttachment ? [maybeUploadedLongAttachment] : []), + ...uploadedAttachments, + ], body, contact, deletedForEveryoneTimestamp: message.get('deletedForEveryoneTimestamp'), + editedMessageTimestamp: isEditedMessage ? mainMessageTimestamp : undefined, expireTimer: message.get('expireTimer'), // TODO: we want filtration here if feature flag doesn't allow format/spoiler sends bodyRanges: message.get('bodyRanges'), messageTimestamp, preview, quote, + reaction: storyReaction + ? { + ...storyReaction, + remove: false, + } + : undefined, sticker, storyMessage, storyContext: storyMessage @@ -551,15 +602,315 @@ async function getMessageSendData({ timestamp: storyMessage.get('sent_at'), } : undefined, - reaction: storyReaction - ? { - ...storyReaction, - remove: false, - } - : undefined, }; } +async function uploadSingleAttachment( + message: MessageModel, + attachment: AttachmentType +): Promise { + const { loadAttachmentData } = window.Signal.Migrations; + + const withData = await loadAttachmentData(attachment); + const uploaded = await uploadAttachment(withData); + + // Add digest to the attachment + const logId = `uploadSingleAttachment(${message.idForLogging()}`; + const oldAttachments = message.get('attachments'); + strictAssert( + oldAttachments !== undefined, + `${logId}: Attachment was uploaded, but message doesn't ` + + 'have attachments anymore' + ); + + const index = oldAttachments.indexOf(attachment); + strictAssert( + index !== -1, + `${logId}: Attachment was uploaded, but isn't in the message anymore` + ); + + const newAttachments = [...oldAttachments]; + newAttachments[index].digest = Bytes.toBase64(uploaded.digest); + + message.set('attachments', newAttachments); + + return uploaded; +} + +async function uploadMessageQuote( + message: MessageModel, + uploadQueue: PQueue +): Promise { + const { loadQuoteData } = window.Signal.Migrations; + + // We don't update the caches here because (1) we expect the caches to be populated + // on initial send, so they should be there in the 99% case (2) if you're retrying + // a failed message across restarts, we don't touch the cache for simplicity. If + // sends are failing, let's not add the complication of a cache. + const loadedQuote = + message.cachedOutgoingQuoteData || + (await loadQuoteData(message.get('quote'))); + + if (!loadedQuote) { + return undefined; + } + + const uploadedAttachments = await uploadQueue.addAll( + loadedQuote.attachments.map( + attachment => async (): Promise => { + const { thumbnail } = attachment; + strictAssert(thumbnail, 'Quote attachment must have a thumbnail'); + + const uploaded = await uploadAttachment(thumbnail); + + return { + contentType: MIMETypeToString(thumbnail.contentType), + fileName: attachment.fileName, + thumbnail: uploaded, + }; + } + ) + ); + + // Update message with attachment digests + const logId = `uploadMessageQuote(${message.idForLogging()}`; + const oldQuote = message.get('quote'); + strictAssert(oldQuote, `${logId}: Quote is gone after upload`); + + const newQuote = { + ...oldQuote, + attachments: oldQuote.attachments.map((attachment, index) => { + strictAssert( + attachment.path === loadedQuote.attachments.at(index)?.path, + `${logId}: Quote attachment ${index} was updated from under us` + ); + + strictAssert( + attachment.thumbnail, + `${logId}: Quote attachment ${index} no longer has a thumbnail` + ); + + return { + ...attachment, + thumbnail: { + ...attachment.thumbnail, + digest: Bytes.toBase64(uploadedAttachments[index].thumbnail.digest), + }, + }; + }), + }; + message.set('quote', newQuote); + + return { + isGiftBadge: loadedQuote.isGiftBadge, + id: loadedQuote.id, + authorUuid: loadedQuote.authorUuid, + text: loadedQuote.text, + bodyRanges: loadedQuote.bodyRanges, + attachments: uploadedAttachments, + }; +} + +async function uploadMessagePreviews( + message: MessageModel, + uploadQueue: PQueue +): Promise | undefined> { + const { loadPreviewData } = window.Signal.Migrations; + + // See uploadMessageQuote for comment on how we do caching for these + // attachments. + const loadedPreviews = + message.cachedOutgoingPreviewData || + (await loadPreviewData(message.get('preview'))); + + if (!loadedPreviews) { + return undefined; + } + if (loadedPreviews.length === 0) { + return []; + } + + const uploadedPreviews = await uploadQueue.addAll( + loadedPreviews.map( + preview => async (): Promise => { + if (!preview.image) { + return { + ...preview, + + // Pacify typescript + image: undefined, + }; + } + + return { + ...preview, + image: await uploadAttachment(preview.image), + }; + } + ) + ); + + // Update message with attachment digests + const logId = `uploadMessagePreviews(${message.idForLogging()}`; + const oldPreview = message.get('preview'); + strictAssert(oldPreview, `${logId}: Link preview is gone after upload`); + + const newPreview = oldPreview.map((preview, index) => { + strictAssert( + preview.image?.path === loadedPreviews.at(index)?.image?.path, + `${logId}: Preview attachment ${index} was updated from under us` + ); + + const uploaded = uploadedPreviews.at(index); + if (!preview.image || !uploaded?.image) { + return preview; + } + + return { + ...preview, + image: { + ...preview.image, + digest: Bytes.toBase64(uploaded.image.digest), + }, + }; + }); + message.set('preview', newPreview); + + return uploadedPreviews; +} + +async function uploadMessageSticker( + message: MessageModel, + uploadQueue: PQueue +): Promise { + const { loadStickerData } = window.Signal.Migrations; + + // See uploadMessageQuote for comment on how we do caching for these + // attachments. + const sticker = + message.cachedOutgoingStickerData || + (await loadStickerData(message.get('sticker'))); + + if (!sticker) { + return undefined; + } + + const uploaded = await uploadQueue.add(() => uploadAttachment(sticker.data)); + + // Add digest to the attachment + const logId = `uploadMessageSticker(${message.idForLogging()}`; + const oldSticker = message.get('sticker'); + strictAssert( + oldSticker?.data !== undefined, + `${logId}: Sticker was uploaded, but message doesn't ` + + 'have a sticker anymore' + ); + strictAssert( + oldSticker.data.path === sticker.data?.path, + `${logId}: Sticker was uploaded, but message has a different sticker` + ); + message.set('sticker', { + ...oldSticker, + data: { + ...oldSticker.data, + digest: Bytes.toBase64(uploaded.digest), + }, + }); + + return { + ...sticker, + data: uploaded, + }; +} + +async function uploadMessageContacts( + message: MessageModel, + uploadQueue: PQueue +): Promise | undefined> { + const { loadContactData } = window.Signal.Migrations; + + // See uploadMessageQuote for comment on how we do caching for these + // attachments. + const contacts = + message.cachedOutgoingContactData || + (await loadContactData(message.get('contact'))); + + if (!contacts) { + return undefined; + } + if (contacts.length === 0) { + return []; + } + + const uploadedContacts = await uploadQueue.addAll( + contacts.map( + contact => async (): Promise => { + const avatar = contact.avatar?.avatar; + // Pacify typescript + if (contact.avatar === undefined || !avatar) { + return { + ...contact, + avatar: undefined, + }; + } + + const uploaded = await uploadAttachment(avatar); + + return { + ...contact, + avatar: { + ...contact.avatar, + avatar: uploaded, + }, + }; + } + ) + ); + + // Add digest to the attachment + const logId = `uploadMessageContacts(${message.idForLogging()}`; + const oldContact = message.get('contact'); + strictAssert(oldContact, `${logId}: Contacts are gone after upload`); + + const newContact = oldContact.map((contact, index) => { + const loaded: EmbeddedContactWithHydratedAvatar | undefined = + contacts.at(index); + if (!contact.avatar) { + strictAssert( + loaded?.avatar === undefined, + `${logId}: Avatar erased in the message` + ); + return contact; + } + + strictAssert( + loaded !== undefined && + loaded.avatar !== undefined && + loaded.avatar.avatar.path === contact.avatar.avatar.path, + `${logId}: Avatar has incorrect path` + ); + const uploaded = uploadedContacts.at(index); + strictAssert( + uploaded !== undefined && uploaded.avatar !== undefined, + `${logId}: Avatar wasn't uploaded properly` + ); + + return { + ...contact, + avatar: { + ...contact.avatar, + avatar: { + ...contact.avatar.avatar, + digest: Bytes.toBase64(uploaded.avatar.avatar.digest), + }, + }, + }; + }); + message.set('contact', newContact); + + return uploadedContacts; +} + async function markMessageFailed( message: MessageModel, errors: Array diff --git a/ts/jobs/helpers/sendStory.ts b/ts/jobs/helpers/sendStory.ts index e28c4f8cb7a3..2cf3e6708a15 100644 --- a/ts/jobs/helpers/sendStory.ts +++ b/ts/jobs/helpers/sendStory.ts @@ -2,10 +2,7 @@ // SPDX-License-Identifier: AGPL-3.0-only import { isEqual } from 'lodash'; -import type { - AttachmentWithHydratedData, - TextAttachmentType, -} from '../../types/Attachment'; +import type { UploadedAttachmentType } from '../../types/Attachment'; import type { ConversationModel } from '../../models/conversations'; import type { ConversationQueueJobBundle, @@ -38,7 +35,9 @@ import { isGroupV2, isMe } from '../../util/whatTypeOfConversation'; import { ourProfileKeyService } from '../../services/ourProfileKey'; import { sendContentMessageToGroup } from '../../util/sendToGroup'; import { distributionListToSendTarget } from '../../util/distributionListToSendTarget'; +import { uploadAttachment } from '../../util/uploadAttachment'; import { SendMessageChallengeError } from '../../textsecure/Errors'; +import type { OutgoingTextAttachmentType } from '../../textsecure/SendMessage'; export async function sendStory( conversation: ConversationModel, @@ -136,15 +135,40 @@ export async function sendStory( return; } - let textAttachment: TextAttachmentType | undefined; - let fileAttachment: AttachmentWithHydratedData | undefined; + let textAttachment: OutgoingTextAttachmentType | undefined; + let fileAttachment: UploadedAttachmentType | undefined; if (attachment.textAttachment) { - textAttachment = attachment.textAttachment; + const localAttachment = attachment.textAttachment; + + // Pacify typescript + if (localAttachment.preview === undefined) { + textAttachment = { + ...localAttachment, + preview: undefined, + }; + } else { + const hydratedPreview = ( + await window.Signal.Migrations.loadPreviewData([ + localAttachment.preview, + ]) + )[0]; + + textAttachment = { + ...localAttachment, + preview: { + ...hydratedPreview, + image: + hydratedPreview.image && + (await uploadAttachment(hydratedPreview.image)), + }, + }; + } } else { - fileAttachment = await window.Signal.Migrations.loadAttachmentData( - attachment - ); + const hydratedAttachment = + await window.Signal.Migrations.loadAttachmentData(attachment); + + fileAttachment = await uploadAttachment(hydratedAttachment); } const groupV2 = isGroupV2(conversation.attributes) diff --git a/ts/messageModifiers/AttachmentDownloads.ts b/ts/messageModifiers/AttachmentDownloads.ts index e388acdeb507..1224c9589343 100644 --- a/ts/messageModifiers/AttachmentDownloads.ts +++ b/ts/messageModifiers/AttachmentDownloads.ts @@ -78,6 +78,7 @@ export async function stop(): Promise { export async function addJob( attachment: AttachmentType, + // TODO: DESKTOP-5279 job: { messageId: string; type: AttachmentDownloadJobTypeType; index: number } ): Promise { if (!attachment) { @@ -482,6 +483,18 @@ async function _addAttachmentToMessage( return; } + const maybeReplaceAttachment = (existing: AttachmentType): AttachmentType => { + if (isDownloaded(existing)) { + return existing; + } + + if (attachmentSignature !== getAttachmentSignature(existing)) { + return existing; + } + + return attachment; + }; + if (type === 'attachment') { const attachments = message.get('attachments'); @@ -498,51 +511,25 @@ async function _addAttachmentToMessage( ...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; + attachments: edit.attachments.map(item => { + const newItem = maybeReplaceAttachment(item); + handledInEditHistory ||= item !== newItem; + return newItem; }), }; }); - if (newEditHistory !== editHistory) { + if (handledInEditHistory) { message.set({ editHistory: newEditHistory }); } } - if (!attachments || attachments.length <= index) { - throw new Error( - `_addAttachmentToMessage: attachments didn't exist or index(${index}) was too large` - ); + if (attachments) { + message.set({ + attachments: attachments.map(item => maybeReplaceAttachment(item)), + }); } - // 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; - - message.set({ attachments: newAttachments }); - return; } @@ -554,69 +541,42 @@ async function _addAttachmentToMessage( const editHistory = message.get('editHistory'); if (preview && editHistory) { const newEditHistory = editHistory.map(edit => { - if (!edit.preview || edit.preview.length <= index) { + if (!edit.preview) { 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, + preview: edit.preview.map(item => { + if (!item.image) { + return item; + } + + const newImage = maybeReplaceAttachment(item.image); + handledInEditHistory ||= item.image !== newImage; + return { ...item, image: newImage }; + }), }; }); - if (newEditHistory !== editHistory) { + if (handledInEditHistory) { message.set({ editHistory: newEditHistory }); } } - if (!preview || preview.length <= index) { - throw new Error( - `_addAttachmentToMessage: preview didn't exist or ${index} was too large` - ); + if (preview) { + message.set({ + preview: preview.map(item => { + if (!item.image) { + return item; + } + return { + ...item, + image: maybeReplaceAttachment(item.image), + }; + }), + }); } - const item = preview[index]; - if (!item) { - 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], - image: attachment, - }; - - message.set({ preview: newPreview }); return; } @@ -628,6 +588,7 @@ async function _addAttachmentToMessage( `_addAttachmentToMessage: contact didn't exist or ${index} was too large` ); } + const item = contact[index]; if (item && item.avatar && item.avatar.avatar) { _checkOldAttachment(item.avatar, 'avatar', logPrefix); @@ -653,38 +614,58 @@ async function _addAttachmentToMessage( if (type === 'quote') { const quote = message.get('quote'); - if (!quote) { - throw new Error("_addAttachmentToMessage: quote didn't exist"); - } - const { attachments } = quote; - if (!attachments || attachments.length <= index) { - throw new Error( - `_addAttachmentToMessage: quote attachments didn't exist or ${index} was too large` - ); + const editHistory = message.get('editHistory'); + let handledInEditHistory = false; + if (editHistory) { + const newEditHistory = editHistory.map(edit => { + if (!edit.quote) { + return edit; + } + + return { + ...edit, + quote: { + ...edit.quote, + attachments: edit.quote.attachments.map(item => { + const { thumbnail } = item; + if (!thumbnail) { + return; + } + + const newThumbnail = maybeReplaceAttachment(thumbnail); + if (thumbnail !== newThumbnail) { + handledInEditHistory = true; + } + return { ...item, thumbnail: newThumbnail }; + }), + }, + }; + }); + + if (handledInEditHistory) { + message.set({ editHistory: newEditHistory }); + } } - const item = attachments[index]; - if (!item) { - throw new Error( - `_addAttachmentToMessage: quote attachment ${index} was falsey` - ); + if (quote) { + const newQuote = { + ...quote, + attachments: quote.attachments.map(item => { + const { thumbnail } = item; + if (!thumbnail) { + return item; + } + + return { + ...item, + thumbnail: maybeReplaceAttachment(thumbnail), + }; + }), + }; + + message.set({ quote: newQuote }); } - _checkOldAttachment(item, 'thumbnail', logPrefix); - - const newAttachments = [...attachments]; - newAttachments[index] = { - ...attachments[index], - thumbnail: attachment, - }; - - const newQuote = { - ...quote, - attachments: newAttachments, - }; - - message.set({ quote: newQuote }); - return; } diff --git a/ts/messageModifiers/Edits.ts b/ts/messageModifiers/Edits.ts index 0b35c49eae3a..56b744b4caa1 100644 --- a/ts/messageModifiers/Edits.ts +++ b/ts/messageModifiers/Edits.ts @@ -3,7 +3,6 @@ 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'; @@ -12,7 +11,7 @@ import { getContactId } from '../messages/helpers'; import { handleEditMessage } from '../util/handleEditMessage'; export type EditAttributesType = { - dataMessage: ProcessedDataMessage; + conversationId: string; fromId: string; message: MessageAttributesType; targetSentTimestamp: number; @@ -29,9 +28,14 @@ export function forMessage(message: MessageModel): Array { }); if (size(matchingEdits) > 0) { - log.info('Edits.forMessage: Found early edit for message'); + const result = Array.from(matchingEdits); + const editsLogIds = result.map(x => x.message.sent_at); + log.info( + `Edits.forMessage(${message.get('sent_at')}): ` + + `Found early edits for message ${editsLogIds.join(', ')}` + ); filter(matchingEdits, item => edits.delete(item)); - return Array.from(matchingEdits); + return result; } return []; @@ -64,7 +68,7 @@ export async function onEdit(edit: EditAttributesType): Promise { targetConversation.queueJob('Edits.onEdit', async () => { log.info('Handling edit for', { targetSentTimestamp: edit.targetSentTimestamp, - sentAt: edit.dataMessage.timestamp, + sentAt: edit.message.timestamp, }); const messages = await window.Signal.Data.getMessagesBySentAt( @@ -74,7 +78,7 @@ export async function onEdit(edit: EditAttributesType): Promise { // Verify authorship const targetMessage = messages.find( m => - edit.message.conversationId === m.conversationId && + edit.conversationId === m.conversationId && edit.fromId === getContactId(m) ); diff --git a/ts/messageModifiers/MessageReceipts.ts b/ts/messageModifiers/MessageReceipts.ts index d2b9bf27f6eb..5386069a49f8 100644 --- a/ts/messageModifiers/MessageReceipts.ts +++ b/ts/messageModifiers/MessageReceipts.ts @@ -286,10 +286,9 @@ export class MessageReceipts extends Collection { const type = receipt.get('type'); try { - const messages = - await window.Signal.Data.getMessagesIncludingEditedBySentAt( - messageSentAt - ); + const messages = await window.Signal.Data.getMessagesBySentAt( + messageSentAt + ); const message = await getTargetMessage( sourceConversationId, diff --git a/ts/messageModifiers/ReadSyncs.ts b/ts/messageModifiers/ReadSyncs.ts index 2b0ad7f2a3af..7038e0b0360d 100644 --- a/ts/messageModifiers/ReadSyncs.ts +++ b/ts/messageModifiers/ReadSyncs.ts @@ -83,10 +83,9 @@ export class ReadSyncs extends Collection { async onSync(sync: ReadSyncModel): Promise { try { - const messages = - await window.Signal.Data.getMessagesIncludingEditedBySentAt( - sync.get('timestamp') - ); + const messages = await window.Signal.Data.getMessagesBySentAt( + 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 112f47dd4456..44f46b2eb059 100644 --- a/ts/messageModifiers/ViewSyncs.ts +++ b/ts/messageModifiers/ViewSyncs.ts @@ -63,10 +63,9 @@ export class ViewSyncs extends Collection { async onSync(sync: ViewSyncModel): Promise { try { - const messages = - await window.Signal.Data.getMessagesIncludingEditedBySentAt( - sync.get('timestamp') - ); + const messages = await window.Signal.Data.getMessagesBySentAt( + sync.get('timestamp') + ); const found = messages.find(item => { const sender = window.ConversationController.lookupOrCreate({ diff --git a/ts/messages/helpers.ts b/ts/messages/helpers.ts index e42015a3d100..5ef8f3a02cb0 100644 --- a/ts/messages/helpers.ts +++ b/ts/messages/helpers.ts @@ -111,7 +111,7 @@ export function getPaymentEventDescription( export function isQuoteAMatch( message: MessageAttributesType | null | undefined, conversationId: string, - quote: QuotedMessageType + quote: Pick ): message is MessageAttributesType { if (!message) { return false; @@ -124,8 +124,13 @@ export function isQuoteAMatch( reason: 'helpers.isQuoteAMatch', }); + const isSameTimestamp = + message.sent_at === id || + message.editHistory?.some(({ timestamp }) => timestamp === id) || + false; + return ( - message.sent_at === id && + isSameTimestamp && message.conversationId === conversationId && getContactId(message) === authorConversation?.id ); diff --git a/ts/model-types.d.ts b/ts/model-types.d.ts index 5712a468475e..1bef01542bcf 100644 --- a/ts/model-types.d.ts +++ b/ts/model-types.d.ts @@ -76,7 +76,7 @@ export type QuotedAttachment = { export type QuotedMessageType = { // TODO DESKTOP-3826 // eslint-disable-next-line @typescript-eslint/no-explicit-any - attachments: Array; + attachments: ReadonlyArray; payment?: AnyPaymentEvent; // `author` is an old attribute that holds the author's E164. We shouldn't use it for // new messages, but old messages might have this attribute. @@ -125,6 +125,7 @@ export type EditHistoryType = { body?: string; bodyRanges?: ReadonlyArray; preview?: Array; + quote?: QuotedMessageType; timestamp: number; }; @@ -278,6 +279,16 @@ export type ValidateConversationType = Pick< 'e164' | 'uuid' | 'type' | 'groupId' >; +export type DraftEditMessageType = { + editHistoryLength: number; + attachmentThumbnail?: string; + bodyRanges?: DraftBodyRanges; + body: string; + preview?: LinkPreviewType; + targetMessageId: string; + quote?: QuotedMessageType; +}; + export type ConversationAttributesType = { accessKey?: string | null; addedBy?: string; @@ -341,6 +352,7 @@ export type ConversationAttributesType = { // Shared fields active_at?: number | null; draft?: string | null; + draftEditMessage?: DraftEditMessageType; hasPostedStory?: boolean; isArchived?: boolean; name?: string; diff --git a/ts/models/conversations.ts b/ts/models/conversations.ts index efc5ec9a7563..7fceaf5351ec 100644 --- a/ts/models/conversations.ts +++ b/ts/models/conversations.ts @@ -38,10 +38,8 @@ import * as Conversation from '../types/Conversation'; import type { StickerType, StickerWithHydratedData } from '../types/Stickers'; import * as Stickers from '../types/Stickers'; import { StorySendMode } from '../types/Stories'; -import type { - ContactWithHydratedAvatar, - GroupV2InfoType, -} from '../textsecure/SendMessage'; +import type { EmbeddedContactWithHydratedAvatar } from '../types/EmbeddedContact'; +import type { GroupV2InfoType } from '../textsecure/SendMessage'; import createTaskWithTimeout from '../textsecure/TaskWithTimeout'; import MessageSender from '../textsecure/SendMessage'; import type { @@ -106,7 +104,10 @@ import { getConversationMembers } from '../util/getConversationMembers'; import { updateConversationsWithUuidLookup } from '../updateConversationsWithUuidLookup'; import { ReadStatus } from '../messages/MessageReadStatus'; import { SendStatus } from '../messages/MessageSendState'; -import type { LinkPreviewType } from '../types/message/LinkPreviews'; +import type { + LinkPreviewType, + LinkPreviewWithHydratedData, +} from '../types/message/LinkPreviews'; import { MINUTE, SECOND, DurationInSeconds } from '../util/durations'; import { concat, filter, map, repeat, zipObject } from '../util/iterables'; import * as universalExpireTimer from '../util/universalExpireTimer'; @@ -1916,6 +1917,7 @@ export class ConversationModel extends window.Backbone const draftTimestamp = this.get('draftTimestamp'); const draftPreview = this.getDraftPreview(); const draftText = dropNull(this.get('draft')); + const draftEditMessage = this.get('draftEditMessage'); const shouldShowDraft = Boolean( this.hasDraft() && draftTimestamp && draftTimestamp >= timestamp ); @@ -1993,6 +1995,7 @@ export class ConversationModel extends window.Backbone draftBodyRanges: this.getDraftBodyRanges(), draftPreview, draftText, + draftEditMessage, familyName: this.get('profileFamilyName'), firstName: this.get('profileName'), groupDescription: this.get('description'), @@ -4008,8 +4011,8 @@ export class ConversationModel extends window.Backbone ): Promise< Array<{ contentType: MIMEType; - fileName: string | null; - thumbnail: ThumbnailType | null; + fileName?: string | null; + thumbnail?: ThumbnailType | null; }> > { return getQuoteAttachment(attachments, preview, sticker); @@ -4105,6 +4108,85 @@ export class ConversationModel extends window.Backbone } } + batchReduxChanges(callback: () => void): void { + strictAssert(!this.isInReduxBatch, 'Nested redux batching is not allowed'); + this.isInReduxBatch = true; + batchDispatch(() => { + try { + callback(); + } finally { + this.isInReduxBatch = false; + } + }); + } + + beforeMessageSend({ + message, + dontAddMessage, + dontClearDraft, + now, + extraReduxActions, + }: { + message: MessageModel; + dontAddMessage: boolean; + dontClearDraft: boolean; + now: number; + extraReduxActions?: () => void; + }): void { + this.batchReduxChanges(() => { + const { clearUnreadMetrics } = window.reduxActions.conversations; + clearUnreadMetrics(this.id); + + const mandatoryProfileSharingEnabled = + window.Signal.RemoteConfig.isEnabled('desktop.mandatoryProfileSharing'); + const enabledProfileSharing = Boolean( + mandatoryProfileSharingEnabled && !this.get('profileSharing') + ); + const unarchivedConversation = Boolean(this.get('isArchived')); + + log.info( + `beforeMessageSend(${this.idForLogging()}): ` + + `clearDraft(${!dontClearDraft}) addMessage(${!dontAddMessage})` + ); + + if (!dontAddMessage) { + this.doAddSingleMessage(message, { isJustSent: true }); + } + const draftProperties = dontClearDraft + ? {} + : { + draft: '', + draftEditMessage: undefined, + draftBodyRanges: [], + draftTimestamp: null, + quotedMessageId: undefined, + lastMessageAuthor: message.getAuthorText(), + lastMessage: message.getNotificationText(), + lastMessageStatus: 'sending' as const, + }; + + this.set({ + ...draftProperties, + ...(enabledProfileSharing ? { profileSharing: true } : {}), + ...(dontAddMessage + ? {} + : this.incrementSentMessageCount({ dry: true })), + active_at: now, + timestamp: now, + ...(unarchivedConversation ? { isArchived: false } : {}), + }); + + if (enabledProfileSharing) { + this.captureChange('beforeMessageSend/mandatoryProfileSharing'); + } + if (unarchivedConversation) { + this.captureChange('beforeMessageSend/unarchive'); + } + + extraReduxActions?.(); + }); + } + async enqueueMessageForSend( { attachments, @@ -4117,14 +4199,14 @@ export class ConversationModel extends window.Backbone }: { attachments: Array; body: string | undefined; - contact?: Array; + contact?: Array; bodyRanges?: DraftBodyRanges; - preview?: Array; + preview?: Array; quote?: QuotedMessageType; sticker?: StickerWithHydratedData; }, { - dontClearDraft, + dontClearDraft = false, sendHQImages, storyId, timestamp, @@ -4156,10 +4238,6 @@ export class ConversationModel extends window.Backbone this.clearTypingTimers(); - const mandatoryProfileSharingEnabled = window.Signal.RemoteConfig.isEnabled( - 'desktop.mandatoryProfileSharing' - ); - let expirationStartTimestamp: number | undefined; let expireTimer: DurationInSeconds | undefined; @@ -4231,7 +4309,24 @@ export class ConversationModel extends window.Backbone const model = new window.Whisper.Message(attributes); const message = window.MessageController.register(model.id, model); message.cachedOutgoingContactData = contact; - message.cachedOutgoingPreviewData = preview; + + // Attach path to preview images so that sendNormalMessage can use them to + // update digests on attachments. + if (preview) { + message.cachedOutgoingPreviewData = preview.map((item, index) => { + if (!item.image) { + return item; + } + + return { + ...item, + image: { + ...item.image, + path: attributes.preview?.at(index)?.image?.path, + }, + }; + }); + } message.cachedOutgoingQuoteData = quote; message.cachedOutgoingStickerData = sticker; @@ -4278,53 +4373,12 @@ export class ConversationModel extends window.Backbone await addStickerPackReference(model.id, sticker.packId); } - this.isInReduxBatch = true; - batchDispatch(() => { - try { - const { clearUnreadMetrics } = window.reduxActions.conversations; - clearUnreadMetrics(this.id); - - const enabledProfileSharing = Boolean( - mandatoryProfileSharingEnabled && !this.get('profileSharing') - ); - const unarchivedConversation = Boolean(this.get('isArchived')); - - this.doAddSingleMessage(model, { isJustSent: true }); - - log.info( - `enqueueMessageForSend(${this.idForLogging()}): clearDraft(${!dontClearDraft})` - ); - const draftProperties = dontClearDraft - ? {} - : { - draft: '', - draftBodyRanges: [], - draftTimestamp: null, - lastMessageAuthor: model.getAuthorText(), - lastMessage: model.getNotificationText(), - lastMessageStatus: 'sending' as const, - }; - - this.set({ - ...draftProperties, - ...(enabledProfileSharing ? { profileSharing: true } : {}), - ...this.incrementSentMessageCount({ dry: true }), - active_at: now, - timestamp: now, - ...(unarchivedConversation ? { isArchived: false } : {}), - }); - - if (enabledProfileSharing) { - this.captureChange('enqueueMessageForSend/mandatoryProfileSharing'); - } - if (unarchivedConversation) { - this.captureChange('enqueueMessageForSend/unarchive'); - } - - extraReduxActions?.(); - } finally { - this.isInReduxBatch = false; - } + this.beforeMessageSend({ + message: model, + dontClearDraft, + dontAddMessage: false, + now, + extraReduxActions, }); const renderDuration = Date.now() - renderStart; diff --git a/ts/models/messages.ts b/ts/models/messages.ts index 2b848483b55e..1e8c2d6cc9e2 100644 --- a/ts/models/messages.ts +++ b/ts/models/messages.ts @@ -55,10 +55,7 @@ import * as reactionUtil from '../reactions/util'; import * as Stickers from '../types/Stickers'; import * as Errors from '../types/errors'; import * as EmbeddedContact from '../types/EmbeddedContact'; -import type { - AttachmentType, - AttachmentWithHydratedData, -} from '../types/Attachment'; +import type { AttachmentType } from '../types/Attachment'; import { isImage, isVideo } from '../types/Attachment'; import * as Attachment from '../types/Attachment'; import { stringToMIMEType } from '../types/MIME'; @@ -138,9 +135,11 @@ import { conversationQueueJobEnum, } from '../jobs/conversationJobQueue'; import { notificationService } from '../services/notifications'; -import type { LinkPreviewType } from '../types/message/LinkPreviews'; +import type { + LinkPreviewType, + LinkPreviewWithHydratedData, +} from '../types/message/LinkPreviews'; import * as log from '../logging/log'; -import * as Bytes from '../Bytes'; import { cleanupMessage, deleteMessageData } from '../util/cleanup'; import { getContact, @@ -162,7 +161,7 @@ import type { ConversationQueueJobData } from '../jobs/conversationJobQueue'; import { getMessageById } from '../messages/getMessageById'; import { shouldDownloadStory } from '../util/shouldDownloadStory'; import { shouldShowStoriesView } from '../state/selectors/stories'; -import type { ContactWithHydratedAvatar } from '../textsecure/SendMessage'; +import type { EmbeddedContactWithHydratedAvatar } from '../types/EmbeddedContact'; import { SeenStatus } from '../MessageSeenStatus'; import { isNewReactionReplacingPrevious } from '../reactions/util'; import { parseBoostBadgeListFromServer } from '../badges/parseBadgesFromServer'; @@ -198,15 +197,6 @@ const { upgradeMessageSchema } = window.Signal.Migrations; const { getMessageBySender } = window.Signal.Data; export class MessageModel extends window.Backbone.Model { - static getLongMessageAttachment: (opts: { - attachments: Array; - body?: string; - now: number; - }) => { - body?: string; - attachments: Array; - }; - CURRENT_PROTOCOL_VERSION?: number; // Set when sending some sync messages, so we get the functionality of @@ -226,9 +216,9 @@ export class MessageModel extends window.Backbone.Model { syncPromise?: Promise; - cachedOutgoingContactData?: Array; + cachedOutgoingContactData?: Array; - cachedOutgoingPreviewData?: Array; + cachedOutgoingPreviewData?: Array; cachedOutgoingQuoteData?: QuotedMessageType; @@ -1075,14 +1065,25 @@ export class MessageModel extends window.Backbone.Model { const inMemoryMessages = window.MessageController.filterBySentAt( Number(sentAt) ); - const matchingMessage = find(inMemoryMessages, message => + let matchingMessage = find(inMemoryMessages, message => isQuoteAMatch(message.attributes, this.get('conversationId'), quote) ); + if (!matchingMessage) { + const messages = await window.Signal.Data.getMessagesBySentAt( + Number(sentAt) + ); + const found = messages.find(item => + isQuoteAMatch(item, this.get('conversationId'), quote) + ); + if (found) { + matchingMessage = window.MessageController.register(found.id, found); + } + } + if (!matchingMessage) { log.info( `doubleCheckMissingQuoteReference/${logId}: No match for ${sentAt}.` ); - return; } @@ -1500,6 +1501,8 @@ export class MessageModel extends window.Backbone.Model { // This is used by sendSyncMessage, then set to null if ('dataMessage' in result.value && result.value.dataMessage) { attributesToUpdate.dataMessage = result.value.dataMessage; + } else if ('editMessage' in result.value && result.value.editMessage) { + attributesToUpdate.dataMessage = result.value.editMessage; } if (!this.doNotSave) { @@ -1683,6 +1686,7 @@ export class MessageModel extends window.Backbone.Model { const isTotalSuccess: boolean = result.success && !this.get('errors')?.length; if (isTotalSuccess) { + delete this.cachedOutgoingContactData; delete this.cachedOutgoingPreviewData; delete this.cachedOutgoingQuoteData; delete this.cachedOutgoingStickerData; @@ -1797,10 +1801,18 @@ export class MessageModel extends window.Backbone.Model { map(conversationsWithSealedSender, c => c.id) ); + const isEditedMessage = Boolean(this.get('editHistory')); + const mainMessageTimestamp = this.get('sent_at') || this.get('timestamp'); + const timestamp = + this.get('editMessageTimestamp') || mainMessageTimestamp; + return handleMessageSend( messaging.sendSyncMessage({ encodedDataMessage: dataMessage, - timestamp: this.get('sent_at'), + editedMessageTimestamp: isEditedMessage + ? mainMessageTimestamp + : undefined, + timestamp, destination: conv.get('e164'), destinationUuid: conv.get('uuid'), expirationStartTimestamp: @@ -1970,8 +1982,7 @@ export class MessageModel extends window.Backbone.Model { queryMessage = matchingMessage; } else { log.info('copyFromQuotedMessage: db lookup needed', id); - const messages = - await window.Signal.Data.getMessagesIncludingEditedBySentAt(id); + const messages = await window.Signal.Data.getMessagesBySentAt(id); const found = messages.find(item => isQuoteAMatch(item, conversationId, result) ); @@ -3090,9 +3101,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); + log.info( + `modifyTargetMessage/${this.idForLogging()}: ${ + edits.length + } edits in second run` + ); await Promise.all( edits.map(editAttributes => - handleEditMessage(message.attributes, editAttributes) + conversation.queueJob('modifyTargetMessage/edits', () => + handleEditMessage(message.attributes, editAttributes) + ) ) ); } @@ -3460,32 +3478,6 @@ export class MessageModel extends window.Backbone.Model { window.Whisper.Message = MessageModel; -window.Whisper.Message.getLongMessageAttachment = ({ - body, - attachments, - now, -}) => { - if (!body || body.length <= 2048) { - return { - body, - attachments, - }; - } - - const data = Bytes.fromString(body); - const attachment = { - contentType: MIME.LONG_MESSAGE, - fileName: `long-message-${now}.txt`, - data, - size: data.byteLength, - }; - - return { - body: body.slice(0, 2048), - attachments: [attachment, ...attachments], - }; -}; - window.Whisper.MessageCollection = window.Backbone.Collection.extend({ model: window.Whisper.Message, comparator(left: Readonly, right: Readonly) { diff --git a/ts/services/LinkPreview.ts b/ts/services/LinkPreview.ts index 9c176b76f14a..ed1c4046b341 100644 --- a/ts/services/LinkPreview.ts +++ b/ts/services/LinkPreview.ts @@ -3,7 +3,7 @@ import { debounce, omit } from 'lodash'; -import type { LinkPreviewType } from '../types/message/LinkPreviews'; +import type { LinkPreviewWithHydratedData } from '../types/message/LinkPreviews'; import type { LinkPreviewImage, LinkPreviewResult, @@ -237,7 +237,9 @@ export async function addLinkPreview( } } -export function getLinkPreviewForSend(message: string): Array { +export function getLinkPreviewForSend( + message: string +): Array { // Don't generate link previews if user has turned them off if (!window.storage.get('linkPreviews', false)) { return []; @@ -260,8 +262,8 @@ export function getLinkPreviewForSend(message: string): Array { } export function sanitizeLinkPreview( - item: LinkPreviewResult | LinkPreviewType -): LinkPreviewType { + item: LinkPreviewResult | LinkPreviewWithHydratedData +): LinkPreviewWithHydratedData { if (item.image) { // We eliminate the ObjectURL here, unneeded for send or save return { diff --git a/ts/signal.ts b/ts/signal.ts index e3bab2d9ccbe..752a1378fc8f 100644 --- a/ts/signal.ts +++ b/ts/signal.ts @@ -42,9 +42,14 @@ import type { } from './types/Attachment'; import type { MessageAttributesType, QuotedMessageType } from './model-types.d'; import type { SignalCoreType } from './window.d'; -import type { EmbeddedContactType } from './types/EmbeddedContact'; -import type { ContactWithHydratedAvatar } from './textsecure/SendMessage'; -import type { LinkPreviewType } from './types/message/LinkPreviews'; +import type { + EmbeddedContactType, + EmbeddedContactWithHydratedAvatar, +} from './types/EmbeddedContact'; +import type { + LinkPreviewType, + LinkPreviewWithHydratedData, +} from './types/message/LinkPreviews'; import type { StickerType, StickerWithHydratedData } from './types/Stickers'; type MigrationsModuleType = { @@ -75,13 +80,13 @@ type MigrationsModuleType = { ) => Promise; loadContactData: ( contact: Array | undefined - ) => Promise | undefined>; + ) => Promise | undefined>; loadMessage: ( message: MessageAttributesType ) => Promise; loadPreviewData: ( preview: Array | undefined - ) => Promise>; + ) => Promise>; loadQuoteData: ( quote: QuotedMessageType | null | undefined ) => Promise; diff --git a/ts/sql/Interface.ts b/ts/sql/Interface.ts index a8eccdc5c731..8a052d413b7a 100644 --- a/ts/sql/Interface.ts +++ b/ts/sql/Interface.ts @@ -561,9 +561,6 @@ export type DataInterface = { _removeAllMessages: () => Promise; getAllMessageIds: () => Promise>; getMessagesBySentAt: (sentAt: number) => Promise>; - getMessagesIncludingEditedBySentAt: ( - sentAt: number - ) => Promise>; getExpiredMessages: () => Promise>; getMessagesUnexpectedlyMissingExpirationStartTimestamp: () => Promise< Array diff --git a/ts/sql/Server.ts b/ts/sql/Server.ts index 00c6ec7c4cd3..e39af10f10c5 100644 --- a/ts/sql/Server.ts +++ b/ts/sql/Server.ts @@ -257,7 +257,6 @@ const dataInterface: ServerInterface = { _removeAllMessages, getAllMessageIds, getMessagesBySentAt, - getMessagesIncludingEditedBySentAt, getUnreadEditedMessagesAndMarkRead, getExpiredMessages, getMessagesUnexpectedlyMissingExpirationStartTimestamp, @@ -3136,17 +3135,19 @@ async function getMessagesBySentAt( sentAt: number ): Promise> { const db = getInstance(); - const rows: JSONRows = db - .prepare( - ` - SELECT json FROM messages - WHERE sent_at = $sent_at - ORDER BY received_at DESC, sent_at DESC; - ` - ) - .all({ - sent_at: sentAt, - }); + + 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)); } @@ -5718,27 +5719,6 @@ async function saveEditedMessage( })(); } -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 }> > { diff --git a/ts/state/ducks/composer.ts b/ts/state/ducks/composer.ts index ba35e53aaac3..000fa5859518 100644 --- a/ts/state/ducks/composer.ts +++ b/ts/state/ducks/composer.ts @@ -4,7 +4,7 @@ import path from 'path'; import { debounce, isEqual } from 'lodash'; -import type { ThunkAction } from 'redux-thunk'; +import type { ThunkAction, ThunkDispatch } from 'redux-thunk'; import type { ReadonlyDeep } from 'type-fest'; import type { @@ -87,6 +87,7 @@ import { longRunningTaskWrapper } from '../../util/longRunningTaskWrapper'; import { drop } from '../../util/drop'; import { strictAssert } from '../../util/assert'; import { makeQuote } from '../../util/makeQuote'; +import { sendEditedMessage as doSendEditedMessage } from '../../util/sendEditedMessage'; import { maybeBlockSendForFormattingModal } from '../../util/maybeBlockSendForFormattingModal'; // State @@ -138,7 +139,7 @@ const ADD_PENDING_ATTACHMENT = 'composer/ADD_PENDING_ATTACHMENT'; const INCREMENT_SEND_COUNTER = 'composer/INCREMENT_SEND_COUNTER'; const REPLACE_ATTACHMENTS = 'composer/REPLACE_ATTACHMENTS'; const RESET_COMPOSER = 'composer/RESET_COMPOSER'; -const SET_FOCUS = 'composer/SET_FOCUS'; +export const SET_FOCUS = 'composer/SET_FOCUS'; const SET_HIGH_QUALITY_SETTING = 'composer/SET_HIGH_QUALITY_SETTING'; const SET_QUOTED_MESSAGE = 'composer/SET_QUOTED_MESSAGE'; const SET_COMPOSER_DISABLED = 'composer/SET_COMPOSER_DISABLED'; @@ -238,6 +239,7 @@ export const actions = { replaceAttachments, resetComposer, scrollToQuotedMessage, + sendEditedMessage, sendMultiMediaMessage, sendStickerMessage, setComposerDisabledState, @@ -304,6 +306,7 @@ function onCloseLinkPreview(conversationId: string): NoopActionType { payload: null, }; } + function onTextTooLong(): ShowToastActionType { return { type: SHOW_TOAST, @@ -377,14 +380,159 @@ export function handleLeaveConversation( }; } +// eslint-disable-next-line local-rules/type-alias-readonlydeep +type WithPreSendChecksOptions = Readonly<{ + bodyRanges?: DraftBodyRanges; + message?: string; + voiceNoteAttachment?: InMemoryAttachmentDraftType; +}>; + +async function withPreSendChecks( + conversationId: string, + options: WithPreSendChecksOptions, + dispatch: ThunkDispatch< + RootStateType, + unknown, + SetComposerDisabledStateActionType | ShowToastActionType + >, + body: () => Promise +): Promise { + const conversation = window.ConversationController.get(conversationId); + if (!conversation) { + throw new Error('sendMultiMediaMessage: No conversation found'); + } + + const sendStart = Date.now(); + const recipientsByConversation = getRecipientsByConversation([ + conversation.attributes, + ]); + + const { bodyRanges, message, voiceNoteAttachment } = options; + + try { + dispatch(setComposerDisabledState(conversationId, true)); + + try { + const sendAnyway = await blockSendUntilConversationsAreVerified( + recipientsByConversation, + SafetyNumberChangeSource.MessageSend + ); + if (!sendAnyway) { + dispatch(setComposerDisabledState(conversationId, false)); + return; + } + } catch (error) { + log.error( + 'withPreSendChecks block until verified error:', + Errors.toLogFormat(error) + ); + return; + } + + try { + if (bodyRanges?.length && !window.storage.get('formattingWarningShown')) { + const sendAnyway = await maybeBlockSendForFormattingModal(bodyRanges); + if (!sendAnyway) { + dispatch(setComposerDisabledState(conversationId, false)); + return; + } + drop(window.storage.put('formattingWarningShown', true)); + } + } catch (error) { + log.error( + 'withPreSendChecks block for formatting modal:', + Errors.toLogFormat(error) + ); + return; + } + + const toast = shouldShowInvalidMessageToast(conversation.attributes); + if (toast != null) { + dispatch({ + type: SHOW_TOAST, + payload: toast, + }); + return; + } + + if ( + !message?.length && + !hasDraftAttachments(conversation.attributes.draftAttachments, { + includePending: false, + }) && + !voiceNoteAttachment + ) { + return; + } + + const sendDelta = Date.now() - sendStart; + log.info(`withPreSendChecks: Send pre-checks took ${sendDelta}ms`); + + await body(); + } finally { + dispatch(setComposerDisabledState(conversationId, false)); + } + + conversation.clearTypingTimers(); +} + +function sendEditedMessage( + conversationId: string, + options: WithPreSendChecksOptions & { + targetMessageId: string; + quoteAuthorUuid?: string; + quoteSentAt?: number; + } +): ThunkAction< + void, + RootStateType, + unknown, + SetComposerDisabledStateActionType | ShowToastActionType +> { + return async dispatch => { + const conversation = window.ConversationController.get(conversationId); + if (!conversation) { + throw new Error('sendEditedMessage: No conversation found'); + } + + const { + message = '', + bodyRanges, + quoteSentAt, + quoteAuthorUuid, + targetMessageId, + } = options; + + await withPreSendChecks(conversationId, options, dispatch, async () => { + try { + await doSendEditedMessage(conversationId, { + body: message, + bodyRanges, + preview: getLinkPreviewForSend(message), + quoteAuthorUuid, + quoteSentAt, + targetMessageId, + }); + } catch (error) { + log.error('sendEditedMessage', Errors.toLogFormat(error)); + if (error.toastType) { + dispatch({ + type: SHOW_TOAST, + payload: { + toastType: error.toastType, + }, + }); + } + } + }); + }; +} + function sendMultiMediaMessage( conversationId: string, - options: { + options: WithPreSendChecksOptions & { draftAttachments?: ReadonlyArray; - bodyRanges?: DraftBodyRanges; - message?: string; timestamp?: number; - voiceNoteAttachment?: InMemoryAttachmentDraftType; } ): ThunkAction< void, @@ -413,73 +561,7 @@ function sendMultiMediaMessage( const state = getState(); - const sendStart = Date.now(); - const recipientsByConversation = getRecipientsByConversation([ - conversation.attributes, - ]); - - try { - dispatch(setComposerDisabledState(conversationId, true)); - - const sendAnyway = await blockSendUntilConversationsAreVerified( - recipientsByConversation, - SafetyNumberChangeSource.MessageSend - ); - if (!sendAnyway) { - dispatch(setComposerDisabledState(conversationId, false)); - return; - } - } catch (error) { - dispatch(setComposerDisabledState(conversationId, false)); - log.error( - 'sendMessage block until verified error:', - Errors.toLogFormat(error) - ); - return; - } - - try { - if (bodyRanges?.length && !window.storage.get('formattingWarningShown')) { - const sendAnyway = await maybeBlockSendForFormattingModal(bodyRanges); - if (!sendAnyway) { - dispatch(setComposerDisabledState(conversationId, false)); - return; - } - drop(window.storage.put('formattingWarningShown', true)); - } - } catch (error) { - dispatch(setComposerDisabledState(conversationId, false)); - log.error( - 'sendMessage block for formatting modal:', - Errors.toLogFormat(error) - ); - return; - } - - conversation.clearTypingTimers(); - - const toast = shouldShowInvalidMessageToast(conversation.attributes); - if (toast != null) { - dispatch({ - type: SHOW_TOAST, - payload: toast, - }); - dispatch(setComposerDisabledState(conversationId, false)); - return; - } - - if ( - !message.length && - !hasDraftAttachments(conversation.attributes.draftAttachments, { - includePending: false, - }) && - !voiceNoteAttachment - ) { - dispatch(setComposerDisabledState(conversationId, false)); - return; - } - - try { + await withPreSendChecks(conversationId, options, dispatch, async () => { let attachments: Array = []; if (voiceNoteAttachment) { attachments = [voiceNoteAttachment]; @@ -505,48 +587,45 @@ function sendMultiMediaMessage( ? shouldSendHighQualityAttachments : state.items['sent-media-quality'] === 'high'; - const sendDelta = Date.now() - sendStart; - - log.info('Send pre-checks took', sendDelta, 'milliseconds'); - - await conversation.enqueueMessageForSend( - { - body: message, - attachments, - quote, - preview: getLinkPreviewForSend(message), - bodyRanges, - }, - { - sendHQImages, - timestamp, - // We rely on enqueueMessageForSend to call these within redux's batch - extraReduxActions: () => { - conversation.setMarkedUnread(false); - resetLinkPreview(conversationId); - drop( - clearConversationDraftAttachments( - conversationId, - draftAttachments - ) - ); - setQuoteByMessageId(conversationId, undefined)( - dispatch, - getState, - undefined - ); - dispatch(incrementSendCounter(conversationId)); - dispatch(setComposerDisabledState(conversationId, false)); + try { + await conversation.enqueueMessageForSend( + { + body: message, + attachments, + quote, + preview: getLinkPreviewForSend(message), + bodyRanges, }, - } - ); - } catch (error) { - log.error( - 'Error pulling attached files before send', - Errors.toLogFormat(error) - ); - dispatch(setComposerDisabledState(conversationId, false)); - } + { + sendHQImages, + timestamp, + // We rely on enqueueMessageForSend to call these within redux's batch + extraReduxActions: () => { + conversation.setMarkedUnread(false); + resetLinkPreview(conversationId); + drop( + clearConversationDraftAttachments( + conversationId, + draftAttachments + ) + ); + setQuoteByMessageId(conversationId, undefined)( + dispatch, + getState, + undefined + ); + dispatch(incrementSendCounter(conversationId)); + dispatch(setComposerDisabledState(conversationId, false)); + }, + } + ); + } catch (error) { + log.error( + 'Error pulling attached files before send', + Errors.toLogFormat(error) + ); + } + }); }; } @@ -668,6 +747,7 @@ export function setQuoteByMessageId( window.Signal.Data.updateConversation(conversation.attributes); } + const draftEditMessage = conversation.get('draftEditMessage'); if (message) { const quote = await makeQuote(message.attributes); @@ -676,15 +756,31 @@ export function setQuoteByMessageId( return; } - dispatch( - setQuotedMessage(conversationId, { - conversationId, - quote, - }) - ); + if (draftEditMessage) { + conversation.set({ + draftEditMessage: { + ...draftEditMessage, + quote, + }, + }); + } else { + dispatch( + setQuotedMessage(conversationId, { + conversationId, + quote, + }) + ); + } dispatch(setComposerFocus(conversation.id)); dispatch(setComposerDisabledState(conversationId, false)); + } else if (draftEditMessage) { + conversation.set({ + draftEditMessage: { + ...draftEditMessage, + quote: undefined, + }, + }); } else { dispatch(setQuotedMessage(conversationId, undefined)); } diff --git a/ts/state/ducks/conversations.ts b/ts/state/ducks/conversations.ts index cff06d299565..53b5ef7dbb13 100644 --- a/ts/state/ducks/conversations.ts +++ b/ts/state/ducks/conversations.ts @@ -51,8 +51,9 @@ import type { CustomColorType, } from '../../types/Colors'; import type { - LastMessageStatus, ConversationAttributesType, + DraftEditMessageType, + LastMessageStatus, MessageAttributesType, } from '../../model-types.d'; import type { @@ -76,6 +77,7 @@ import { writeProfile } from '../../services/writeProfile'; import { getConversationUuidsStoppingSend, getConversationIdsStoppedForVerification, + getConversationSelector, getMe, getMessagesByConversation, } from '../selectors/conversations'; @@ -108,7 +110,11 @@ import { import { missingCaseError } from '../../util/missingCaseError'; import { viewSyncJobQueue } from '../../jobs/viewSyncJobQueue'; import { ReadStatus } from '../../messages/MessageReadStatus'; -import { isIncoming, isOutgoing } from '../selectors/message'; +import { + isIncoming, + isOutgoing, + processBodyRanges, +} from '../selectors/message'; import { getActiveCallState } from '../selectors/calling'; import { sendDeleteForEveryoneMessage } from '../../util/sendDeleteForEveryoneMessage'; import type { ShowToastActionType } from './toast'; @@ -144,6 +150,7 @@ import type { SetQuotedMessageActionType, } from './composer'; import { + SET_FOCUS, replaceAttachments, setComposerFocus, setQuoteByMessageId, @@ -288,6 +295,7 @@ export type ConversationType = ReadonlyDeep< shouldShowDraft?: boolean; // Full information for re-hydrating composition area draftText?: string; + draftEditMessage?: DraftEditMessageType; draftBodyRanges?: DraftBodyRanges; // Summary for the left pane draftPreview?: DraftPreviewType; @@ -1003,6 +1011,7 @@ export const actions = { deleteMessages, deleteMessagesForEveryone, destroyMessages, + discardEditMessage, discardMessages, doubleCheckMissingQuoteReference, generateNewGroupLink, @@ -1063,6 +1072,7 @@ export const actions = { setIsFetchingUUID, setIsNearBottom, setMessageLoadingState, + setMessageToEdit, setMuteExpiration, setPinned, setPreJoinConversation, @@ -1717,6 +1727,73 @@ function destroyMessages( }; } +function discardEditMessage( + conversationId: string +): ThunkAction { + return () => { + window.ConversationController.get(conversationId)?.set( + { + draftEditMessage: undefined, + draftBodyRanges: undefined, + draft: undefined, + quotedMessageId: undefined, + }, + { unset: true } + ); + }; +} + +function setMessageToEdit( + conversationId: string, + messageId: string +): ThunkAction { + return async (dispatch, getState) => { + const conversation = window.ConversationController.get(conversationId); + + if (!conversation) { + return; + } + + const message = (await getMessageById(messageId))?.attributes; + if (!message) { + return; + } + + if (!message.body) { + return; + } + + let attachmentThumbnail: string | undefined; + if (message.attachments) { + const thumbnailPath = message.attachments[0]?.thumbnail?.path; + attachmentThumbnail = thumbnailPath + ? window.Signal.Migrations.getAbsoluteAttachmentPath(thumbnailPath) + : undefined; + } + + conversation.set({ + draftEditMessage: { + body: message.body, + editHistoryLength: message.editHistory?.length ?? 0, + attachmentThumbnail, + preview: message.preview ? message.preview[0] : undefined, + targetMessageId: messageId, + quote: message.quote, + }, + draftBodyRanges: processBodyRanges(message, { + conversationSelector: getConversationSelector(getState()), + }), + }); + + dispatch({ + type: SET_FOCUS, + payload: { + conversationId, + }, + }); + }; +} + function generateNewGroupLink( conversationId: string ): ThunkAction { diff --git a/ts/state/ducks/globalModals.ts b/ts/state/ducks/globalModals.ts index 92cbbc75554d..bd9273251cfa 100644 --- a/ts/state/ducks/globalModals.ts +++ b/ts/state/ducks/globalModals.ts @@ -718,6 +718,10 @@ function copyOverMessageAttributesIntoEditHistory( return messageAttributes.editHistory.map(editedMessageAttributes => ({ ...messageAttributes, + // Always take attachments from the edited message (they might be absent) + attachments: undefined, + quote: undefined, + preview: [], ...editedMessageAttributes, // For timestamp uniqueness of messages sent_at: editedMessageAttributes.timestamp, diff --git a/ts/state/selectors/message.ts b/ts/state/selectors/message.ts index 7536dda544bc..4c7c20336d1a 100644 --- a/ts/state/selectors/message.ts +++ b/ts/state/selectors/message.ts @@ -7,6 +7,8 @@ import filesize from 'filesize'; import getDirection from 'direction'; import emojiRegex from 'emoji-regex'; import LinkifyIt from 'linkify-it'; +import type { ReadonlyDeep } from 'type-fest'; + import type { StateType } from '../reducer'; import type { LastMessageStatus, @@ -66,6 +68,7 @@ import { isNotNil } from '../../util/isNotNil'; import { isMoreRecentThan } from '../../util/timestamp'; import * as iterables from '../../util/iterables'; import { strictAssert } from '../../util/assert'; +import { canEditMessages } from '../../util/canEditMessages'; import { getAccountSelector } from './accounts'; import { @@ -127,6 +130,7 @@ import { getTitleNoDefault, getNumber } from '../../util/getTitle'; export { isIncoming, isOutgoing, isStory }; +const MAX_EDIT_COUNT = 10; const THREE_HOURS = 3 * HOUR; const linkify = LinkifyIt(); @@ -502,9 +506,8 @@ const getPropsForStoryReplyContext = ( }; export const getPropsForQuote = ( - message: Pick< - MessageWithUIFieldsType, - 'conversationId' | 'quote' | 'payment' + message: ReadonlyDeep< + Pick >, { conversationSelector, @@ -717,6 +720,7 @@ export const getPropsForMessage = ( storyReplyContext, textAttachment, payment, + canEditMessage: canEditMessage(message), canDeleteForEveryone: canDeleteForEveryone(message), canDownload: canDownload(message, conversationSelector), canReact: canReact(message, ourConversationId, conversationSelector), @@ -1811,6 +1815,18 @@ export function canRetryDeleteForEveryone( ); } +export function canEditMessage(message: MessageWithUIFieldsType): boolean { + return ( + canEditMessages() && + !message.deletedForEveryone && + isOutgoing(message) && + isMoreRecentThan(message.sent_at, THREE_HOURS) && + (message.editHistory?.length ?? 0) <= MAX_EDIT_COUNT && + someSendStatus(message.sendStateByConversationId, isSent) && + Boolean(message.body) + ); +} + export function canDownload( message: MessageWithUIFieldsType, conversationSelector: GetConversationByIdType diff --git a/ts/state/smart/CompositionArea.tsx b/ts/state/smart/CompositionArea.tsx index 93b7f23ec22e..124922d8b290 100644 --- a/ts/state/smart/CompositionArea.tsx +++ b/ts/state/smart/CompositionArea.tsx @@ -4,6 +4,7 @@ import React from 'react'; import { connect } from 'react-redux'; import { get } from 'lodash'; + import { mapDispatchToProps } from '../actions'; import type { Props as ComponentPropsType } from '../../components/CompositionArea'; import { CompositionArea } from '../../components/CompositionArea'; @@ -58,8 +59,13 @@ const mapStateToProps = (state: StateType, props: ExternalProps) => { throw new Error(`Conversation id ${id} not found!`); } - const { announcementsOnly, areWeAdmin, draftText, draftBodyRanges } = - conversation; + const { + announcementsOnly, + areWeAdmin, + draftEditMessage, + draftText, + draftBodyRanges, + } = conversation; const receivedPacks = getReceivedStickerPacks(state); const installedPacks = getInstalledStickerPacks(state); @@ -82,6 +88,7 @@ const mapStateToProps = (state: StateType, props: ExternalProps) => { const composerStateForConversationIdSelector = getComposerStateForConversationIdSelector(state); + const composerState = composerStateForConversationIdSelector(id); const { attachments: draftAttachments, focusCounter, @@ -89,10 +96,17 @@ const mapStateToProps = (state: StateType, props: ExternalProps) => { linkPreviewLoading, linkPreviewResult, messageCompositionId, - quotedMessage, sendCounter, shouldSendHighQualityAttachments, - } = composerStateForConversationIdSelector(id); + } = composerState; + + let { quotedMessage } = composerState; + if (!quotedMessage && draftEditMessage?.quote) { + quotedMessage = { + conversationId: id, + quote: draftEditMessage.quote, + }; + } const recentEmojis = selectRecentEmojis(state); @@ -107,6 +121,7 @@ const mapStateToProps = (state: StateType, props: ExternalProps) => { return { // Base conversationId: id, + draftEditMessage, focusCounter, getPreferredBadge: getPreferredBadgeSelector(state), i18n: getIntl(state), @@ -141,6 +156,8 @@ const mapStateToProps = (state: StateType, props: ExternalProps) => { ourConversationId: getUserConversationId(state), }) : undefined, + quotedMessageAuthorUuid: quotedMessage?.quote?.authorUuid, + quotedMessageSentAt: quotedMessage?.quote?.id, // Emojis recentEmojis, skinTone: getEmojiSkinTone(state), diff --git a/ts/state/smart/MessageDetail.tsx b/ts/state/smart/MessageDetail.tsx index 873a4de7f5cd..fc97f3c54039 100644 --- a/ts/state/smart/MessageDetail.tsx +++ b/ts/state/smart/MessageDetail.tsx @@ -44,6 +44,7 @@ export function SmartMessageDetail(): JSX.Element | null { markAttachmentAsCorrupted, messageExpanded, openGiftBadge, + retryMessageSend, popPanelForConversation, pushPanelForConversation, saveAttachment, @@ -91,6 +92,7 @@ export function SmartMessageDetail(): JSX.Element | null { message={message} messageExpanded={messageExpanded} openGiftBadge={openGiftBadge} + retryMessageSend={retryMessageSend} pushPanelForConversation={pushPanelForConversation} receivedAt={receivedAt} renderAudioAttachment={renderAudioAttachment} diff --git a/ts/state/smart/TimelineItem.tsx b/ts/state/smart/TimelineItem.tsx index a80d849e8188..9c70e7682803 100644 --- a/ts/state/smart/TimelineItem.tsx +++ b/ts/state/smart/TimelineItem.tsx @@ -123,6 +123,7 @@ export function SmartTimelineItem(props: ExternalProps): JSX.Element { saveAttachment, targetMessage, toggleSelectMessage, + setMessageToEdit, showConversation, showExpiredIncomingTapToViewToast, showExpiredOutgoingTapToViewToast, @@ -190,6 +191,7 @@ export function SmartTimelineItem(props: ExternalProps): JSX.Element { scrollToQuotedMessage={scrollToQuotedMessage} targetMessage={targetMessage} setQuoteByMessageId={setQuoteByMessageId} + setMessageToEdit={setMessageToEdit} showContactModal={showContactModal} showConversation={showConversation} showExpiredIncomingTapToViewToast={showExpiredIncomingTapToViewToast} diff --git a/ts/test-electron/Crypto_test.ts b/ts/test-electron/Crypto_test.ts index ce99102448ee..77577218a8f8 100644 --- a/ts/test-electron/Crypto_test.ts +++ b/ts/test-electron/Crypto_test.ts @@ -11,6 +11,7 @@ import { decryptProfileName, encryptProfile, decryptProfile, + getAttachmentSizeBucket, getRandomBytes, constantTimeEqual, generateRegistrationId, @@ -30,6 +31,36 @@ import { bytesToUuid, } from '../Crypto'; +const BUCKET_SIZES = [ + 541, 568, 596, 626, 657, 690, 725, 761, 799, 839, 881, 925, 972, 1020, 1071, + 1125, 1181, 1240, 1302, 1367, 1436, 1507, 1583, 1662, 1745, 1832, 1924, 2020, + 2121, 2227, 2339, 2456, 2579, 2708, 2843, 2985, 3134, 3291, 3456, 3629, 3810, + 4001, 4201, 4411, 4631, 4863, 5106, 5361, 5629, 5911, 6207, 6517, 6843, 7185, + 7544, 7921, 8318, 8733, 9170, 9629, 10110, 10616, 11146, 11704, 12289, 12903, + 13549, 14226, 14937, 15684, 16469, 17292, 18157, 19065, 20018, 21019, 22070, + 23173, 24332, 25549, 26826, 28167, 29576, 31054, 32607, 34238, 35950, 37747, + 39634, 41616, 43697, 45882, 48176, 50585, 53114, 55770, 58558, 61486, 64561, + 67789, 71178, 74737, 78474, 82398, 86518, 90843, 95386, 100155, 105163, + 110421, 115942, 121739, 127826, 134217, 140928, 147975, 155373, 163142, + 171299, 179864, 188858, 198300, 208215, 218626, 229558, 241036, 253087, + 265742, 279029, 292980, 307629, 323011, 339161, 356119, 373925, 392622, + 412253, 432866, 454509, 477234, 501096, 526151, 552458, 580081, 609086, + 639540, 671517, 705093, 740347, 777365, 816233, 857045, 899897, 944892, + 992136, 1041743, 1093831, 1148522, 1205948, 1266246, 1329558, 1396036, + 1465838, 1539130, 1616086, 1696890, 1781735, 1870822, 1964363, 2062581, + 2165710, 2273996, 2387695, 2507080, 2632434, 2764056, 2902259, 3047372, + 3199740, 3359727, 3527714, 3704100, 3889305, 4083770, 4287958, 4502356, + 4727474, 4963848, 5212040, 5472642, 5746274, 6033588, 6335268, 6652031, + 6984633, 7333864, 7700558, 8085585, 8489865, 8914358, 9360076, 9828080, + 10319484, 10835458, 11377231, 11946092, 12543397, 13170567, 13829095, + 14520550, 15246578, 16008907, 16809352, 17649820, 18532311, 19458926, + 20431872, 21453466, 22526139, 23652446, 24835069, 26076822, 27380663, + 28749697, 30187181, 31696540, 33281368, 34945436, 36692708, 38527343, + 40453710, 42476396, 44600216, 46830227, 49171738, 51630325, 54211841, + 56922433, 59768555, 62756983, 65894832, 69189573, 72649052, 76281505, + 80095580, 84100359, 88305377, 92720646, 97356678, 102224512, 107335738, +]; + describe('Crypto', () => { describe('encrypting and decrypting profile data', () => { const NAME_PADDED_LENGTH = 53; @@ -507,4 +538,53 @@ describe('Crypto', () => { assert.isUndefined(bytesToUuid(new Uint8Array(Array(17).fill(0x22)))); }); }); + + describe('getAttachmentSizeBucket', () => { + it('properly calculates first bucket', () => { + for (let size = 0, max = BUCKET_SIZES[0]; size < max; size += 1) { + assert.strictEqual(BUCKET_SIZES[0], getAttachmentSizeBucket(size)); + } + }); + + it('properly calculates entire table', () => { + let count = 0; + + const failures = new Array(); + for (let i = 0, max = BUCKET_SIZES.length - 1; i < max; i += 1) { + // Exact + if (BUCKET_SIZES[i] !== getAttachmentSizeBucket(BUCKET_SIZES[i])) { + count += 1; + failures.push( + `${BUCKET_SIZES[i]} does not equal ${getAttachmentSizeBucket( + BUCKET_SIZES[i] + )}` + ); + } + + // Just under + if (BUCKET_SIZES[i] !== getAttachmentSizeBucket(BUCKET_SIZES[i] - 1)) { + count += 1; + failures.push( + `${BUCKET_SIZES[i]} does not equal ${getAttachmentSizeBucket( + BUCKET_SIZES[i] - 1 + )}` + ); + } + + // Just over + if ( + BUCKET_SIZES[i + 1] !== getAttachmentSizeBucket(BUCKET_SIZES[i] + 1) + ) { + count += 1; + failures.push( + `${BUCKET_SIZES[i + 1]} does not equal ${getAttachmentSizeBucket( + BUCKET_SIZES[i] + 1 + )}` + ); + } + } + + assert.strictEqual(count, 0, failures.join('\n')); + }); + }); }); diff --git a/ts/test-electron/state/ducks/stories_test.ts b/ts/test-electron/state/ducks/stories_test.ts index 842cf8aac6c7..2641fd78b0be 100644 --- a/ts/test-electron/state/ducks/stories_test.ts +++ b/ts/test-electron/state/ducks/stories_test.ts @@ -918,6 +918,7 @@ describe('both/state/ducks/stories', () => { attachments: [ { contentType: IMAGE_JPEG, + digest: 'digest', size: 0, }, ], @@ -961,6 +962,7 @@ describe('both/state/ducks/stories', () => { url: 'https://signal.org', image: { contentType: IMAGE_JPEG, + digest: 'digest-1', size: 0, }, }; @@ -969,6 +971,7 @@ describe('both/state/ducks/stories', () => { attachments: [ { contentType: TEXT_ATTACHMENT, + digest: 'digest-2', size: 0, textAttachment: { preview, diff --git a/ts/test-electron/textsecure/SendMessage_test.ts b/ts/test-electron/textsecure/SendMessage_test.ts deleted file mode 100644 index 48f461491005..000000000000 --- a/ts/test-electron/textsecure/SendMessage_test.ts +++ /dev/null @@ -1,110 +0,0 @@ -// Copyright 2019 Signal Messenger, LLC -// SPDX-License-Identifier: AGPL-3.0-only - -import { assert } from 'chai'; - -import MessageSender from '../../textsecure/SendMessage'; -import type { WebAPIType } from '../../textsecure/WebAPI'; - -const BUCKET_SIZES = [ - 541, 568, 596, 626, 657, 690, 725, 761, 799, 839, 881, 925, 972, 1020, 1071, - 1125, 1181, 1240, 1302, 1367, 1436, 1507, 1583, 1662, 1745, 1832, 1924, 2020, - 2121, 2227, 2339, 2456, 2579, 2708, 2843, 2985, 3134, 3291, 3456, 3629, 3810, - 4001, 4201, 4411, 4631, 4863, 5106, 5361, 5629, 5911, 6207, 6517, 6843, 7185, - 7544, 7921, 8318, 8733, 9170, 9629, 10110, 10616, 11146, 11704, 12289, 12903, - 13549, 14226, 14937, 15684, 16469, 17292, 18157, 19065, 20018, 21019, 22070, - 23173, 24332, 25549, 26826, 28167, 29576, 31054, 32607, 34238, 35950, 37747, - 39634, 41616, 43697, 45882, 48176, 50585, 53114, 55770, 58558, 61486, 64561, - 67789, 71178, 74737, 78474, 82398, 86518, 90843, 95386, 100155, 105163, - 110421, 115942, 121739, 127826, 134217, 140928, 147975, 155373, 163142, - 171299, 179864, 188858, 198300, 208215, 218626, 229558, 241036, 253087, - 265742, 279029, 292980, 307629, 323011, 339161, 356119, 373925, 392622, - 412253, 432866, 454509, 477234, 501096, 526151, 552458, 580081, 609086, - 639540, 671517, 705093, 740347, 777365, 816233, 857045, 899897, 944892, - 992136, 1041743, 1093831, 1148522, 1205948, 1266246, 1329558, 1396036, - 1465838, 1539130, 1616086, 1696890, 1781735, 1870822, 1964363, 2062581, - 2165710, 2273996, 2387695, 2507080, 2632434, 2764056, 2902259, 3047372, - 3199740, 3359727, 3527714, 3704100, 3889305, 4083770, 4287958, 4502356, - 4727474, 4963848, 5212040, 5472642, 5746274, 6033588, 6335268, 6652031, - 6984633, 7333864, 7700558, 8085585, 8489865, 8914358, 9360076, 9828080, - 10319484, 10835458, 11377231, 11946092, 12543397, 13170567, 13829095, - 14520550, 15246578, 16008907, 16809352, 17649820, 18532311, 19458926, - 20431872, 21453466, 22526139, 23652446, 24835069, 26076822, 27380663, - 28749697, 30187181, 31696540, 33281368, 34945436, 36692708, 38527343, - 40453710, 42476396, 44600216, 46830227, 49171738, 51630325, 54211841, - 56922433, 59768555, 62756983, 65894832, 69189573, 72649052, 76281505, - 80095580, 84100359, 88305377, 92720646, 97356678, 102224512, 107335738, -]; - -describe('SendMessage', () => { - let sendMessage: MessageSender; - - before(() => { - sendMessage = new MessageSender({} as unknown as WebAPIType); - }); - - describe('#_getAttachmentSizeBucket', () => { - it('properly calculates first bucket', () => { - for (let size = 0, max = BUCKET_SIZES[0]; size < max; size += 1) { - assert.strictEqual( - BUCKET_SIZES[0], - sendMessage._getAttachmentSizeBucket(size) - ); - } - }); - - it('properly calculates entire table', () => { - let count = 0; - - const failures = new Array(); - for (let i = 0, max = BUCKET_SIZES.length - 1; i < max; i += 1) { - // Exact - if ( - BUCKET_SIZES[i] !== - sendMessage._getAttachmentSizeBucket(BUCKET_SIZES[i]) - ) { - count += 1; - failures.push( - `${ - BUCKET_SIZES[i] - } does not equal ${sendMessage._getAttachmentSizeBucket( - BUCKET_SIZES[i] - )}` - ); - } - - // Just under - if ( - BUCKET_SIZES[i] !== - sendMessage._getAttachmentSizeBucket(BUCKET_SIZES[i] - 1) - ) { - count += 1; - failures.push( - `${ - BUCKET_SIZES[i] - } does not equal ${sendMessage._getAttachmentSizeBucket( - BUCKET_SIZES[i] - 1 - )}` - ); - } - - // Just over - if ( - BUCKET_SIZES[i + 1] !== - sendMessage._getAttachmentSizeBucket(BUCKET_SIZES[i] + 1) - ) { - count += 1; - failures.push( - `${ - BUCKET_SIZES[i + 1] - } does not equal ${sendMessage._getAttachmentSizeBucket( - BUCKET_SIZES[i] + 1 - )}` - ); - } - } - - assert.strictEqual(count, 0, failures.join('\n')); - }); - }); -}); diff --git a/ts/textsecure/OutgoingMessage.ts b/ts/textsecure/OutgoingMessage.ts index af4ab33b4f35..a84b5dbd0288 100644 --- a/ts/textsecure/OutgoingMessage.ts +++ b/ts/textsecure/OutgoingMessage.ts @@ -204,15 +204,20 @@ export default class OutgoingMessage { const contentProto = this.getContentProtoBytes(); const { timestamp, contentHint, recipients, urgent } = this; let dataMessage: Uint8Array | undefined; + let editMessage: Uint8Array | undefined; let hasPniSignatureMessage = false; if (proto instanceof Proto.Content) { if (proto.dataMessage) { dataMessage = Proto.DataMessage.encode(proto.dataMessage).finish(); + } else if (proto.editMessage) { + editMessage = Proto.EditMessage.encode(proto.editMessage).finish(); } hasPniSignatureMessage = Boolean(proto.pniSignatureMessage); } else if (proto instanceof Proto.DataMessage) { dataMessage = Proto.DataMessage.encode(proto).finish(); + } else if (proto instanceof Proto.EditMessage) { + editMessage = Proto.EditMessage.encode(proto).finish(); } this.callback({ @@ -223,6 +228,7 @@ export default class OutgoingMessage { contentHint, dataMessage, + editMessage, recipients, contentProto, timestamp, diff --git a/ts/textsecure/SendMessage.ts b/ts/textsecure/SendMessage.ts index 9d2663130c68..01695b00ceea 100644 --- a/ts/textsecure/SendMessage.ts +++ b/ts/textsecure/SendMessage.ts @@ -13,7 +13,6 @@ import { SenderKeyDistributionMessage, } from '@signalapp/libsignal-client'; -import type { QuotedMessageType } from '../model-types.d'; import type { ConversationModel } from '../models/conversations'; import { GLOBAL_ZONE } from '../SignalProtocolStore'; import { assertDev, strictAssert } from '../util/assert'; @@ -21,9 +20,10 @@ import { parseIntOrThrow } from '../util/parseIntOrThrow'; import { Address } from '../types/Address'; import { QualifiedAddress } from '../types/QualifiedAddress'; import { SenderKeys } from '../LibSignalStores'; -import type { LinkPreviewType } from '../types/message/LinkPreviews'; -import { MIMETypeToString } from '../types/MIME'; -import type * as Attachment from '../types/Attachment'; +import type { + TextAttachmentType, + UploadedAttachmentType, +} from '../types/Attachment'; import type { UUID } from '../types/UUID'; import type { ChallengeType, @@ -49,7 +49,7 @@ import type { } from './OutgoingMessage'; import OutgoingMessage from './OutgoingMessage'; import * as Bytes from '../Bytes'; -import { getRandomBytes, getZeroes, encryptAttachment } from '../Crypto'; +import { getRandomBytes } from '../Crypto'; import { MessageError, SignedPreKeyRotationError, @@ -57,8 +57,8 @@ import { HTTPError, NoSenderKeyError, } from './Errors'; -import type { RawBodyRange } from '../types/BodyRange'; import { BodyRange } from '../types/BodyRange'; +import type { RawBodyRange } from '../types/BodyRange'; import type { StoryContextType } from '../types/Util'; import type { LinkPreviewImage, @@ -71,13 +71,12 @@ import { uuidToBytes } from '../util/uuidToBytes'; import type { DurationInSeconds } from '../util/durations'; import { SignalService as Proto } from '../protobuf'; import * as log from '../logging/log'; -import type { Avatar, EmbeddedContactType } from '../types/EmbeddedContact'; +import type { EmbeddedContactWithUploadedAvatar } from '../types/EmbeddedContact'; import { numberToPhoneType, numberToEmailType, numberToAddressType, } from '../types/EmbeddedContact'; -import type { StickerWithHydratedData } from '../types/Stickers'; import { missingCaseError } from '../util/missingCaseError'; export type SendMetadataType = { @@ -92,9 +91,33 @@ export type SendOptionsType = { online?: boolean; }; -type QuoteAttachmentType = { - thumbnail?: AttachmentType; - attachmentPointer?: Proto.IAttachmentPointer; +export type OutgoingQuoteAttachmentType = Readonly<{ + contentType: string; + fileName?: string; + thumbnail: UploadedAttachmentType; +}>; + +export type OutgoingQuoteType = Readonly<{ + isGiftBadge?: boolean; + id?: number; + authorUuid?: string; + text?: string; + attachments: ReadonlyArray; + bodyRanges?: ReadonlyArray; +}>; + +export type OutgoingLinkPreviewType = Readonly<{ + title?: string; + description?: string; + domain?: string; + url: string; + isStickerPack?: boolean; + image?: Readonly; + date?: number; +}>; + +export type OutgoingTextAttachmentType = Omit & { + preview?: OutgoingLinkPreviewType; }; export type GroupV2InfoType = { @@ -108,9 +131,13 @@ type GroupCallUpdateType = { eraId: string; }; -export type StickerType = StickerWithHydratedData & { - attachmentPointer?: Proto.IAttachmentPointer; -}; +export type OutgoingStickerType = Readonly<{ + packId: string; + packKey: string; + stickerId: number; + emoji?: string; + data: Readonly; +}>; export type ReactionType = { emoji?: string; @@ -119,22 +146,6 @@ export type ReactionType = { targetTimestamp?: number; }; -export type AttachmentType = { - size: number; - data: Uint8Array; - contentType: string; - - fileName?: string; - flags?: number; - width?: number; - height?: number; - caption?: string; - - attachmentPointer?: Proto.IAttachmentPointer; - - blurHash?: string; -}; - export const singleProtoJobDataSchema = z.object({ contentHint: z.number(), identifier: z.string(), @@ -147,35 +158,12 @@ export const singleProtoJobDataSchema = z.object({ export type SingleProtoJobData = z.infer; -function makeAttachmentSendReady( - attachment: Attachment.AttachmentType -): AttachmentType | undefined { - const { data } = attachment; - - if (!data) { - throw new Error( - 'makeAttachmentSendReady: Missing data, returning undefined' - ); - } - - return { - ...attachment, - contentType: MIMETypeToString(attachment.contentType), - data, - }; -} - -export type ContactWithHydratedAvatar = EmbeddedContactType & { - avatar?: Avatar & { - attachmentPointer?: Proto.IAttachmentPointer; - }; -}; - export type MessageOptionsType = { - attachments?: ReadonlyArray | null; + attachments?: ReadonlyArray; body?: string; bodyRanges?: ReadonlyArray; - contact?: Array; + contact?: ReadonlyArray; + editedMessageTimestamp?: number; expireTimer?: DurationInSeconds; flags?: number; group?: { @@ -184,11 +172,11 @@ export type MessageOptionsType = { }; groupV2?: GroupV2InfoType; needsSync?: boolean; - preview?: ReadonlyArray; + preview?: ReadonlyArray; profileKey?: Uint8Array; - quote?: QuotedMessageType | null; + quote?: OutgoingQuoteType; recipients: ReadonlyArray; - sticker?: StickerWithHydratedData; + sticker?: OutgoingStickerType; reaction?: ReactionType; deletedForEveryoneTimestamp?: number; timestamp: number; @@ -196,32 +184,33 @@ export type MessageOptionsType = { storyContext?: StoryContextType; }; export type GroupSendOptionsType = { - attachments?: Array; + attachments?: ReadonlyArray; bodyRanges?: ReadonlyArray; - contact?: Array; + contact?: ReadonlyArray; deletedForEveryoneTimestamp?: number; + editedMessageTimestamp?: number; expireTimer?: DurationInSeconds; flags?: number; groupCallUpdate?: GroupCallUpdateType; groupV2?: GroupV2InfoType; messageText?: string; - preview?: ReadonlyArray; + preview?: ReadonlyArray; profileKey?: Uint8Array; - quote?: QuotedMessageType | null; + quote?: OutgoingQuoteType; reaction?: ReactionType; - sticker?: StickerWithHydratedData; + sticker?: OutgoingStickerType; storyContext?: StoryContextType; timestamp: number; }; class Message { - attachments: ReadonlyArray; + attachments: ReadonlyArray; body?: string; bodyRanges?: ReadonlyArray; - contact?: Array; + contact?: ReadonlyArray; expireTimer?: DurationInSeconds; @@ -236,15 +225,15 @@ class Message { needsSync?: boolean; - preview?: ReadonlyArray; + preview?: ReadonlyArray; profileKey?: Uint8Array; - quote?: QuotedMessageType | null; + quote?: OutgoingQuoteType; recipients: ReadonlyArray; - sticker?: StickerType; + sticker?: OutgoingStickerType; reaction?: ReactionType; @@ -252,8 +241,6 @@ class Message { dataMessage?: Proto.DataMessage; - attachmentPointers: Array = []; - deletedForEveryoneTimestamp?: number; groupCallUpdate?: GroupCallUpdateType; @@ -346,7 +333,7 @@ class Message { const proto = new Proto.DataMessage(); proto.timestamp = Long.fromNumber(this.timestamp); - proto.attachments = this.attachmentPointers; + proto.attachments = this.attachments.slice(); if (this.body) { proto.body = this.body; @@ -383,10 +370,7 @@ class Message { proto.sticker.packKey = Bytes.fromBase64(this.sticker.packKey); proto.sticker.stickerId = this.sticker.stickerId; proto.sticker.emoji = this.sticker.emoji; - - if (this.sticker.attachmentPointer) { - proto.sticker.data = this.sticker.attachmentPointer; - } + proto.sticker.data = this.sticker.data; } if (this.reaction) { proto.reaction = new Proto.DataMessage.Reaction(); @@ -406,82 +390,83 @@ class Message { item.url = preview.url; item.description = preview.description || null; item.date = preview.date || null; - if (preview.attachmentPointer) { - item.image = preview.attachmentPointer; + if (preview.image) { + item.image = preview.image; } return item; }); } if (Array.isArray(this.contact)) { - proto.contact = this.contact.map(contact => { - const contactProto = new Proto.DataMessage.Contact(); - if (contact.name) { - const nameProto: Proto.DataMessage.Contact.IName = { - givenName: contact.name.givenName, - familyName: contact.name.familyName, - prefix: contact.name.prefix, - suffix: contact.name.suffix, - middleName: contact.name.middleName, - displayName: contact.name.displayName, - }; - contactProto.name = new Proto.DataMessage.Contact.Name(nameProto); - } - if (Array.isArray(contact.number)) { - contactProto.number = contact.number.map(number => { - const numberProto: Proto.DataMessage.Contact.IPhone = { - value: number.value, - type: numberToPhoneType(number.type), - label: number.label, + proto.contact = this.contact.map( + (contact: EmbeddedContactWithUploadedAvatar) => { + const contactProto = new Proto.DataMessage.Contact(); + if (contact.name) { + const nameProto: Proto.DataMessage.Contact.IName = { + givenName: contact.name.givenName, + familyName: contact.name.familyName, + prefix: contact.name.prefix, + suffix: contact.name.suffix, + middleName: contact.name.middleName, + displayName: contact.name.displayName, }; + contactProto.name = new Proto.DataMessage.Contact.Name(nameProto); + } + if (Array.isArray(contact.number)) { + contactProto.number = contact.number.map(number => { + const numberProto: Proto.DataMessage.Contact.IPhone = { + value: number.value, + type: numberToPhoneType(number.type), + label: number.label, + }; - return new Proto.DataMessage.Contact.Phone(numberProto); - }); - } - if (Array.isArray(contact.email)) { - contactProto.email = contact.email.map(email => { - const emailProto: Proto.DataMessage.Contact.IEmail = { - value: email.value, - type: numberToEmailType(email.type), - label: email.label, - }; + return new Proto.DataMessage.Contact.Phone(numberProto); + }); + } + if (Array.isArray(contact.email)) { + contactProto.email = contact.email.map(email => { + const emailProto: Proto.DataMessage.Contact.IEmail = { + value: email.value, + type: numberToEmailType(email.type), + label: email.label, + }; - return new Proto.DataMessage.Contact.Email(emailProto); - }); - } - if (Array.isArray(contact.address)) { - contactProto.address = contact.address.map(address => { - const addressProto: Proto.DataMessage.Contact.IPostalAddress = { - type: numberToAddressType(address.type), - label: address.label, - street: address.street, - pobox: address.pobox, - neighborhood: address.neighborhood, - city: address.city, - region: address.region, - postcode: address.postcode, - country: address.country, - }; + return new Proto.DataMessage.Contact.Email(emailProto); + }); + } + if (Array.isArray(contact.address)) { + contactProto.address = contact.address.map(address => { + const addressProto: Proto.DataMessage.Contact.IPostalAddress = { + type: numberToAddressType(address.type), + label: address.label, + street: address.street, + pobox: address.pobox, + neighborhood: address.neighborhood, + city: address.city, + region: address.region, + postcode: address.postcode, + country: address.country, + }; - return new Proto.DataMessage.Contact.PostalAddress(addressProto); - }); - } - if (contact.avatar && contact.avatar.attachmentPointer) { - const avatarProto = new Proto.DataMessage.Contact.Avatar(); - avatarProto.avatar = contact.avatar.attachmentPointer; - avatarProto.isProfile = Boolean(contact.avatar.isProfile); - contactProto.avatar = avatarProto; - } + return new Proto.DataMessage.Contact.PostalAddress(addressProto); + }); + } + if (contact.avatar?.avatar) { + const avatarProto = new Proto.DataMessage.Contact.Avatar(); + avatarProto.avatar = contact.avatar.avatar; + avatarProto.isProfile = Boolean(contact.avatar.isProfile); + contactProto.avatar = avatarProto; + } - if (contact.organization) { - contactProto.organization = contact.organization; - } + if (contact.organization) { + contactProto.organization = contact.organization; + } - return contactProto; - }); + return contactProto; + } + ); } if (this.quote) { - const { QuotedAttachment } = Proto.DataMessage.Quote; const { BodyRange: ProtoBodyRange, Quote } = Proto.DataMessage; proto.quote = new Quote(); @@ -497,21 +482,7 @@ class Message { this.quote.id === undefined ? null : Long.fromNumber(this.quote.id); quote.authorUuid = this.quote.authorUuid || null; quote.text = this.quote.text || null; - quote.attachments = (this.quote.attachments || []).map( - (attachment: AttachmentType) => { - const quotedAttachment = new QuotedAttachment(); - - quotedAttachment.contentType = attachment.contentType; - if (attachment.fileName) { - quotedAttachment.fileName = attachment.fileName; - } - if (attachment.attachmentPointer) { - quotedAttachment.thumbnail = attachment.attachmentPointer; - } - - return quotedAttachment; - } - ); + quote.attachments = this.quote.attachments.slice() || []; const bodyRanges = this.quote.bodyRanges || []; quote.bodyRanges = bodyRanges.map(range => { const bodyRange = new ProtoBodyRange(); @@ -665,13 +636,6 @@ export default class MessageSender { // Attachment upload functions - _getAttachmentSizeBucket(size: number): number { - return Math.max( - 541, - Math.floor(1.05 ** Math.ceil(Math.log(size) / Math.log(1.05))) - ); - } - static getRandomPadding(): Uint8Array { // Generate a random int from 1 and 512 const buffer = getRandomBytes(2); @@ -681,216 +645,11 @@ export default class MessageSender { return getRandomBytes(paddingLength); } - getPaddedAttachment(data: Readonly): Uint8Array { - const size = data.byteLength; - const paddedSize = this._getAttachmentSizeBucket(size); - const padding = getZeroes(paddedSize - size); - - return Bytes.concatenate([data, padding]); - } - - async makeAttachmentPointer( - attachment: Readonly< - Partial & - Pick - > - ): Promise { - assertDev( - typeof attachment === 'object' && attachment != null, - 'Got null attachment in `makeAttachmentPointer`' - ); - - const { data, size, contentType } = attachment; - if (!(data instanceof Uint8Array)) { - throw new Error( - `makeAttachmentPointer: data was a '${typeof data}' instead of Uint8Array` - ); - } - if (data.byteLength !== size) { - throw new Error( - `makeAttachmentPointer: Size ${size} did not match data.byteLength ${data.byteLength}` - ); - } - if (typeof contentType !== 'string') { - throw new Error( - `makeAttachmentPointer: contentType ${contentType} was not a string` - ); - } - - const padded = this.getPaddedAttachment(data); - const key = getRandomBytes(64); - - const result = encryptAttachment(padded, key); - const id = await this.server.putAttachment(result.ciphertext); - - const proto = new Proto.AttachmentPointer(); - proto.cdnId = Long.fromString(id); - proto.contentType = attachment.contentType; - proto.key = key; - proto.size = data.byteLength; - proto.digest = result.digest; - - if (attachment.fileName) { - proto.fileName = attachment.fileName; - } - if (attachment.flags) { - proto.flags = attachment.flags; - } - if (attachment.width) { - proto.width = attachment.width; - } - if (attachment.height) { - proto.height = attachment.height; - } - if (attachment.caption) { - proto.caption = attachment.caption; - } - if (attachment.blurHash) { - proto.blurHash = attachment.blurHash; - } - - return proto; - } - - async uploadAttachments(message: Message): Promise { - try { - // eslint-disable-next-line no-param-reassign - message.attachmentPointers = await Promise.all( - message.attachments.map(attachment => - this.makeAttachmentPointer(attachment) - ) - ); - } catch (error) { - if (error instanceof HTTPError) { - throw new MessageError(message, error); - } else { - throw error; - } - } - } - - async uploadLinkPreviews(message: Message): Promise { - try { - const preview = await Promise.all( - (message.preview || []).map(async (item: Readonly) => { - if (!item.image) { - return item; - } - const attachment = makeAttachmentSendReady(item.image); - if (!attachment) { - return item; - } - - return { - ...item, - attachmentPointer: await this.makeAttachmentPointer(attachment), - }; - }) - ); - // eslint-disable-next-line no-param-reassign - message.preview = preview; - } catch (error) { - if (error instanceof HTTPError) { - throw new MessageError(message, error); - } else { - throw error; - } - } - } - - async uploadSticker(message: Message): Promise { - try { - const { sticker } = message; - - if (!sticker) { - return; - } - if (!sticker.data) { - throw new Error('uploadSticker: No sticker data to upload!'); - } - - // eslint-disable-next-line no-param-reassign - message.sticker = { - ...sticker, - attachmentPointer: await this.makeAttachmentPointer(sticker.data), - }; - } catch (error) { - if (error instanceof HTTPError) { - throw new MessageError(message, error); - } else { - throw error; - } - } - } - - async uploadContactAvatar(message: Message): Promise { - const { contact } = message; - if (!contact || contact.length === 0) { - return; - } - - try { - await Promise.all( - contact.map(async (item: ContactWithHydratedAvatar) => { - const itemAvatar = item?.avatar; - const avatar = itemAvatar?.avatar; - - if (!itemAvatar || !avatar || !avatar.data) { - return; - } - - const attachment = makeAttachmentSendReady(avatar); - if (!attachment) { - return; - } - - itemAvatar.attachmentPointer = await this.makeAttachmentPointer( - attachment - ); - }) - ); - } catch (error) { - if (error instanceof HTTPError) { - throw new MessageError(message, error); - } else { - throw error; - } - } - } - - async uploadThumbnails(message: Message): Promise { - const { quote } = message; - if (!quote || !quote.attachments || quote.attachments.length === 0) { - return; - } - - try { - await Promise.all( - quote.attachments.map(async (attachment: QuoteAttachmentType) => { - if (!attachment.thumbnail) { - return; - } - - // eslint-disable-next-line no-param-reassign - attachment.attachmentPointer = await this.makeAttachmentPointer( - attachment.thumbnail - ); - }) - ); - } catch (error) { - if (error instanceof HTTPError) { - throw new MessageError(message, error); - } else { - throw error; - } - } - } - // Proto assembly - async getTextAttachmentProto( - attachmentAttrs: Attachment.TextAttachmentType - ): Promise { + getTextAttachmentProto( + attachmentAttrs: OutgoingTextAttachmentType + ): Proto.TextAttachment { const textAttachment = new Proto.TextAttachment(); if (attachmentAttrs.text) { @@ -910,15 +669,8 @@ export default class MessageSender { } if (attachmentAttrs.preview) { - const previewImage = attachmentAttrs.preview.image; - // This cast is OK because we're ensuring that previewImage.data is truthy - const image = - previewImage && previewImage.data - ? await this.makeAttachmentPointer(previewImage as AttachmentType) - : undefined; - textAttachment.preview = { - image, + image: attachmentAttrs.preview.image, title: attachmentAttrs.preview.title, url: attachmentAttrs.preview.url, }; @@ -950,20 +702,17 @@ export default class MessageSender { textAttachment, }: { allowsReplies?: boolean; - fileAttachment?: AttachmentType; + fileAttachment?: UploadedAttachmentType; groupV2?: GroupV2InfoType; profileKey: Uint8Array; - textAttachment?: Attachment.TextAttachmentType; + textAttachment?: OutgoingTextAttachmentType; }): Promise { const storyMessage = new Proto.StoryMessage(); storyMessage.profileKey = profileKey; if (fileAttachment) { try { - const attachmentPointer = await this.makeAttachmentPointer( - fileAttachment - ); - storyMessage.fileAttachment = attachmentPointer; + storyMessage.fileAttachment = fileAttachment; } catch (error) { if (error instanceof HTTPError) { throw new MessageError(message, error); @@ -974,9 +723,7 @@ export default class MessageSender { } if (textAttachment) { - storyMessage.textAttachment = await this.getTextAttachmentProto( - textAttachment - ); + storyMessage.textAttachment = this.getTextAttachmentProto(textAttachment); } if (groupV2) { @@ -1006,7 +753,16 @@ export default class MessageSender { const dataMessage = message.toProto(); const contentMessage = new Proto.Content(); - contentMessage.dataMessage = dataMessage; + if (options.editedMessageTimestamp) { + const editMessage = new Proto.EditMessage(); + editMessage.dataMessage = dataMessage; + editMessage.targetSentTimestamp = Long.fromNumber( + options.editedMessageTimestamp + ); + contentMessage.editMessage = editMessage; + } else { + contentMessage.dataMessage = dataMessage; + } const { includePniSignatureMessage } = options; if (includePniSignatureMessage) { @@ -1033,13 +789,6 @@ export default class MessageSender { attributes: Readonly ): Promise { const message = new Message(attributes); - await Promise.all([ - this.uploadAttachments(message), - this.uploadContactAvatar(message), - this.uploadThumbnails(message), - this.uploadLinkPreviews(message), - this.uploadSticker(message), - ]); return message; } @@ -1094,6 +843,7 @@ export default class MessageSender { bodyRanges, contact, deletedForEveryoneTimestamp, + editedMessageTimestamp, expireTimer, flags, groupCallUpdate, @@ -1144,6 +894,7 @@ export default class MessageSender { body: messageText, contact, deletedForEveryoneTimestamp, + editedMessageTimestamp, expireTimer, flags, groupCallUpdate, @@ -1353,6 +1104,7 @@ export default class MessageSender { contact, contentHint, deletedForEveryoneTimestamp, + editedMessageTimestamp, expireTimer, groupId, identifier, @@ -1369,21 +1121,22 @@ export default class MessageSender { urgent, includePniSignatureMessage, }: Readonly<{ - attachments: ReadonlyArray | undefined; + attachments: ReadonlyArray | undefined; bodyRanges?: ReadonlyArray; - contact?: Array; + contact?: ReadonlyArray; contentHint: number; deletedForEveryoneTimestamp: number | undefined; + editedMessageTimestamp?: number; expireTimer: DurationInSeconds | undefined; groupId: string | undefined; identifier: string; messageText: string | undefined; options?: SendOptionsType; - preview?: ReadonlyArray | undefined; + preview?: ReadonlyArray | undefined; profileKey?: Uint8Array; - quote?: QuotedMessageType | null; + quote?: OutgoingQuoteType; reaction?: ReactionType; - sticker?: StickerWithHydratedData; + sticker?: OutgoingStickerType; storyContext?: StoryContextType; story?: boolean; timestamp: number; @@ -1397,6 +1150,7 @@ export default class MessageSender { body: messageText, contact, deletedForEveryoneTimestamp, + editedMessageTimestamp, expireTimer, preview, profileKey, @@ -1421,6 +1175,7 @@ export default class MessageSender { // Note: this is used for sending real messages to your other devices after sending a // message to others. async sendSyncMessage({ + editedMessageTimestamp, encodedDataMessage, timestamp, destination, @@ -1434,6 +1189,7 @@ export default class MessageSender { storyMessage, storyMessageRecipients, }: Readonly<{ + editedMessageTimestamp?: number; encodedDataMessage?: Uint8Array; timestamp: number; destination: string | undefined; @@ -1452,7 +1208,13 @@ export default class MessageSender { const sentMessage = new Proto.SyncMessage.Sent(); sentMessage.timestamp = Long.fromNumber(timestamp); - if (encodedDataMessage) { + if (editedMessageTimestamp && encodedDataMessage) { + const dataMessage = Proto.DataMessage.decode(encodedDataMessage); + const editMessage = new Proto.EditMessage(); + editMessage.dataMessage = dataMessage; + editMessage.targetSentTimestamp = Long.fromNumber(editedMessageTimestamp); + sentMessage.editMessage = editMessage; + } else if (encodedDataMessage) { const dataMessage = Proto.DataMessage.decode(encodedDataMessage); sentMessage.message = dataMessage; } diff --git a/ts/textsecure/Types.d.ts b/ts/textsecure/Types.d.ts index c0ef65a6114f..a59f00efcec4 100644 --- a/ts/textsecure/Types.d.ts +++ b/ts/textsecure/Types.d.ts @@ -251,6 +251,7 @@ export type CallbackResultType = { errors?: Array; unidentifiedDeliveries?: Array; dataMessage?: Uint8Array; + editMessage?: Uint8Array; // If this send is not the final step in a multi-step send, we shouldn't treat its // results we would treat a one-step send. diff --git a/ts/textsecure/WebAPI.ts b/ts/textsecure/WebAPI.ts index ce673ca9d34c..277fdc31f9e0 100644 --- a/ts/textsecure/WebAPI.ts +++ b/ts/textsecure/WebAPI.ts @@ -948,7 +948,7 @@ export type WebAPIType = { postBatchIdentityCheck: ( elements: VerifyAciRequestType ) => Promise; - putAttachment: (encryptedBin: Uint8Array) => Promise; + putEncryptedAttachment: (encryptedBin: Uint8Array) => Promise; putProfile: ( jsonData: ProfileRequestDataType ) => Promise; @@ -1280,7 +1280,7 @@ export function initialize({ onOffline, onOnline, postBatchIdentityCheck, - putAttachment, + putEncryptedAttachment, putProfile, putStickers, reconnect, @@ -2507,7 +2507,7 @@ export function initialize({ attachmentIdString: string; }; - async function putAttachment(encryptedBin: Uint8Array) { + async function putEncryptedAttachment(encryptedBin: Uint8Array) { const response = (await _ajax({ call: 'attachmentId', httpType: 'GET', diff --git a/ts/types/Attachment.ts b/ts/types/Attachment.ts index 10560c1b8e9a..81e28419e194 100644 --- a/ts/types/Attachment.ts +++ b/ts/types/Attachment.ts @@ -27,7 +27,8 @@ 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'; +import { strictAssert } from '../util/assert'; +import type { SignalService as Proto } from '../protobuf'; const MAX_WIDTH = 300; const MAX_HEIGHT = MAX_WIDTH * 1.5; @@ -84,6 +85,16 @@ export type AttachmentType = { key?: string; }; +export type UploadedAttachmentType = Proto.IAttachmentPointer & + Readonly<{ + // Required fields + cdnId: Long; + key: Uint8Array; + size: number; + digest: Uint8Array; + contentType: string; + }>; + export type AttachmentWithHydratedData = AttachmentType & { data: Uint8Array; }; @@ -1006,6 +1017,6 @@ export const canBeDownloaded = ( }; export function getAttachmentSignature(attachment: AttachmentType): string { - softAssert(attachment.digest, 'attachment missing digest'); - return attachment.digest || String(attachment.blurHash); + strictAssert(attachment.digest, 'attachment missing digest'); + return attachment.digest; } diff --git a/ts/types/EmbeddedContact.ts b/ts/types/EmbeddedContact.ts index 375f167d98d3..c5c9ed5804ea 100644 --- a/ts/types/EmbeddedContact.ts +++ b/ts/types/EmbeddedContact.ts @@ -11,17 +11,22 @@ import { format as formatPhoneNumber, parse as parsePhoneNumber, } from './PhoneNumber'; -import type { AttachmentType, migrateDataToFileSystem } from './Attachment'; +import type { + AttachmentType, + AttachmentWithHydratedData, + UploadedAttachmentType, + migrateDataToFileSystem, +} from './Attachment'; import { toLogFormat } from './errors'; import type { LoggerType } from './Logging'; import type { UUIDStringType } from './UUID'; -export type EmbeddedContactType = { +type GenericEmbeddedContactType = { name?: Name; number?: Array; email?: Array; address?: Array; - avatar?: Avatar; + avatar?: AvatarType; organization?: string; // Populated by selector @@ -29,6 +34,12 @@ export type EmbeddedContactType = { uuid?: UUIDStringType; }; +export type EmbeddedContactType = GenericEmbeddedContactType; +export type EmbeddedContactWithHydratedAvatar = + GenericEmbeddedContactType; +export type EmbeddedContactWithUploadedAvatar = + GenericEmbeddedContactType; + type Name = { givenName?: string; familyName?: string; @@ -75,11 +86,15 @@ export type PostalAddress = { country?: string; }; -export type Avatar = { - avatar: AttachmentType; +type GenericAvatar = { + avatar: Attachment; isProfile: boolean; }; +export type Avatar = GenericAvatar; +export type AvatarWithHydratedData = GenericAvatar; +export type UploadedAvatar = GenericAvatar; + const DEFAULT_PHONE_TYPE = Proto.DataMessage.Contact.Phone.Type.HOME; const DEFAULT_EMAIL_TYPE = Proto.DataMessage.Contact.Email.Type.HOME; const DEFAULT_ADDRESS_TYPE = Proto.DataMessage.Contact.PostalAddress.Type.HOME; diff --git a/ts/types/ErrorWithToast.ts b/ts/types/ErrorWithToast.ts new file mode 100644 index 000000000000..f3cd20931dca --- /dev/null +++ b/ts/types/ErrorWithToast.ts @@ -0,0 +1,13 @@ +// Copyright 2023 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +import type { ToastType } from './Toast'; + +export class ErrorWithToast extends Error { + public toastType: ToastType; + + constructor(message: string, toastType: ToastType) { + super(message); + this.toastType = toastType; + } +} diff --git a/ts/types/LinkPreview.ts b/ts/types/LinkPreview.ts index 44e4357c01e9..ed52602c5ed6 100644 --- a/ts/types/LinkPreview.ts +++ b/ts/types/LinkPreview.ts @@ -8,11 +8,9 @@ import LinkifyIt from 'linkify-it'; import { maybeParseUrl } from '../util/url'; import { replaceEmojiWithSpaces } from '../util/emoji'; -import type { AttachmentType } from './Attachment'; +import type { AttachmentWithHydratedData } from './Attachment'; -export type LinkPreviewImage = AttachmentType & { - data: Uint8Array; -}; +export type LinkPreviewImage = AttachmentWithHydratedData; export type LinkPreviewResult = { title: string | null; diff --git a/ts/types/Message2.ts b/ts/types/Message2.ts index 93838790a452..34cc76dbdbda 100644 --- a/ts/types/Message2.ts +++ b/ts/types/Message2.ts @@ -20,13 +20,19 @@ import { initializeAttachmentMetadata } from './message/initializeAttachmentMeta import type * as MIME from './MIME'; import type { LoggerType } from './Logging'; -import type { EmbeddedContactType } from './EmbeddedContact'; +import type { + EmbeddedContactType, + EmbeddedContactWithHydratedAvatar, +} from './EmbeddedContact'; import type { MessageAttributesType, QuotedMessageType, } from '../model-types.d'; -import type { LinkPreviewType } from './message/LinkPreviews'; +import type { + LinkPreviewType, + LinkPreviewWithHydratedData, +} from './message/LinkPreviews'; import type { StickerType, StickerWithHydratedData } from './Stickers'; export { hasExpiration } from './Message'; @@ -714,28 +720,33 @@ export const loadContactData = ( loadAttachmentData: LoadAttachmentType ): (( contact: Array | undefined -) => Promise | undefined>) => { +) => Promise | undefined>) => { if (!isFunction(loadAttachmentData)) { throw new TypeError('loadContactData: loadAttachmentData is required'); } return async ( contact: Array | undefined - ): Promise | undefined> => { + ): Promise | undefined> => { if (!contact) { return undefined; } return Promise.all( contact.map( - async (item: EmbeddedContactType): Promise => { + async ( + item: EmbeddedContactType + ): Promise => { if ( !item || !item.avatar || !item.avatar.avatar || !item.avatar.avatar.path ) { - return item; + return { + ...item, + avatar: undefined, + }; } return { @@ -758,7 +769,7 @@ export const loadPreviewData = ( loadAttachmentData: LoadAttachmentType ): (( preview: Array | undefined -) => Promise>) => { +) => Promise>) => { if (!isFunction(loadAttachmentData)) { throw new TypeError('loadPreviewData: loadAttachmentData is required'); } @@ -769,16 +780,22 @@ export const loadPreviewData = ( } return Promise.all( - preview.map(async item => { - if (!item.image) { - return item; - } + preview.map( + async (item: LinkPreviewType): Promise => { + if (!item.image) { + return { + ...item, + // Pacify typescript + image: undefined, + }; + } - return { - ...item, - image: await loadAttachmentData(item.image), - }; - }) + return { + ...item, + image: await loadAttachmentData(item.image), + }; + } + ) ); }; }; diff --git a/ts/types/Toast.tsx b/ts/types/Toast.tsx index 20abde4e9cd0..8b604f42119f 100644 --- a/ts/types/Toast.tsx +++ b/ts/types/Toast.tsx @@ -7,6 +7,7 @@ export enum ToastType { AlreadyRequestedToJoin = 'AlreadyRequestedToJoin', Blocked = 'Blocked', BlockedGroup = 'BlockedGroup', + CannotEditMessage = 'CannotEditMessage', CannotForwardEmptyMessage = 'CannotForwardEmptyMessage', CannotMixMultiAndNonMultiAttachments = 'CannotMixMultiAndNonMultiAttachments', CannotOpenGiftBadgeIncoming = 'CannotOpenGiftBadgeIncoming', @@ -54,6 +55,7 @@ export type AnyToast = | { toastType: ToastType.AlreadyRequestedToJoin } | { toastType: ToastType.Blocked } | { toastType: ToastType.BlockedGroup } + | { toastType: ToastType.CannotEditMessage } | { toastType: ToastType.CannotForwardEmptyMessage } | { toastType: ToastType.CannotMixMultiAndNonMultiAttachments } | { toastType: ToastType.CannotOpenGiftBadgeIncoming } diff --git a/ts/types/message/LinkPreviews.ts b/ts/types/message/LinkPreviews.ts index ba163b20da64..0ffdedbf4949 100644 --- a/ts/types/message/LinkPreviews.ts +++ b/ts/types/message/LinkPreviews.ts @@ -1,14 +1,18 @@ // Copyright 2021 Signal Messenger, LLC // SPDX-License-Identifier: AGPL-3.0-only -import type { AttachmentType } from '../Attachment'; +import type { AttachmentType, AttachmentWithHydratedData } from '../Attachment'; -export type LinkPreviewType = { +type GenericLinkPreviewType = { title?: string; description?: string; domain?: string; url: string; isStickerPack?: boolean; - image?: Readonly; + image?: Readonly; date?: number; }; + +export type LinkPreviewType = GenericLinkPreviewType; +export type LinkPreviewWithHydratedData = + GenericLinkPreviewType; diff --git a/ts/util/canEditMessages.ts b/ts/util/canEditMessages.ts new file mode 100644 index 000000000000..19a4276edcc5 --- /dev/null +++ b/ts/util/canEditMessages.ts @@ -0,0 +1,8 @@ +// Copyright 2023 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +import { isEnabled } from '../RemoteConfig'; + +export function canEditMessages(): boolean { + return isEnabled('desktop.editMessageSend'); +} diff --git a/ts/util/copyDataMessageIntoMessage.ts b/ts/util/copyDataMessageIntoMessage.ts new file mode 100644 index 000000000000..5ddf2253a924 --- /dev/null +++ b/ts/util/copyDataMessageIntoMessage.ts @@ -0,0 +1,19 @@ +// Copyright 2023 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +import type { MessageAttributesType } from '../model-types.d'; +import type { ProcessedDataMessage } from '../textsecure/Types.d'; + +export function copyDataMessageIntoMessage( + dataMessage: ProcessedDataMessage, + message: MessageAttributesType +): MessageAttributesType { + return { + ...message, + ...dataMessage, + // TODO: DESKTOP-5278 + // There are type conflicts between MessageAttributesType and the protos + // that are passed in here. + // eslint-disable-next-line @typescript-eslint/no-explicit-any + } as any as MessageAttributesType; +} diff --git a/ts/util/handleEditMessage.ts b/ts/util/handleEditMessage.ts index f0e0060bd0df..5cf47a06aa4e 100644 --- a/ts/util/handleEditMessage.ts +++ b/ts/util/handleEditMessage.ts @@ -3,17 +3,17 @@ import type { AttachmentType } from '../types/Attachment'; import type { EditAttributesType } from '../messageModifiers/Edits'; -import type { EditHistoryType, MessageAttributesType } from '../model-types.d'; +import type { + EditHistoryType, + MessageAttributesType, + QuotedMessageType, +} 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 { getAttachmentSignature, isVoiceMessage } from '../types/Attachment'; import { getMessageIdForLogging } from './idForLogging'; import { hasErrors } from '../state/selectors/message'; import { isIncoming, isOutgoing } from '../messages/helpers'; @@ -56,7 +56,7 @@ export async function handleEditMessage( // Pull out the edit history from the main message. If this is the first edit // then the original message becomes the first item in the edit history. - const editHistory: Array = mainMessage.editHistory || [ + let editHistory: Array = mainMessage.editHistory || [ { attachments: mainMessage.attachments, body: mainMessage.body, @@ -76,46 +76,59 @@ export async function handleEditMessage( 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 - ); + await window.Signal.Migrations.upgradeMessageSchema(editAttributes.message); // Copies over the attachments from the main message if they're the same // and they have already been downloaded. const attachmentSignatures: Map = new Map(); const previewSignatures: Map = new Map(); + const quoteSignatures: Map = new Map(); mainMessage.attachments?.forEach(attachment => { - if (!isDownloaded(attachment)) { - return; - } const signature = getAttachmentSignature(attachment); - attachmentSignatures.set(signature, attachment); + if (signature) { + attachmentSignatures.set(signature, attachment); + } }); mainMessage.preview?.forEach(preview => { - if (!preview.image || !isDownloaded(preview.image)) { + if (!preview.image) { return; } const signature = getAttachmentSignature(preview.image); - previewSignatures.set(signature, preview); + if (signature) { + previewSignatures.set(signature, preview); + } }); + if (mainMessage.quote) { + for (const attachment of mainMessage.quote.attachments) { + if (!attachment.thumbnail) { + continue; + } + const signature = getAttachmentSignature(attachment.thumbnail); + if (signature) { + quoteSignatures.set(signature, attachment); + } + } + } + let newAttachments = 0; const nextEditedMessageAttachments = upgradedEditedMessageData.attachments?.map(attachment => { const signature = getAttachmentSignature(attachment); - const existingAttachment = attachmentSignatures.get(signature); + const existingAttachment = signature + ? attachmentSignatures.get(signature) + : undefined; - return existingAttachment || attachment; + if (existingAttachment) { + return existingAttachment; + } + + newAttachments += 1; + return attachment; }); + let newPreviews = 0; const nextEditedMessagePreview = upgradedEditedMessageData.preview?.map( preview => { if (!preview.image) { @@ -123,22 +136,69 @@ export async function handleEditMessage( } const signature = getAttachmentSignature(preview.image); - const existingPreview = previewSignatures.get(signature); - return existingPreview || preview; + const existingPreview = signature + ? previewSignatures.get(signature) + : undefined; + if (existingPreview) { + return existingPreview; + } + newPreviews += 1; + return preview; } ); + let newQuoteThumbnails = 0; + + const { quote: upgradedQuote } = upgradedEditedMessageData; + let nextEditedMessageQuote: QuotedMessageType | undefined; + if (!upgradedQuote) { + // Quote dropped + log.info(`${idLog}: dropping quote`); + } else if (!upgradedQuote.id || upgradedQuote.id === mainMessage.quote?.id) { + // Quote preserved + nextEditedMessageQuote = mainMessage.quote; + } else { + // Quote updated! + nextEditedMessageQuote = { + ...upgradedQuote, + attachments: upgradedQuote.attachments.map(attachment => { + if (!attachment.thumbnail) { + return attachment; + } + const signature = getAttachmentSignature(attachment.thumbnail); + const existingThumbnail = signature + ? quoteSignatures.get(signature) + : undefined; + if (existingThumbnail) { + return { + ...attachment, + thumbnail: existingThumbnail, + }; + } + + newQuoteThumbnails += 1; + return attachment; + }), + }; + } + + log.info( + `${idLog}: editing message, added ${newAttachments} attachments, ` + + `${newPreviews} previews, ${newQuoteThumbnails} quote thumbnails` + ); + const editedMessage: EditHistoryType = { attachments: nextEditedMessageAttachments, body: upgradedEditedMessageData.body, bodyRanges: upgradedEditedMessageData.bodyRanges, preview: nextEditedMessagePreview, timestamp: upgradedEditedMessageData.timestamp, + quote: nextEditedMessageQuote, }; // The edit history works like a queue where the newest edits are at the top. // Here we unshift the latest edit onto the edit history. - editHistory.unshift(editedMessage); + editHistory = [editedMessage, ...editHistory]; // Update all the editable attributes on the main message also updating the // edit history. @@ -149,6 +209,7 @@ export async function handleEditMessage( editHistory, editMessageTimestamp: upgradedEditedMessageData.timestamp, preview: editedMessage.preview, + quote: editedMessage.quote, }); // Queue up any downloads in case they're different, update the fields if so. diff --git a/ts/util/makeQuote.ts b/ts/util/makeQuote.ts index 7a1d261fd54c..a50a8fea7789 100644 --- a/ts/util/makeQuote.ts +++ b/ts/util/makeQuote.ts @@ -59,8 +59,8 @@ export async function getQuoteAttachment( ): Promise< Array<{ contentType: MIMEType; - fileName: string | null; - thumbnail: ThumbnailType | null; + fileName?: string | null; + thumbnail?: ThumbnailType | null; }> > { const { getAbsoluteAttachmentPath, loadAttachmentData } = diff --git a/ts/util/maybeForwardMessages.ts b/ts/util/maybeForwardMessages.ts index 3b7736b013cf..b95c7f281d9f 100644 --- a/ts/util/maybeForwardMessages.ts +++ b/ts/util/maybeForwardMessages.ts @@ -4,7 +4,10 @@ import { orderBy } from 'lodash'; import type { AttachmentType } from '../types/Attachment'; import { isVoiceMessage } from '../types/Attachment'; -import type { LinkPreviewType } from '../types/message/LinkPreviews'; +import type { + LinkPreviewType, + LinkPreviewWithHydratedData, +} from '../types/message/LinkPreviews'; import type { MessageAttributesType, QuotedMessageType } from '../model-types'; import * as log from '../logging/log'; import { SafetyNumberChangeSource } from '../components/SafetyNumberChangeDialog'; @@ -16,7 +19,7 @@ import { import { isNotNil } from './isNotNil'; import { resetLinkPreview } from '../services/LinkPreview'; import { getRecipientsByConversation } from './getRecipientsByConversation'; -import type { ContactWithHydratedAvatar } from '../textsecure/SendMessage'; +import type { EmbeddedContactWithHydratedAvatar } from '../types/EmbeddedContact'; import type { DraftBodyRanges, HydratedBodyRangesType, @@ -177,8 +180,8 @@ export async function maybeForwardMessages( attachments: Array; body: string | undefined; bodyRanges?: DraftBodyRanges; - contact?: Array; - preview?: Array; + contact?: Array; + preview?: Array; quote?: QuotedMessageType; sticker?: StickerWithHydratedData; }; diff --git a/ts/util/queueAttachmentDownloads.ts b/ts/util/queueAttachmentDownloads.ts index e1370e46c030..c5ad01db54af 100644 --- a/ts/util/queueAttachmentDownloads.ts +++ b/ts/util/queueAttachmentDownloads.ts @@ -28,6 +28,7 @@ import { } from '../types/Attachment'; import type { StickerType } from '../types/Stickers'; import type { LinkPreviewType } from '../types/message/LinkPreviews'; +import { isNotNil } from './isNotNil'; type ReturnType = { bodyAttachment?: AttachmentType; @@ -111,6 +112,18 @@ export async function queueAttachmentDownloads( ); count += previewCount; + log.info( + `${idLog}: Queueing ${message.quote?.attachments?.length ?? 0} ` + + 'quote attachment downloads' + ); + const { quote, count: thumbnailCount } = await queueQuoteAttachments( + idLog, + messageId, + message.quote, + message.editHistory?.map(x => x.quote).filter(isNotNil) ?? [] + ); + count += thumbnailCount; + const contactsToQueue = message.contact || []; log.info( `${idLog}: Queueing ${contactsToQueue.length} contact attachment downloads` @@ -141,40 +154,6 @@ export async function queueAttachmentDownloads( }) ); - let { quote } = message; - const quoteAttachmentsToQueue = - quote && quote.attachments ? quote.attachments : []; - log.info( - `${idLog}: Queueing ${quoteAttachmentsToQueue.length} quote attachment downloads` - ); - if (quote && quoteAttachmentsToQueue.length > 0) { - quote = { - ...quote, - attachments: await Promise.all( - (quote?.attachments || []).map(async (item, index) => { - if (!item.thumbnail) { - return item; - } - // We've already downloaded this! - if (item.thumbnail.path) { - log.info(`${idLog}: Quote attachment already downloaded`); - return item; - } - - count += 1; - return { - ...item, - thumbnail: await AttachmentDownloads.addJob(item.thumbnail, { - messageId, - type: 'quote', - index, - }), - }; - }) - ), - }; - } - let { sticker } = message; if (sticker && sticker.data && sticker.data.path) { log.info(`${idLog}: Sticker attachment already downloaded`); @@ -226,11 +205,6 @@ export async function queueAttachmentDownloads( 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, @@ -239,15 +213,22 @@ export async function queueAttachmentDownloads( attachments ); count += editAttachmentsCount; + if (editAttachmentsCount !== 0) { + log.info( + `${idLog}: Queueing ${editAttachmentsCount} normal attachment ` + + `downloads (edited:${edit.timestamp})` + ); + } - 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; + if (editPreviewCount !== 0) { + log.info( + `${idLog}: Queueing ${editPreviewCount} preview attachment ` + + `downloads (edited:${edit.timestamp})` + ); + } return { ...edit, @@ -293,7 +274,9 @@ async function queueNormalAttachments( const attachmentSignatures: Map = new Map(); otherAttachments?.forEach(attachment => { const signature = getAttachmentSignature(attachment); - attachmentSignatures.set(signature, attachment); + if (signature) { + attachmentSignatures.set(signature, attachment); + } }); let count = 0; @@ -415,3 +398,98 @@ async function queuePreviews( count, }; } + +function getQuoteThumbnailSignature( + quote: QuotedMessageType, + thumbnail?: AttachmentType +): string | undefined { + if (!thumbnail) { + return undefined; + } + return `<${quote.id}>${getAttachmentSignature(thumbnail)}`; +} + +async function queueQuoteAttachments( + idLog: string, + messageId: string, + quote: QuotedMessageType | undefined, + otherQuotes: ReadonlyArray +): Promise<{ quote?: QuotedMessageType; count: number }> { + let count = 0; + if (!quote) { + return { quote, count }; + } + + const quoteAttachmentsToQueue = + quote && quote.attachments ? quote.attachments : []; + if (quoteAttachmentsToQueue.length === 0) { + return { quote, count }; + } + + // Similar to queueNormalAttachments' logic for detecting same attachments + // except here we also pick by quote sent timestamp. + const thumbnailSignatures: Map = new Map(); + otherQuotes.forEach(otherQuote => { + for (const attachment of otherQuote.attachments) { + const signature = getQuoteThumbnailSignature( + otherQuote, + attachment.thumbnail + ); + if (!signature) { + continue; + } + thumbnailSignatures.set(signature, attachment); + } + }); + + return { + quote: { + ...quote, + attachments: await Promise.all( + quote.attachments.map(async (item, index) => { + if (!item.thumbnail) { + return item; + } + // We've already downloaded this! + if (isDownloaded(item.thumbnail)) { + log.info(`${idLog}: Quote attachment already downloaded`); + return item; + } + + const signature = getQuoteThumbnailSignature(quote, item.thumbnail); + const existingThumbnail = signature + ? thumbnailSignatures.get(signature) + : undefined; + + // We've already downloaded this elsewhere! + if ( + existingThumbnail && + (isDownloading(existingThumbnail) || + isDownloaded(existingThumbnail)) + ) { + log.info( + `${idLog}: Preview already downloaded elsewhere. Replacing` + ); + // Incrementing count so that we update the message's fields downstream + count += 1; + return { + ...item, + thumbnail: existingThumbnail, + }; + } + + count += 1; + return { + ...item, + thumbnail: await AttachmentDownloads.addJob(item.thumbnail, { + messageId, + type: 'quote', + index, + }), + }; + }) + ), + }, + count, + }; +} diff --git a/ts/util/sendEditedMessage.ts b/ts/util/sendEditedMessage.ts new file mode 100644 index 000000000000..c143216c9e13 --- /dev/null +++ b/ts/util/sendEditedMessage.ts @@ -0,0 +1,243 @@ +// Copyright 2023 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +import type { DraftBodyRanges } from '../types/BodyRange'; +import type { LinkPreviewType } from '../types/message/LinkPreviews'; +import type { + MessageAttributesType, + QuotedMessageType, +} from '../model-types.d'; +import * as log from '../logging/log'; +import type { AttachmentType } from '../types/Attachment'; +import { ErrorWithToast } from '../types/ErrorWithToast'; +import { SendStatus } from '../messages/MessageSendState'; +import { ToastType } from '../types/Toast'; +import { UUID } from '../types/UUID'; +import { canEditMessage } from '../state/selectors/message'; +import { + conversationJobQueue, + conversationQueueJobEnum, +} from '../jobs/conversationJobQueue'; +import { concat, filter, map, repeat, zipObject, find } from './iterables'; +import { getConversationIdForLogging } from './idForLogging'; +import { isQuoteAMatch } from '../messages/helpers'; +import { getMessageById } from '../messages/getMessageById'; +import { handleEditMessage } from './handleEditMessage'; +import { incrementMessageCounter } from './incrementMessageCounter'; +import { isGroupV1 } from './whatTypeOfConversation'; +import { isNotNil } from './isNotNil'; +import { isSignalConversation } from './isSignalConversation'; +import { strictAssert } from './assert'; +import { timeAndLogIfTooLong } from './timeAndLogIfTooLong'; +import { makeQuote } from './makeQuote'; + +const SEND_REPORT_THRESHOLD_MS = 25; + +export async function sendEditedMessage( + conversationId: string, + { + body, + bodyRanges, + preview, + quoteSentAt, + quoteAuthorUuid, + targetMessageId, + }: { + body?: string; + bodyRanges?: DraftBodyRanges; + preview: Array; + quoteSentAt?: number; + quoteAuthorUuid?: string; + targetMessageId: string; + } +): Promise { + const { messaging } = window.textsecure; + strictAssert(messaging, 'messaging not available'); + + const conversation = window.ConversationController.get(conversationId); + strictAssert(conversation, 'no conversation found'); + + const idLog = `sendEditedMessage(${getConversationIdForLogging( + conversation.attributes + )})`; + + const targetMessage = await getMessageById(targetMessageId); + strictAssert(targetMessage, 'could not find message to edit'); + + if (isGroupV1(conversation.attributes)) { + log.warn(`${idLog}: can't send to gv1`); + return; + } + + if (isSignalConversation(conversation.attributes)) { + log.warn(`${idLog}: can't send to Signal`); + return; + } + + if (!canEditMessage(targetMessage.attributes)) { + throw new ErrorWithToast( + `${idLog}: cannot edit`, + ToastType.CannotEditMessage + ); + } + + const timestamp = Date.now(); + + log.info(`${idLog}: sending ${timestamp}`); + + conversation.clearTypingTimers(); + + const ourConversation = + window.ConversationController.getOurConversationOrThrow(); + const fromId = ourConversation.id; + + const recipientMaybeConversations = map( + conversation.getRecipients({ + isStoryReply: false, + }), + identifier => window.ConversationController.get(identifier) + ); + const recipientConversations = filter(recipientMaybeConversations, isNotNil); + const recipientConversationIds = concat( + map(recipientConversations, c => c.id), + [fromId] + ); + const sendStateByConversationId = zipObject( + recipientConversationIds, + repeat({ + status: SendStatus.Pending, + updatedAt: timestamp, + }) + ); + + // Resetting send state for the target message + targetMessage.set({ sendStateByConversationId }); + + // Can't send both preview and attachments + const attachments = + preview && preview.length ? [] : targetMessage.get('attachments') || []; + + const fixNewAttachment = ( + attachment: AttachmentType, + temporaryDigest: string + ): AttachmentType => { + // Check if this is an existing attachment or a new attachment coming + // from composer + if (attachment.digest) { + return attachment; + } + + // Generated semi-unique digest so that `handleEditMessage` understand + // it is a new attachment + return { + ...attachment, + digest: `${temporaryDigest}:${attachment.path}`, + }; + }; + + let quote: QuotedMessageType | undefined; + if (quoteSentAt !== undefined && quoteAuthorUuid !== undefined) { + const existingQuote = targetMessage.get('quote'); + + // Keep the quote if unchanged. + if (quoteSentAt === existingQuote?.id) { + quote = existingQuote; + } else { + const messages = await window.Signal.Data.getMessagesBySentAt( + quoteSentAt + ); + const matchingMessage = find(messages, item => + isQuoteAMatch(item, conversationId, { + id: quoteSentAt, + authorUuid: quoteAuthorUuid, + }) + ); + + if (matchingMessage) { + quote = await makeQuote(matchingMessage); + } + } + } + + // An ephemeral message that we just use to handle the edit + const tmpMessage: MessageAttributesType = { + attachments: attachments?.map((attachment, index) => + fixNewAttachment(attachment, `attachment:${index}`) + ), + body, + bodyRanges, + conversationId, + preview: preview?.map((entry, index) => { + const image = + entry.image && fixNewAttachment(entry.image, `preview:${index}`); + if (entry.image === image) { + return entry; + } + return { + ...entry, + image, + }; + }), + id: UUID.generate().toString(), + quote, + received_at: incrementMessageCounter(), + received_at_ms: timestamp, + sent_at: timestamp, + timestamp, + type: 'outgoing', + }; + + // Building up the dependencies for handling the edit message + const editAttributes = { + conversationId, + fromId, + message: tmpMessage, + targetSentTimestamp: targetMessage.attributes.timestamp, + }; + + // Takes care of putting the message in the edit history, replacing the + // main message's values, and updating the conversation's properties. + await handleEditMessage(targetMessage.attributes, editAttributes); + + // Inserting the send into a job and saving it to the message + await timeAndLogIfTooLong( + SEND_REPORT_THRESHOLD_MS, + () => + conversationJobQueue.add( + { + type: conversationQueueJobEnum.enum.NormalMessage, + conversationId, + messageId: targetMessageId, + revision: conversation.get('revision'), + }, + async jobToInsert => { + log.info( + `${idLog}: saving message ${targetMessageId} and job ${jobToInsert.id}` + ); + await window.Signal.Data.saveMessage(targetMessage.attributes, { + jobToInsert, + ourUuid: window.textsecure.storage.user.getCheckedUuid().toString(), + }); + } + ), + duration => `${idLog}: db save took ${duration}ms` + ); + + // Does the same render dance that models/conversations does when we call + // enqueueMessageForSend. Calls redux actions, clears drafts, unarchives, and + // updates storage service if needed. + await timeAndLogIfTooLong( + SEND_REPORT_THRESHOLD_MS, + async () => { + conversation.beforeMessageSend({ + message: targetMessage, + dontClearDraft: false, + dontAddMessage: true, + now: timestamp, + }); + }, + duration => `${idLog}: batchDisptach took ${duration}ms` + ); + + window.Signal.Data.updateConversation(conversation.attributes); +} diff --git a/ts/util/sendStoryMessage.ts b/ts/util/sendStoryMessage.ts index 5168a510fc41..2a784e3205f3 100644 --- a/ts/util/sendStoryMessage.ts +++ b/ts/util/sendStoryMessage.ts @@ -142,8 +142,9 @@ export async function sendStoryMessage( const attachments: Array = [attachment]; const linkPreview = attachment?.textAttachment?.preview; + const { loadPreviewData } = window.Signal.Migrations; const sanitizedLinkPreview = linkPreview - ? sanitizeLinkPreview(linkPreview) + ? sanitizeLinkPreview((await loadPreviewData([linkPreview]))[0]) : undefined; // If a text attachment has a link preview we remove it from the // textAttachment data structure and instead process the preview and add diff --git a/ts/util/timeAndLogIfTooLong.ts b/ts/util/timeAndLogIfTooLong.ts new file mode 100644 index 000000000000..51cd83b2f8c4 --- /dev/null +++ b/ts/util/timeAndLogIfTooLong.ts @@ -0,0 +1,20 @@ +// Copyright 2023 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +import * as log from '../logging/log'; + +export async function timeAndLogIfTooLong( + threshold: number, + func: () => Promise, + getLogLine: (duration: number) => string +): Promise { + const start = Date.now(); + try { + await func(); + } finally { + const duration = Date.now() - start; + if (duration > threshold) { + log.info(getLogLine(duration)); + } + } +} diff --git a/ts/util/uploadAttachment.ts b/ts/util/uploadAttachment.ts new file mode 100644 index 000000000000..b6b15fd7f697 --- /dev/null +++ b/ts/util/uploadAttachment.ts @@ -0,0 +1,41 @@ +// Copyright 2023 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +import Long from 'long'; + +import type { + AttachmentWithHydratedData, + UploadedAttachmentType, +} from '../types/Attachment'; +import { MIMETypeToString } from '../types/MIME'; +import { padAndEncryptAttachment, getRandomBytes } from '../Crypto'; +import { strictAssert } from './assert'; + +export async function uploadAttachment( + attachment: AttachmentWithHydratedData +): Promise { + const keys = getRandomBytes(64); + const encrypted = padAndEncryptAttachment(attachment.data, keys); + + const { server } = window.textsecure; + strictAssert(server, 'WebAPI must be initialized'); + + const attachmentIdString = await server.putEncryptedAttachment( + encrypted.ciphertext + ); + + return { + cdnId: Long.fromString(attachmentIdString), + key: keys, + size: attachment.data.byteLength, + digest: encrypted.digest, + + contentType: MIMETypeToString(attachment.contentType), + fileName: attachment.fileName, + flags: attachment.flags, + width: attachment.width, + height: attachment.height, + caption: attachment.caption, + blurHash: attachment.blurHash, + }; +}