Send edited messages support

Co-authored-by: Fedor Indutnyy <indutny@signal.org>
This commit is contained in:
Josh Perez 2023-04-20 12:31:59 -04:00 committed by GitHub
parent d380817a44
commit 1f2cde6d04
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
79 changed files with 2507 additions and 1175 deletions

View file

@ -2161,6 +2161,9 @@
"icu:accept": { "icu:accept": {
"messageformat": "Accept" "messageformat": "Accept"
}, },
"icu:edit": {
"messageformat": "Edit"
},
"forward": { "forward": {
"message": "Forward", "message": "Forward",
"description": "(deleted 03/29/2023)" "description": "(deleted 03/29/2023)"
@ -3538,6 +3541,10 @@
"messageformat": "Delete failed", "messageformat": "Delete failed",
"description": "Shown on a message which was deleted for everyone if the delete wasn't successfully sent to anyone" "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": { "sendPaused": {
"message": "Send paused", "message": "Send paused",
"description": "(deleted 03/29/2023) Shown on outgoing message if it cannot be sent immediately" "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.", "messageformat": "Failed to fetch phone number. Check your connection and try again.",
"description": "Shown if request to Signal servers to find phone number fails" "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": { "startConversation--username-not-found": {
"message": "User not found. $atUsername$ is not a Signal user; make sure youve entered the complete username.", "message": "User not found. $atUsername$ is not a Signal user; make sure youve 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" "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", "messageformat": "Checking contact's registration status",
"description": "Displayed while checking if the contact is SMS-only" "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": { "countMutedConversationsDescription": {
"message": "Include muted conversations in badge count", "message": "Include muted conversations in badge count",
"description": "(deleted 03/29/2023) Description for counting muted conversations in badge setting" "description": "(deleted 03/29/2023) Description for counting muted conversations in badge setting"
@ -12363,6 +12386,14 @@
"messageformat": "Edit history", "messageformat": "Edit history",
"description": "Modal title for the edit history messages modal" "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": { "WhatsNew__modal-title": {
"message": "What's New", "message": "What's New",
"description": "(deleted 03/29/2023) Title for the whats new modal" "description": "(deleted 03/29/2023) Title for the whats new modal"

View file

@ -0,0 +1,3 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M18.9717 5.263C19.3787 5.5235 19.4975 6.06463 19.237 6.47166L10.917 19.4717C10.7641 19.7106 10.5048 19.8606 10.2215 19.874C9.9381 19.8874 9.66581 19.7627 9.49097 19.5393L4.81097 13.5593C4.51314 13.1787 4.58021 12.6287 4.96077 12.3309C5.34133 12.0331 5.89127 12.1002 6.18911 12.4807L10.1084 17.4887L17.7631 5.52831C18.0235 5.12129 18.5647 5.0025 18.9717 5.263Z" fill="black"/>
</svg>

After

Width:  |  Height:  |  Size: 528 B

3
images/icons/v3/edit.svg Normal file
View file

@ -0,0 +1,3 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M20.3686 3.63127C19.0604 2.32306 16.9393 2.32307 15.6311 3.63127L4.38113 14.8813C4.29072 14.9717 4.22122 15.0808 4.17753 15.201L2.54368 19.694C2.14467 20.7913 3.2085 21.8552 4.30579 21.4562L8.79887 19.8223C8.91903 19.7786 9.02816 19.7091 9.11857 19.6187L20.3686 8.36871C21.6768 7.0605 21.6768 4.93948 20.3686 3.63127ZM16.8686 4.86871C17.4934 4.24392 18.5063 4.24392 19.1311 4.86871C19.7559 5.4935 19.7559 6.50648 19.1311 7.13127L17.9998 8.26259L15.7373 6.00002L16.8686 4.86871ZM14.4998 7.23746L5.75583 15.9814L4.46293 19.5369L8.01839 18.244L16.7624 9.50002L14.4998 7.23746Z" fill="black"/>
</svg>

After

Width:  |  Height:  |  Size: 742 B

3
images/icons/v3/x.svg Normal file
View file

@ -0,0 +1,3 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M18.6187 6.61872C18.9604 6.27701 18.9604 5.72299 18.6187 5.38128C18.277 5.03957 17.723 5.03957 17.3813 5.38128L12 10.7626L6.61872 5.38128C6.27701 5.03957 5.72299 5.03957 5.38128 5.38128C5.03957 5.72299 5.03957 6.27701 5.38128 6.61872L10.7626 12L5.38128 17.3813C5.03957 17.723 5.03957 18.277 5.38128 18.6187C5.72299 18.9604 6.27701 18.9604 6.61872 18.6187L12 13.2374L17.3813 18.6187C17.723 18.9604 18.277 18.9604 18.6187 18.6187C18.9604 18.277 18.9604 17.723 18.6187 17.3813L13.2374 12L18.6187 6.61872Z" fill="black"/>
</svg>

After

Width:  |  Height:  |  Size: 630 B

View file

@ -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 { &__delete-message::before {
@include light-theme { @include light-theme {
@include color-svg( @include color-svg(

View file

@ -49,6 +49,37 @@
margin-right: 12px; 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 { &__send-button {
display: flex; display: flex;
justify-content: center; justify-content: center;
@ -69,6 +100,7 @@
&__input { &__input {
flex-grow: 1; flex-grow: 1;
margin: 0 6px; margin: 0 6px;
position: relative;
&--large { &--large {
margin: 0; margin: 0;

View file

@ -4,7 +4,7 @@
.module-composition-input { .module-composition-input {
&__quill { &__quill {
height: 100%; height: 100%;
padding-left: 6px; padding-left: 12px;
.ql-editor { .ql-editor {
caret-color: transparent; caret-color: transparent;
@ -81,7 +81,7 @@
&__scroller { &__scroller {
$padding-top: 6px; $padding-top: 6px;
padding: $padding-top; padding: $padding-top 0;
min-height: calc(32px - 2 * $border-size); min-height: calc(32px - 2 * $border-size);
max-height: calc(72px - 2 * $border-size); max-height: calc(72px - 2 * $border-size);
@ -333,6 +333,35 @@
stroke: $color-white; 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 { div.CompositionInput__link-preview {

View file

@ -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;
}
}
}
}

View file

@ -75,6 +75,7 @@
@import './components/DisappearingTimeDialog.scss'; @import './components/DisappearingTimeDialog.scss';
@import './components/DisappearingTimerSelect.scss'; @import './components/DisappearingTimerSelect.scss';
@import './components/EditConversationAttributesModal.scss'; @import './components/EditConversationAttributesModal.scss';
@import './components/EditHistoryMessagesModal.scss';
@import './components/EditUsernameModalBody.scss'; @import './components/EditUsernameModalBody.scss';
@import './components/ForwardMessageModal.scss'; @import './components/ForwardMessageModal.scss';
@import './components/GradientDial.scss'; @import './components/GradientDial.scss';

View file

@ -454,8 +454,8 @@ export function decryptAttachment(
} }
export function encryptAttachment( export function encryptAttachment(
plaintext: Uint8Array, plaintext: Readonly<Uint8Array>,
keys: Uint8Array keys: Readonly<Uint8Array>
): EncryptedAttachment { ): EncryptedAttachment {
if (!(plaintext instanceof Uint8Array)) { if (!(plaintext instanceof Uint8Array)) {
throw new TypeError( 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<Uint8Array>,
keys: Readonly<Uint8Array>
): 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 { export function encryptProfile(data: Uint8Array, key: Uint8Array): Uint8Array {
const iv = getRandomBytes(PROFILE_IV_LENGTH); const iv = getRandomBytes(PROFILE_IV_LENGTH);
if (key.byteLength !== PROFILE_KEY_LENGTH) { if (key.byteLength !== PROFILE_KEY_LENGTH) {

View file

@ -17,6 +17,7 @@ export type ConfigKeyType =
| 'desktop.calling.audioLevelForSpeaking' | 'desktop.calling.audioLevelForSpeaking'
| 'desktop.cdsi.returnAcisWithoutUaks' | 'desktop.cdsi.returnAcisWithoutUaks'
| 'desktop.clientExpiration' | 'desktop.clientExpiration'
| 'desktop.editMessageSend'
| 'desktop.contactManagement.beta' | 'desktop.contactManagement.beta'
| 'desktop.contactManagement' | 'desktop.contactManagement'
| 'desktop.groupCallOutboundRing2.beta' | 'desktop.groupCallOutboundRing2.beta'

View file

@ -176,6 +176,7 @@ import { showConfirmationDialog } from './util/showConfirmationDialog';
import { onCallEventSync } from './util/onCallEventSync'; import { onCallEventSync } from './util/onCallEventSync';
import { sleeper } from './util/sleeper'; import { sleeper } from './util/sleeper';
import { MINUTE } from './util/durations'; import { MINUTE } from './util/durations';
import { copyDataMessageIntoMessage } from './util/copyDataMessageIntoMessage';
import { import {
flushMessageCounter, flushMessageCounter,
incrementMessageCounter, incrementMessageCounter,
@ -3123,9 +3124,9 @@ export async function startApp(): Promise<void> {
}); });
const editAttributes: EditAttributesType = { const editAttributes: EditAttributesType = {
dataMessage: data.message, conversationId: message.attributes.conversationId,
fromId: fromConversation.id, fromId: fromConversation.id,
message: message.attributes, message: copyDataMessageIntoMessage(data.message, message.attributes),
targetSentTimestamp: editedMessageTimestamp, targetSentTimestamp: editedMessageTimestamp,
}; };
@ -3446,9 +3447,9 @@ export async function startApp(): Promise<void> {
}); });
const editAttributes: EditAttributesType = { const editAttributes: EditAttributesType = {
dataMessage: data.message, conversationId: message.attributes.conversationId,
fromId: window.ConversationController.getOurConversationIdOrThrow(), fromId: window.ConversationController.getOurConversationIdOrThrow(),
message: message.attributes, message: copyDataMessageIntoMessage(data.message, message.attributes),
targetSentTimestamp: editedMessageTimestamp, targetSentTimestamp: editedMessageTimestamp,
}; };

View file

@ -34,6 +34,7 @@ export default {
const useProps = (overrideProps: Partial<Props> = {}): Props => ({ const useProps = (overrideProps: Partial<Props> = {}): Props => ({
addAttachment: action('addAttachment'), addAttachment: action('addAttachment'),
conversationId: '123', conversationId: '123',
discardEditMessage: action('discardEditMessage'),
focusCounter: 0, focusCounter: 0,
sendCounter: 0, sendCounter: 0,
i18n, i18n,
@ -47,6 +48,7 @@ const useProps = (overrideProps: Partial<Props> = {}): Props => ({
? overrideProps.isFormattingEnabled ? overrideProps.isFormattingEnabled
: true, : true,
messageCompositionId: '456', messageCompositionId: '456',
sendEditedMessage: action('sendEditedMessage'),
sendMultiMediaMessage: action('sendMultiMediaMessage'), sendMultiMediaMessage: action('sendMultiMediaMessage'),
processAttachments: action('processAttachments'), processAttachments: action('processAttachments'),
removeAttachment: action('removeAttachment'), removeAttachment: action('removeAttachment'),

View file

@ -4,6 +4,8 @@
import React, { useCallback, useEffect, useRef, useState } from 'react'; import React, { useCallback, useEffect, useRef, useState } from 'react';
import { get } from 'lodash'; import { get } from 'lodash';
import classNames from 'classnames'; import classNames from 'classnames';
import type { ReadonlyDeep } from 'type-fest';
import type { DraftBodyRanges } from '../types/BodyRange'; import type { DraftBodyRanges } from '../types/BodyRange';
import type { LocalizerType, ThemeType } from '../types/Util'; import type { LocalizerType, ThemeType } from '../types/Util';
import type { ErrorDialogAudioRecorderType } from '../types/AudioRecorder'; import type { ErrorDialogAudioRecorderType } from '../types/AudioRecorder';
@ -64,6 +66,7 @@ import { useEscapeHandling } from '../hooks/useEscapeHandling';
import type { SmartCompositionRecordingProps } from '../state/smart/CompositionRecording'; import type { SmartCompositionRecordingProps } from '../state/smart/CompositionRecording';
import SelectModeActions from './conversation/SelectModeActions'; import SelectModeActions from './conversation/SelectModeActions';
import type { ShowToastAction } from '../state/ducks/toast'; import type { ShowToastAction } from '../state/ducks/toast';
import type { DraftEditMessageType } from '../model-types.d';
export type OwnProps = Readonly<{ export type OwnProps = Readonly<{
acceptedMessageRequest?: boolean; acceptedMessageRequest?: boolean;
@ -82,6 +85,8 @@ export type OwnProps = Readonly<{
onRecordingComplete: (rec: InMemoryAttachmentDraftType) => unknown onRecordingComplete: (rec: InMemoryAttachmentDraftType) => unknown
) => unknown; ) => unknown;
conversationId: string; conversationId: string;
discardEditMessage: (id: string) => unknown;
draftEditMessage?: DraftEditMessageType;
uuid?: string; uuid?: string;
draftAttachments: ReadonlyArray<AttachmentDraftType>; draftAttachments: ReadonlyArray<AttachmentDraftType>;
errorDialogAudioRecorderType?: ErrorDialogAudioRecorderType; errorDialogAudioRecorderType?: ErrorDialogAudioRecorderType;
@ -117,6 +122,16 @@ export type OwnProps = Readonly<{
id: string, id: string,
opts: { packId: string; stickerId: number } opts: { packId: string; stickerId: number }
): unknown; ): unknown;
sendEditedMessage(
conversationId: string,
options: {
bodyRanges?: DraftBodyRanges;
message?: string;
quoteAuthorUuid?: string;
quoteSentAt?: number;
targetMessageId: string;
}
): unknown;
sendMultiMediaMessage( sendMultiMediaMessage(
conversationId: string, conversationId: string,
options: { options: {
@ -128,10 +143,15 @@ export type OwnProps = Readonly<{
} }
): unknown; ): unknown;
quotedMessageId?: string; quotedMessageId?: string;
quotedMessageProps?: Omit< quotedMessageProps?: ReadonlyDeep<
QuoteProps, Omit<
'i18n' | 'onClick' | 'onClose' | 'withContentAbove' QuoteProps,
'i18n' | 'onClick' | 'onClose' | 'withContentAbove' | 'isCompose'
>
>; >;
quotedMessageAuthorUuid?: string;
quotedMessageSentAt?: number;
removeAttachment: (conversationId: string, filePath: string) => unknown; removeAttachment: (conversationId: string, filePath: string) => unknown;
scrollToMessage: (conversationId: string, messageId: string) => unknown; scrollToMessage: (conversationId: string, messageId: string) => unknown;
setComposerFocus: (conversationId: string) => unknown; setComposerFocus: (conversationId: string) => unknown;
@ -196,6 +216,8 @@ export function CompositionArea({
// Base props // Base props
addAttachment, addAttachment,
conversationId, conversationId,
discardEditMessage,
draftEditMessage,
focusCounter, focusCounter,
i18n, i18n,
imageToBlurHash, imageToBlurHash,
@ -206,6 +228,7 @@ export function CompositionArea({
pushPanelForConversation, pushPanelForConversation,
processAttachments, processAttachments,
removeAttachment, removeAttachment,
sendEditedMessage,
sendMultiMediaMessage, sendMultiMediaMessage,
setComposerFocus, setComposerFocus,
setQuoteByMessageId, setQuoteByMessageId,
@ -224,6 +247,8 @@ export function CompositionArea({
// Quote // Quote
quotedMessageId, quotedMessageId,
quotedMessageProps, quotedMessageProps,
quotedMessageAuthorUuid,
quotedMessageSentAt,
scrollToMessage, scrollToMessage,
// MediaQualitySelector // MediaQualitySelector
setMediaQualitySetting, setMediaQualitySetting,
@ -308,18 +333,42 @@ export function CompositionArea({
} }
}, [inputApiRef, setLarge]); }, [inputApiRef, setLarge]);
const draftEditMessageBody = draftEditMessage?.body;
const editedMessageId = draftEditMessage?.targetMessageId;
const handleSubmit = useCallback( const handleSubmit = useCallback(
(message: string, bodyRanges: DraftBodyRanges, timestamp: number) => { (message: string, bodyRanges: DraftBodyRanges, timestamp: number) => {
emojiButtonRef.current?.close(); emojiButtonRef.current?.close();
sendMultiMediaMessage(conversationId, {
draftAttachments, if (editedMessageId) {
bodyRanges, sendEditedMessage(conversationId, {
message, bodyRanges,
timestamp, message,
}); // sent timestamp for the quote
quoteSentAt: quotedMessageSentAt,
quoteAuthorUuid: quotedMessageAuthorUuid,
targetMessageId: editedMessageId,
});
} else {
sendMultiMediaMessage(conversationId, {
draftAttachments,
bodyRanges,
message,
timestamp,
});
}
setLarge(false); setLarge(false);
}, },
[conversationId, draftAttachments, sendMultiMediaMessage, setLarge] [
conversationId,
draftAttachments,
editedMessageId,
quotedMessageSentAt,
quotedMessageAuthorUuid,
sendEditedMessage,
sendMultiMediaMessage,
setLarge,
]
); );
const launchAttachmentPicker = useCallback(() => { const launchAttachmentPicker = useCallback(() => {
@ -414,11 +463,35 @@ export function CompositionArea({
inputApiRef.current?.setContents(draftText, draftBodyRanges, true); inputApiRef.current?.setContents(draftText, draftBodyRanges, true);
}, [conversationId, draftBodyRanges, draftText, previousConversationId]); }, [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(() => { const handleToggleLarge = useCallback(() => {
setLarge(l => !l); setLarge(l => !l);
}, [setLarge]); }, [setLarge]);
const shouldShowMicrophone = !large && !draftAttachments.length && !draftText; const shouldShowMicrophone =
!large && !draftAttachments.length && !draftText && !draftEditMessage;
const showMediaQualitySelector = draftAttachments.some(isImageAttachment); const showMediaQualitySelector = draftAttachments.some(isImageAttachment);
@ -460,9 +533,29 @@ export function CompositionArea({
</div> </div>
) : null; ) : null;
const editMessageFragment = draftEditMessage ? (
<>
{large && <div className="CompositionArea__placeholder" />}
<div className="CompositionArea__button-cell">
<button
aria-label={i18n('icu:CompositionArea__edit-action--discard')}
className="CompositionArea__edit-button CompositionArea__edit-button--discard"
onClick={() => discardEditMessage(conversationId)}
type="button"
/>
<button
aria-label={i18n('icu:CompositionArea__edit-action--send')}
className="CompositionArea__edit-button CompositionArea__edit-button--accept"
onClick={() => inputApiRef.current?.submit()}
type="button"
/>
</div>
</>
) : null;
const isRecording = recordingState === RecordingState.Recording; const isRecording = recordingState === RecordingState.Recording;
const attButton = const attButton =
linkPreviewResult || isRecording ? undefined : ( draftEditMessage || linkPreviewResult || isRecording ? undefined : (
<div className="CompositionArea__button-cell"> <div className="CompositionArea__button-cell">
<button <button
type="button" type="button"
@ -473,7 +566,7 @@ export function CompositionArea({
</div> </div>
); );
const sendButtonFragment = ( const sendButtonFragment = !draftEditMessage ? (
<> <>
<div className="CompositionArea__placeholder" /> <div className="CompositionArea__placeholder" />
<div className="CompositionArea__button-cell"> <div className="CompositionArea__button-cell">
@ -485,35 +578,36 @@ export function CompositionArea({
/> />
</div> </div>
</> </>
); ) : null;
const stickerButtonPlacement = large ? 'top-start' : 'top-end'; const stickerButtonPlacement = large ? 'top-start' : 'top-end';
const stickerButtonFragment = withStickers ? ( const stickerButtonFragment =
<div className="CompositionArea__button-cell"> !draftEditMessage && withStickers ? (
<StickerButton <div className="CompositionArea__button-cell">
i18n={i18n} <StickerButton
knownPacks={knownPacks} i18n={i18n}
receivedPacks={receivedPacks} knownPacks={knownPacks}
installedPack={installedPack} receivedPacks={receivedPacks}
installedPacks={installedPacks} installedPack={installedPack}
blessedPacks={blessedPacks} installedPacks={installedPacks}
recentStickers={recentStickers} blessedPacks={blessedPacks}
clearInstalledStickerPack={clearInstalledStickerPack} recentStickers={recentStickers}
onClickAddPack={() => clearInstalledStickerPack={clearInstalledStickerPack}
pushPanelForConversation({ onClickAddPack={() =>
type: PanelType.StickerManager, pushPanelForConversation({
}) type: PanelType.StickerManager,
} })
onPickSticker={(packId, stickerId) => }
sendStickerMessage(conversationId, { packId, stickerId }) onPickSticker={(packId, stickerId) =>
} sendStickerMessage(conversationId, { packId, stickerId })
clearShowIntroduction={clearShowIntroduction} }
showPickerHint={showPickerHint} clearShowIntroduction={clearShowIntroduction}
clearShowPickerHint={clearShowPickerHint} showPickerHint={showPickerHint}
position={stickerButtonPlacement} clearShowPickerHint={clearShowPickerHint}
/> position={stickerButtonPlacement}
</div> />
) : null; </div>
) : null;
// Listen for cmd/ctrl-shift-x to toggle large composition mode // Listen for cmd/ctrl-shift-x to toggle large composition mode
useEffect(() => { useEffect(() => {
@ -548,7 +642,16 @@ export function CompositionArea({
if (quotedMessageId) { if (quotedMessageId) {
setQuoteByMessageId(conversationId, undefined); setQuoteByMessageId(conversationId, undefined);
} }
}, [conversationId, quotedMessageId, setQuoteByMessageId]); if (draftEditMessage) {
discardEditMessage(conversationId);
}
}, [
conversationId,
discardEditMessage,
draftEditMessage,
quotedMessageId,
setQuoteByMessageId,
]);
useEscapeHandling(clearQuote); useEscapeHandling(clearQuote);
@ -752,13 +855,17 @@ export function CompositionArea({
'CompositionArea__row--column' 'CompositionArea__row--column'
)} )}
> >
{quotedMessageId && quotedMessageProps && ( {quotedMessageProps && (
<div className="quote-wrapper"> <div className="quote-wrapper">
<Quote <Quote
isCompose isCompose
{...quotedMessageProps} {...quotedMessageProps}
i18n={i18n} i18n={i18n}
onClick={() => scrollToMessage(conversationId, quotedMessageId)} onClick={
quotedMessageId
? () => scrollToMessage(conversationId, quotedMessageId)
: undefined
}
onClose={() => { onClose={() => {
setQuoteByMessageId(conversationId, undefined); setQuoteByMessageId(conversationId, undefined);
}} }}
@ -801,6 +908,7 @@ export function CompositionArea({
conversationId={conversationId} conversationId={conversationId}
disabled={isDisabled} disabled={isDisabled}
draftBodyRanges={draftBodyRanges} draftBodyRanges={draftBodyRanges}
draftEditMessage={draftEditMessage}
draftText={draftText} draftText={draftText}
getPreferredBadge={getPreferredBadge} getPreferredBadge={getPreferredBadge}
getQuotedMessage={getQuotedMessage} getQuotedMessage={getQuotedMessage}
@ -827,6 +935,7 @@ export function CompositionArea({
<> <>
{stickerButtonFragment} {stickerButtonFragment}
{!dirty ? micButtonFragment : null} {!dirty ? micButtonFragment : null}
{editMessageFragment}
{attButton} {attButton}
</> </>
) : null} ) : null}
@ -842,6 +951,7 @@ export function CompositionArea({
{stickerButtonFragment} {stickerButtonFragment}
{attButton} {attButton}
{!dirty ? micButtonFragment : null} {!dirty ? micButtonFragment : null}
{editMessageFragment}
{dirty || !shouldShowMicrophone ? sendButtonFragment : null} {dirty || !shouldShowMicrophone ? sendButtonFragment : null}
</div> </div>
) : null} ) : null}

View file

@ -37,6 +37,7 @@ const useProps = (overrideProps: Partial<Props> = {}): Props => ({
? overrideProps.isFormattingEnabled ? overrideProps.isFormattingEnabled
: true, : true,
large: boolean('large', overrideProps.large || false), large: boolean('large', overrideProps.large || false),
onCloseLinkPreview: action('onCloseLinkPreview'),
onEditorStateChange: action('onEditorStateChange'), onEditorStateChange: action('onEditorStateChange'),
onPickEmoji: action('onPickEmoji'), onPickEmoji: action('onPickEmoji'),
onSubmit: action('onSubmit'), onSubmit: action('onSubmit'),

View file

@ -51,6 +51,7 @@ import * as log from '../logging/log';
import { useRefMerger } from '../hooks/useRefMerger'; import { useRefMerger } from '../hooks/useRefMerger';
import type { LinkPreviewType } from '../types/message/LinkPreviews'; import type { LinkPreviewType } from '../types/message/LinkPreviews';
import { StagedLinkPreview } from './conversation/StagedLinkPreview'; import { StagedLinkPreview } from './conversation/StagedLinkPreview';
import type { DraftEditMessageType } from '../model-types.d';
import { usePrevious } from '../hooks/usePrevious'; import { usePrevious } from '../hooks/usePrevious';
Quill.register('formats/emoji', EmojiBlot); Quill.register('formats/emoji', EmojiBlot);
@ -85,6 +86,7 @@ export type Props = Readonly<{
conversationId?: string; conversationId?: string;
i18n: LocalizerType; i18n: LocalizerType;
disabled?: boolean; disabled?: boolean;
draftEditMessage?: DraftEditMessageType;
getPreferredBadge: PreferredBadgeSelectorType; getPreferredBadge: PreferredBadgeSelectorType;
large?: boolean; large?: boolean;
inputApi?: React.MutableRefObject<InputApi | undefined>; inputApi?: React.MutableRefObject<InputApi | undefined>;
@ -132,6 +134,7 @@ export function CompositionInput(props: Props): React.ReactElement {
conversationId, conversationId,
disabled, disabled,
draftBodyRanges, draftBodyRanges,
draftEditMessage,
draftText, draftText,
getPreferredBadge, getPreferredBadge,
getQuotedMessage, getQuotedMessage,
@ -782,6 +785,21 @@ export function CompositionInput(props: Props): React.ReactElement {
data-testid="CompositionInput" data-testid="CompositionInput"
data-enabled={disabled ? 'false' : 'true'} data-enabled={disabled ? 'false' : 'true'}
> >
{draftEditMessage && (
<div className={getClassName('__editing-message')}>
{i18n('icu:CompositionInput__editing-message')}
</div>
)}
{draftEditMessage?.attachmentThumbnail && (
<div className={getClassName('__editing-message__attachment')}>
<img
alt={i18n('icu:stagedImageAttachment', {
path: draftEditMessage.attachmentThumbnail,
})}
src={draftEditMessage.attachmentThumbnail}
/>
</div>
)}
{conversationId && linkPreviewLoading && linkPreviewResult && ( {conversationId && linkPreviewLoading && linkPreviewResult && (
<StagedLinkPreview <StagedLinkPreview
{...linkPreviewResult} {...linkPreviewResult}

View file

@ -47,6 +47,7 @@ const MESSAGE_DEFAULT_PROPS = {
openGiftBadge: shouldNeverBeCalled, openGiftBadge: shouldNeverBeCalled,
openLink: shouldNeverBeCalled, openLink: shouldNeverBeCalled,
previews: [], previews: [],
retryMessageSend: shouldNeverBeCalled,
pushPanelForConversation: shouldNeverBeCalled, pushPanelForConversation: shouldNeverBeCalled,
renderAudioAttachment: () => <div />, renderAudioAttachment: () => <div />,
renderingContext: 'EditHistoryMessagesModal', renderingContext: 'EditHistoryMessagesModal',
@ -99,8 +100,10 @@ export function EditHistoryMessagesModal({
hasXButton hasXButton
i18n={i18n} i18n={i18n}
modalName="EditHistoryMessagesModal" modalName="EditHistoryMessagesModal"
moduleClassName="EditHistoryMessagesModal"
onClose={closeEditHistoryModal} onClose={closeEditHistoryModal}
title={i18n('icu:EditHistoryMessagesModal__title')} title={i18n('icu:EditHistoryMessagesModal__title')}
noTransform
> >
<div ref={containerElementRef}> <div ref={containerElementRef}>
{editHistoryMessages.map(messageAttributes => { {editHistoryMessages.map(messageAttributes => {

View file

@ -36,6 +36,7 @@ type PropsType = {
}; };
export type ModalPropsType = PropsType & { export type ModalPropsType = PropsType & {
noTransform?: boolean;
noMouseClose?: boolean; noMouseClose?: boolean;
theme?: Theme; theme?: Theme;
}; };
@ -57,15 +58,31 @@ export function Modal({
useFocusTrap, useFocusTrap,
hasHeaderDivider = false, hasHeaderDivider = false,
hasFooterDivider = false, hasFooterDivider = false,
noTransform = false,
padded = true, padded = true,
}: Readonly<ModalPropsType>): JSX.Element | null { }: Readonly<ModalPropsType>): JSX.Element | null {
const { close, isClosed, modalStyles, overlayStyles } = useAnimated(onClose, { const { close, isClosed, modalStyles, overlayStyles } = useAnimated(
getFrom: () => ({ opacity: 0, transform: 'translateY(48px)' }), onClose,
getTo: isOpen =>
isOpen // `background-position: fixed` cannot properly detect the viewport when
? { opacity: 1, transform: 'translateY(0px)' } // the parent element has `transform: translate*`. Even though it requires
: { opacity: 0, transform: 'translateY(48px)' }, // layout recalculation - use `margin-top` if asked by the embedder.
}); noTransform
? {
getFrom: () => ({ opacity: 0, marginTop: '48px' }),
getTo: isOpen =>
isOpen
? { opacity: 1, marginTop: '0px' }
: { opacity: 0, marginTop: '48px' },
}
: {
getFrom: () => ({ opacity: 0, transform: 'translateY(48px)' }),
getTo: isOpen =>
isOpen
? { opacity: 1, transform: 'translateY(0px)' }
: { opacity: 0, transform: 'translateY(48px)' },
}
);
useEffect(() => { useEffect(() => {
if (!isClosed) { if (!isClosed) {

View file

@ -59,6 +59,7 @@ const MESSAGE_DEFAULT_PROPS = {
openGiftBadge: shouldNeverBeCalled, openGiftBadge: shouldNeverBeCalled,
openLink: shouldNeverBeCalled, openLink: shouldNeverBeCalled,
previews: [], previews: [],
retryMessageSend: shouldNeverBeCalled,
pushPanelForConversation: shouldNeverBeCalled, pushPanelForConversation: shouldNeverBeCalled,
renderAudioAttachment: () => <div />, renderAudioAttachment: () => <div />,
saveAttachment: shouldNeverBeCalled, saveAttachment: shouldNeverBeCalled,
@ -240,6 +241,7 @@ export function StoryViewsNRepliesModal({
isFormattingEnabled={isFormattingEnabled} isFormattingEnabled={isFormattingEnabled}
isFormattingSpoilersEnabled={isFormattingSpoilersEnabled} isFormattingSpoilersEnabled={isFormattingSpoilersEnabled}
moduleClassName="StoryViewsNRepliesModal__input" moduleClassName="StoryViewsNRepliesModal__input"
onCloseLinkPreview={noop}
onEditorStateChange={({ messageText }) => { onEditorStateChange={({ messageText }) => {
setMessageBodyText(messageText); setMessageBodyText(messageText);
}} }}

View file

@ -26,6 +26,8 @@ function getToast(toastType: ToastType): AnyToast {
return { toastType: ToastType.Blocked }; return { toastType: ToastType.Blocked };
case ToastType.BlockedGroup: case ToastType.BlockedGroup:
return { toastType: ToastType.BlockedGroup }; return { toastType: ToastType.BlockedGroup };
case ToastType.CannotEditMessage:
return { toastType: ToastType.CannotEditMessage };
case ToastType.CannotForwardEmptyMessage: case ToastType.CannotForwardEmptyMessage:
return { toastType: ToastType.CannotForwardEmptyMessage }; return { toastType: ToastType.CannotForwardEmptyMessage };
case ToastType.CannotMixMultiAndNonMultiAttachments: case ToastType.CannotMixMultiAndNonMultiAttachments:

View file

@ -68,6 +68,14 @@ export function ToastManager({
return <Toast onClose={hideToast}>{i18n('icu:unblockGroupToSend')}</Toast>; return <Toast onClose={hideToast}>{i18n('icu:unblockGroupToSend')}</Toast>;
} }
if (toastType === ToastType.CannotEditMessage) {
return (
<Toast onClose={hideToast}>
{i18n('icu:ToastManager__CannotEditMessage')}
</Toast>
);
}
if (toastType === ToastType.CannotForwardEmptyMessage) { if (toastType === ToastType.CannotForwardEmptyMessage) {
return ( return (
<Toast onClose={hideToast}> <Toast onClose={hideToast}>

View file

@ -99,6 +99,7 @@ import { RenderLocation } from './MessageTextRenderer';
const GUESS_METADATA_WIDTH_TIMESTAMP_SIZE = 16; const GUESS_METADATA_WIDTH_TIMESTAMP_SIZE = 16;
const GUESS_METADATA_WIDTH_EXPIRE_TIMER_SIZE = 18; const GUESS_METADATA_WIDTH_EXPIRE_TIMER_SIZE = 18;
const GUESS_METADATA_WIDTH_EDITED_SIZE = 40;
const GUESS_METADATA_WIDTH_OUTGOING_SIZE: Record<MessageStatusType, number> = { const GUESS_METADATA_WIDTH_OUTGOING_SIZE: Record<MessageStatusType, number> = {
delivered: 24, delivered: 24,
error: 24, error: 24,
@ -314,6 +315,7 @@ export type PropsActions = {
showConversation: ShowConversationType; showConversation: ShowConversationType;
openGiftBadge: (messageId: string) => void; openGiftBadge: (messageId: string) => void;
pushPanelForConversation: PushPanelForConversationActionType; pushPanelForConversation: PushPanelForConversationActionType;
retryMessageSend: (messageId: string) => unknown;
showContactModal: (contactId: string, conversationId?: string) => void; showContactModal: (contactId: string, conversationId?: string) => void;
showSpoiler: (messageId: string) => void; showSpoiler: (messageId: string) => void;
@ -617,10 +619,14 @@ export class Message extends React.PureComponent<Props, State> {
* because it can reduce layout jumpiness. * because it can reduce layout jumpiness.
*/ */
private guessMetadataWidth(): number { private guessMetadataWidth(): number {
const { direction, expirationLength, status } = this.props; const { direction, expirationLength, status, isEditedMessage } = this.props;
let result = GUESS_METADATA_WIDTH_TIMESTAMP_SIZE; let result = GUESS_METADATA_WIDTH_TIMESTAMP_SIZE;
if (isEditedMessage) {
result += GUESS_METADATA_WIDTH_EDITED_SIZE;
}
const hasExpireTimer = Boolean(expirationLength); const hasExpireTimer = Boolean(expirationLength);
if (hasExpireTimer) { if (hasExpireTimer) {
result += GUESS_METADATA_WIDTH_EXPIRE_TIMER_SIZE; result += GUESS_METADATA_WIDTH_EXPIRE_TIMER_SIZE;
@ -790,6 +796,7 @@ export class Message extends React.PureComponent<Props, State> {
isEditedMessage, isEditedMessage,
isSticker, isSticker,
isTapToViewExpired, isTapToViewExpired,
retryMessageSend,
pushPanelForConversation, pushPanelForConversation,
showEditHistoryModal, showEditHistoryModal,
status, status,
@ -816,6 +823,7 @@ export class Message extends React.PureComponent<Props, State> {
isTapToViewExpired={isTapToViewExpired} isTapToViewExpired={isTapToViewExpired}
onWidthMeasured={isInline ? this.updateMetadataWidth : undefined} onWidthMeasured={isInline ? this.updateMetadataWidth : undefined}
pushPanelForConversation={pushPanelForConversation} pushPanelForConversation={pushPanelForConversation}
retryMessageSend={retryMessageSend}
showEditHistoryModal={showEditHistoryModal} showEditHistoryModal={showEditHistoryModal}
status={status} status={status}
textPending={textAttachment?.pending} textPending={textAttachment?.pending}

View file

@ -22,6 +22,7 @@ import { PlaybackButton } from '../PlaybackButton';
import { WaveformScrubber } from './WaveformScrubber'; import { WaveformScrubber } from './WaveformScrubber';
import { useComputePeaks } from '../../hooks/useComputePeaks'; import { useComputePeaks } from '../../hooks/useComputePeaks';
import { durationToPlaybackText } from '../../util/durationToPlaybackText'; import { durationToPlaybackText } from '../../util/durationToPlaybackText';
import { shouldNeverBeCalled } from '../../util/shouldNeverBeCalled';
export type OwnProps = Readonly<{ export type OwnProps = Readonly<{
active: active:
@ -360,6 +361,7 @@ export function MessageAudio(props: Props): JSX.Element {
isSticker={false} isSticker={false}
isTapToViewExpired={false} isTapToViewExpired={false}
pushPanelForConversation={pushPanelForConversation} pushPanelForConversation={pushPanelForConversation}
retryMessageSend={shouldNeverBeCalled}
status={status} status={status}
textPending={textPending} textPending={textPending}
timestamp={timestamp} timestamp={timestamp}

View file

@ -87,6 +87,7 @@ const createProps = (overrideProps: Partial<Props> = {}): Props => ({
renderAudioAttachment: () => <div>*AudioAttachment*</div>, renderAudioAttachment: () => <div>*AudioAttachment*</div>,
saveAttachment: action('saveAttachment'), saveAttachment: action('saveAttachment'),
showSpoiler: action('showSpoiler'), showSpoiler: action('showSpoiler'),
retryMessageSend: action('retryMessageSend'),
pushPanelForConversation: action('pushPanelForConversation'), pushPanelForConversation: action('pushPanelForConversation'),
showContactModal: action('showContactModal'), showContactModal: action('showContactModal'),
showExpiredIncomingTapToViewToast: action( showExpiredIncomingTapToViewToast: action(

View file

@ -84,6 +84,7 @@ export type PropsReduxActions = Pick<
| 'messageExpanded' | 'messageExpanded'
| 'openGiftBadge' | 'openGiftBadge'
| 'pushPanelForConversation' | 'pushPanelForConversation'
| 'retryMessageSend'
| 'saveAttachment' | 'saveAttachment'
| 'showContactModal' | 'showContactModal'
| 'showConversation' | 'showConversation'
@ -125,6 +126,7 @@ export function MessageDetail({
openGiftBadge, openGiftBadge,
platform, platform,
pushPanelForConversation, pushPanelForConversation,
retryMessageSend,
renderAudioAttachment, renderAudioAttachment,
saveAttachment, saveAttachment,
showContactModal, showContactModal,
@ -345,6 +347,7 @@ export function MessageDetail({
openGiftBadge={openGiftBadge} openGiftBadge={openGiftBadge}
platform={platform} platform={platform}
pushPanelForConversation={pushPanelForConversation} pushPanelForConversation={pushPanelForConversation}
retryMessageSend={retryMessageSend}
renderAudioAttachment={renderAudioAttachment} renderAudioAttachment={renderAudioAttachment}
saveAttachment={saveAttachment} saveAttachment={saveAttachment}
shouldCollapseAbove={false} shouldCollapseAbove={false}

View file

@ -2,17 +2,20 @@
// SPDX-License-Identifier: AGPL-3.0-only // SPDX-License-Identifier: AGPL-3.0-only
import type { ReactChild, ReactElement } from 'react'; import type { ReactChild, ReactElement } from 'react';
import React from 'react'; import React, { useCallback, useState } from 'react';
import classNames from 'classnames'; import classNames from 'classnames';
import type { ContentRect } from 'react-measure';
import Measure from 'react-measure'; import Measure from 'react-measure';
import type { LocalizerType } from '../../types/Util'; import type { LocalizerType } from '../../types/Util';
import type { DirectionType, MessageStatusType } from './Message'; import type { DirectionType, MessageStatusType } from './Message';
import type { PushPanelForConversationActionType } from '../../state/ducks/conversations'; import type { PushPanelForConversationActionType } from '../../state/ducks/conversations';
import { missingCaseError } from '../../util/missingCaseError';
import { ExpireTimer } from './ExpireTimer'; import { ExpireTimer } from './ExpireTimer';
import { MessageTimestamp } from './MessageTimestamp'; import { MessageTimestamp } from './MessageTimestamp';
import { PanelType } from '../../types/Panels'; import { PanelType } from '../../types/Panels';
import { Spinner } from '../Spinner'; import { Spinner } from '../Spinner';
import { ConfirmationDialog } from '../ConfirmationDialog';
type PropsType = { type PropsType = {
deletedForEveryone?: boolean; deletedForEveryone?: boolean;
@ -29,12 +32,17 @@ type PropsType = {
isTapToViewExpired?: boolean; isTapToViewExpired?: boolean;
onWidthMeasured?: (width: number) => unknown; onWidthMeasured?: (width: number) => unknown;
pushPanelForConversation: PushPanelForConversationActionType; pushPanelForConversation: PushPanelForConversationActionType;
retryMessageSend: (messageId: string) => unknown;
showEditHistoryModal?: (id: string) => unknown; showEditHistoryModal?: (id: string) => unknown;
status?: MessageStatusType; status?: MessageStatusType;
textPending?: boolean; textPending?: boolean;
timestamp: number; timestamp: number;
}; };
enum ConfirmationType {
EditError = 'EditError',
}
export function MessageMetadata({ export function MessageMetadata({
deletedForEveryone, deletedForEveryone,
direction, direction,
@ -50,11 +58,15 @@ export function MessageMetadata({
isTapToViewExpired, isTapToViewExpired,
onWidthMeasured, onWidthMeasured,
pushPanelForConversation, pushPanelForConversation,
retryMessageSend,
showEditHistoryModal, showEditHistoryModal,
status, status,
textPending, textPending,
timestamp, timestamp,
}: Readonly<PropsType>): ReactElement { }: Readonly<PropsType>): ReactElement {
const [confirmationType, setConfirmationType] = useState<
ConfirmationType | undefined
>();
const withImageNoCaption = Boolean(!isSticker && !hasText && isShowingImage); const withImageNoCaption = Boolean(!isSticker && !hasText && isShowingImage);
const metadataDirection = isSticker ? undefined : direction; const metadataDirection = isSticker ? undefined : direction;
@ -68,9 +80,26 @@ export function MessageMetadata({
if (isError || isPartiallySent || isPaused) { if (isError || isPartiallySent || isPaused) {
let statusInfo: React.ReactChild; let statusInfo: React.ReactChild;
if (isError) { if (isError) {
statusInfo = deletedForEveryone if (deletedForEveryone) {
? i18n('icu:deleteFailed') statusInfo = i18n('icu:deleteFailed');
: i18n('icu:sendFailed'); } else if (isEditedMessage) {
statusInfo = (
<button
type="button"
className="module-message__metadata__tapable"
onClick={(event: React.MouseEvent) => {
event.stopPropagation();
event.preventDefault();
setConfirmationType(ConfirmationType.EditError);
}}
>
{i18n('icu:editFailed')}
</button>
);
} else {
statusInfo = i18n('icu:sendFailed');
}
} else if (isPaused) { } else if (isPaused) {
statusInfo = i18n('icu:sendPaused'); statusInfo = i18n('icu:sendPaused');
} else { } else {
@ -126,6 +155,35 @@ export function MessageMetadata({
} }
} }
let confirmation: JSX.Element | undefined;
if (confirmationType === undefined) {
// no-op
} else if (confirmationType === ConfirmationType.EditError) {
confirmation = (
<ConfirmationDialog
dialogName="MessageMetadata.confirmEditResend"
actions={[
{
action: () => {
retryMessageSend(id);
setConfirmationType(undefined);
},
style: 'negative',
text: i18n('icu:ResendMessageEdit__button'),
},
]}
i18n={i18n}
onClose={() => {
setConfirmationType(undefined);
}}
>
{i18n('icu:ResendMessageEdit__body')}
</ConfirmationDialog>
);
} else {
throw missingCaseError(confirmationType);
}
const className = classNames( const className = classNames(
'module-message__metadata', 'module-message__metadata',
isInline && 'module-message__metadata--inline', isInline && 'module-message__metadata--inline',
@ -184,17 +242,20 @@ export function MessageMetadata({
)} )}
/> />
) : null} ) : null}
{confirmation}
</> </>
); );
const onResize = useCallback(
({ bounds }: ContentRect) => {
onWidthMeasured?.(bounds?.width || 0);
},
[onWidthMeasured]
);
if (onWidthMeasured) { if (onWidthMeasured) {
return ( return (
<Measure <Measure bounds onResize={onResize}>
bounds
onResize={({ bounds }) => {
onWidthMeasured(bounds?.width || 0);
}}
>
{({ measureRef }) => ( {({ measureRef }) => (
<div className={className} ref={measureRef}> <div className={className} ref={measureRef}>
{children} {children}

View file

@ -83,6 +83,7 @@ const defaultMessageProps: TimelineMessagesProps = {
id: 'some-id', id: 'some-id',
title: 'Person X', title: 'Person X',
}), }),
canEditMessage: true,
canReact: true, canReact: true,
canReply: true, canReply: true,
canRetry: true, canRetry: true,
@ -125,6 +126,7 @@ const defaultMessageProps: TimelineMessagesProps = {
renderEmojiPicker: () => <div />, renderEmojiPicker: () => <div />,
renderReactionPicker: () => <div />, renderReactionPicker: () => <div />,
renderAudioAttachment: () => <div>*AudioAttachment*</div>, renderAudioAttachment: () => <div>*AudioAttachment*</div>,
setMessageToEdit: action('setMessageToEdit'),
setQuoteByMessageId: action('default--setQuoteByMessageId'), setQuoteByMessageId: action('default--setQuoteByMessageId'),
retryMessageSend: action('default--retryMessageSend'), retryMessageSend: action('default--retryMessageSend'),
retryDeleteForEveryone: action('default--retryDeleteForEveryone'), retryDeleteForEveryone: action('default--retryDeleteForEveryone'),

View file

@ -49,6 +49,7 @@ function mockMessageTimelineItem(
author: getDefaultConversation({}), author: getDefaultConversation({}),
canDeleteForEveryone: false, canDeleteForEveryone: false,
canDownload: true, canDownload: true,
canEditMessage: true,
canReact: true, canReact: true,
canReply: true, canReply: true,
canRetry: true, canRetry: true,
@ -279,6 +280,7 @@ const actions = () => ({
updateSharedGroups: action('updateSharedGroups'), updateSharedGroups: action('updateSharedGroups'),
reactToMessage: action('reactToMessage'), reactToMessage: action('reactToMessage'),
setMessageToEdit: action('setMessageToEdit'),
setQuoteByMessageId: action('setQuoteByMessageId'), setQuoteByMessageId: action('setQuoteByMessageId'),
retryDeleteForEveryone: action('retryDeleteForEveryone'), retryDeleteForEveryone: action('retryDeleteForEveryone'),
retryMessageSend: action('retryMessageSend'), retryMessageSend: action('retryMessageSend'),

View file

@ -67,6 +67,7 @@ const getDefaultProps = () => ({
reactToMessage: action('reactToMessage'), reactToMessage: action('reactToMessage'),
checkForAccount: action('checkForAccount'), checkForAccount: action('checkForAccount'),
clearTargetedMessage: action('clearTargetedMessage'), clearTargetedMessage: action('clearTargetedMessage'),
setMessageToEdit: action('setMessageToEdit'),
setQuoteByMessageId: action('setQuoteByMessageId'), setQuoteByMessageId: action('setQuoteByMessageId'),
retryDeleteForEveryone: action('retryDeleteForEveryone'), retryDeleteForEveryone: action('retryDeleteForEveryone'),
retryMessageSend: action('retryMessageSend'), retryMessageSend: action('retryMessageSend'),

View file

@ -197,6 +197,7 @@ export const TimelineItem = memo(function TimelineItem({
renderUniversalTimerNotification, renderUniversalTimerNotification,
returnToActiveCall, returnToActiveCall,
targetMessage, targetMessage,
setMessageToEdit,
shouldCollapseAbove, shouldCollapseAbove,
shouldCollapseBelow, shouldCollapseBelow,
shouldHideMetadata, shouldHideMetadata,
@ -223,6 +224,7 @@ export const TimelineItem = memo(function TimelineItem({
{...item.data} {...item.data}
isTargeted={isTargeted} isTargeted={isTargeted}
targetMessage={targetMessage} targetMessage={targetMessage}
setMessageToEdit={setMessageToEdit}
shouldCollapseAbove={shouldCollapseAbove} shouldCollapseAbove={shouldCollapseAbove}
shouldCollapseBelow={shouldCollapseBelow} shouldCollapseBelow={shouldCollapseBelow}
shouldHideMetadata={shouldHideMetadata} shouldHideMetadata={shouldHideMetadata}

View file

@ -245,6 +245,7 @@ const createProps = (overrideProps: Partial<Props> = {}): Props => ({
attachments: overrideProps.attachments, attachments: overrideProps.attachments,
author: overrideProps.author || getDefaultConversation(), author: overrideProps.author || getDefaultConversation(),
bodyRanges: overrideProps.bodyRanges, bodyRanges: overrideProps.bodyRanges,
canEditMessage: true,
canReact: true, canReact: true,
canReply: true, canReply: true,
canDownload: true, canDownload: true,
@ -330,6 +331,7 @@ const createProps = (overrideProps: Partial<Props> = {}): Props => ({
overrideProps.toggleSelectMessage == null overrideProps.toggleSelectMessage == null
? action('toggleSelectMessage') ? action('toggleSelectMessage')
: overrideProps.toggleSelectMessage, : overrideProps.toggleSelectMessage,
setMessageToEdit: action('setMessageToEdit'),
shouldCollapseAbove: isBoolean(overrideProps.shouldCollapseAbove) shouldCollapseAbove: isBoolean(overrideProps.shouldCollapseAbove)
? overrideProps.shouldCollapseAbove ? overrideProps.shouldCollapseAbove
: false, : false,
@ -878,6 +880,13 @@ Error.args = {
text: 'I hope you get this.', 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({}); export const Paused = Template.bind({});
Paused.args = { Paused.args = {
status: 'paused', status: 'paused',

View file

@ -32,6 +32,7 @@ import type { DeleteMessagesPropsType } from '../../state/ducks/globalModals';
export type PropsData = { export type PropsData = {
canDownload: boolean; canDownload: boolean;
canEditMessage: boolean;
canRetry: boolean; canRetry: boolean;
canRetryDeleteForEveryone: boolean; canRetryDeleteForEveryone: boolean;
canReact: boolean; canReact: boolean;
@ -50,6 +51,7 @@ export type PropsActions = {
) => void; ) => void;
retryMessageSend: (id: string) => void; retryMessageSend: (id: string) => void;
retryDeleteForEveryone: (id: string) => void; retryDeleteForEveryone: (id: string) => void;
setMessageToEdit: (conversationId: string, messageId: string) => unknown;
setQuoteByMessageId: (conversationId: string, messageId: string) => void; setQuoteByMessageId: (conversationId: string, messageId: string) => void;
toggleSelectMessage: ( toggleSelectMessage: (
conversationId: string, conversationId: string,
@ -80,6 +82,7 @@ export function TimelineMessage(props: Props): JSX.Element {
attachments, attachments,
author, author,
canDownload, canDownload,
canEditMessage,
canReact, canReact,
canReply, canReply,
canRetry, canRetry,
@ -107,6 +110,7 @@ export function TimelineMessage(props: Props): JSX.Element {
saveAttachment, saveAttachment,
selectedReaction, selectedReaction,
setQuoteByMessageId, setQuoteByMessageId,
setMessageToEdit,
text, text,
timestamp, timestamp,
toggleDeleteMessagesModal, toggleDeleteMessagesModal,
@ -350,6 +354,11 @@ export function TimelineMessage(props: Props): JSX.Element {
triggerId={triggerId} triggerId={triggerId}
shouldShowAdditional={shouldShowAdditional} shouldShowAdditional={shouldShowAdditional}
onDownload={handleDownload} onDownload={handleDownload}
onEdit={
canEditMessage
? () => setMessageToEdit(conversationId, id)
: undefined
}
onReplyToMessage={handleReplyToMessage} onReplyToMessage={handleReplyToMessage}
onReact={handleReact} onReact={handleReact}
onRetryMessageSend={canRetry ? () => retryMessageSend(id) : undefined} onRetryMessageSend={canRetry ? () => retryMessageSend(id) : undefined}
@ -540,6 +549,7 @@ type MessageContextProps = {
shouldShowAdditional: boolean; shouldShowAdditional: boolean;
onDownload: (() => void) | undefined; onDownload: (() => void) | undefined;
onEdit: (() => void) | undefined;
onReplyToMessage: (() => void) | undefined; onReplyToMessage: (() => void) | undefined;
onReact: (() => void) | undefined; onReact: (() => void) | undefined;
onRetryMessageSend: (() => void) | undefined; onRetryMessageSend: (() => void) | undefined;
@ -555,6 +565,7 @@ const MessageContextMenu = ({
triggerId, triggerId,
shouldShowAdditional, shouldShowAdditional,
onDownload, onDownload,
onEdit,
onReplyToMessage, onReplyToMessage,
onReact, onReact,
onMoreInfo, onMoreInfo,
@ -686,6 +697,22 @@ const MessageContextMenu = ({
{i18n('icu:forwardMessage')} {i18n('icu:forwardMessage')}
</MenuItem> </MenuItem>
)} )}
{onEdit && (
<MenuItem
attributes={{
className:
'module-message__context--icon module-message__context__edit-message',
}}
onClick={(event: React.MouseEvent) => {
event.stopPropagation();
event.preventDefault();
onEdit();
}}
>
{i18n('icu:edit')}
</MenuItem>
)}
<MenuItem <MenuItem
attributes={{ attributes={{
className: className:

View file

@ -8,6 +8,7 @@ import { useChain, useSpring, useSpringRef } from '@react-spring/web';
export type ModalConfigType = { export type ModalConfigType = {
opacity: number; opacity: number;
transform?: string; transform?: string;
marginTop?: string;
}; };
enum ModalState { enum ModalState {

View file

@ -2,6 +2,7 @@
// SPDX-License-Identifier: AGPL-3.0-only // SPDX-License-Identifier: AGPL-3.0-only
import { isNumber } from 'lodash'; import { isNumber } from 'lodash';
import PQueue from 'p-queue';
import * as Errors from '../../types/errors'; import * as Errors from '../../types/errors';
import { strictAssert } from '../../util/assert'; import { strictAssert } from '../../util/assert';
@ -13,20 +14,30 @@ import { getSendOptions } from '../../util/getSendOptions';
import { SignalService as Proto } from '../../protobuf'; import { SignalService as Proto } from '../../protobuf';
import { handleMessageSend } from '../../util/handleMessageSend'; import { handleMessageSend } from '../../util/handleMessageSend';
import { findAndFormatContact } from '../../util/findAndFormatContact'; import { findAndFormatContact } from '../../util/findAndFormatContact';
import { uploadAttachment } from '../../util/uploadAttachment';
import type { CallbackResultType } from '../../textsecure/Types.d'; import type { CallbackResultType } from '../../textsecure/Types.d';
import { isSent } from '../../messages/MessageSendState'; import { isSent } from '../../messages/MessageSendState';
import { isOutgoing, canReact } from '../../state/selectors/message'; import { isOutgoing, canReact } from '../../state/selectors/message';
import type { import type {
AttachmentType,
ContactWithHydratedAvatar,
ReactionType, ReactionType,
OutgoingQuoteType,
OutgoingQuoteAttachmentType,
OutgoingLinkPreviewType,
OutgoingStickerType,
} from '../../textsecure/SendMessage'; } from '../../textsecure/SendMessage';
import type { LinkPreviewType } from '../../types/message/LinkPreviews'; import type {
AttachmentType,
UploadedAttachmentType,
AttachmentWithHydratedData,
} from '../../types/Attachment';
import { LONG_MESSAGE, MIMETypeToString } from '../../types/MIME';
import type { RawBodyRange } from '../../types/BodyRange'; import type { RawBodyRange } from '../../types/BodyRange';
import type {
EmbeddedContactWithHydratedAvatar,
EmbeddedContactWithUploadedAvatar,
} from '../../types/EmbeddedContact';
import type { StoryContextType } from '../../types/Util'; import type { StoryContextType } from '../../types/Util';
import type { LoggerType } from '../../types/Logging'; import type { LoggerType } from '../../types/Logging';
import type { StickerWithHydratedData } from '../../types/Stickers';
import type { QuotedMessageType } from '../../model-types.d';
import type { import type {
ConversationQueueJobBundle, ConversationQueueJobBundle,
NormalMessageSendJobData, NormalMessageSendJobData,
@ -39,6 +50,10 @@ import { isConversationAccepted } from '../../util/isConversationAccepted';
import { sendToGroup } from '../../util/sendToGroup'; import { sendToGroup } from '../../util/sendToGroup';
import type { DurationInSeconds } from '../../util/durations'; import type { DurationInSeconds } from '../../util/durations';
import type { UUIDStringType } from '../../types/UUID'; import type { UUIDStringType } from '../../types/UUID';
import * as Bytes from '../../Bytes';
const LONG_ATTACHMENT_LIMIT = 2048;
const MAX_CONCURRENT_ATTACHMENT_UPLOADS = 5;
export async function sendNormalMessage( export async function sendNormalMessage(
conversation: ConversationModel, conversation: ConversationModel,
@ -149,15 +164,16 @@ export async function sendNormalMessage(
body, body,
contact, contact,
deletedForEveryoneTimestamp, deletedForEveryoneTimestamp,
editedMessageTimestamp,
expireTimer, expireTimer,
bodyRanges, bodyRanges,
messageTimestamp, messageTimestamp,
preview, preview,
quote, quote,
reaction,
sticker, sticker,
storyMessage, storyMessage,
storyContext, storyContext,
reaction,
} = await getMessageSendData({ log, message }); } = await getMessageSendData({ log, message });
if (reaction) { if (reaction) {
@ -211,6 +227,7 @@ export async function sendNormalMessage(
bodyRanges, bodyRanges,
contact, contact,
deletedForEveryoneTimestamp, deletedForEveryoneTimestamp,
editedMessageTimestamp,
expireTimer, expireTimer,
groupV2: conversation.getGroupV2Info({ groupV2: conversation.getGroupV2Info({
members: recipientIdentifiersWithoutMe, members: recipientIdentifiersWithoutMe,
@ -256,6 +273,7 @@ export async function sendNormalMessage(
bodyRanges, bodyRanges,
contact, contact,
deletedForEveryoneTimestamp, deletedForEveryoneTimestamp,
editedMessageTimestamp,
expireTimer, expireTimer,
groupV2: groupV2Info, groupV2: groupV2Info,
messageText: body, messageText: body,
@ -309,6 +327,7 @@ export async function sendNormalMessage(
contact, contact,
contentHint: ContentHint.RESENDABLE, contentHint: ContentHint.RESENDABLE,
deletedForEveryoneTimestamp, deletedForEveryoneTimestamp,
editedMessageTimestamp,
expireTimer, expireTimer,
groupId: undefined, groupId: undefined,
identifier: recipientIdentifiersWithoutMe[0], identifier: recipientIdentifiersWithoutMe[0],
@ -466,83 +485,115 @@ async function getMessageSendData({
log: LoggerType; log: LoggerType;
message: MessageModel; message: MessageModel;
}>): Promise<{ }>): Promise<{
attachments: Array<AttachmentType>; attachments: Array<UploadedAttachmentType>;
body: undefined | string; body: undefined | string;
contact?: Array<ContactWithHydratedAvatar>; contact?: Array<EmbeddedContactWithUploadedAvatar>;
deletedForEveryoneTimestamp: undefined | number; deletedForEveryoneTimestamp: undefined | number;
editedMessageTimestamp: number | undefined;
expireTimer: undefined | DurationInSeconds; expireTimer: undefined | DurationInSeconds;
bodyRanges: undefined | ReadonlyArray<RawBodyRange>; bodyRanges: undefined | ReadonlyArray<RawBodyRange>;
messageTimestamp: number; messageTimestamp: number;
preview: Array<LinkPreviewType>; preview: Array<OutgoingLinkPreviewType> | undefined;
quote: QuotedMessageType | null; quote: OutgoingQuoteType | undefined;
sticker: StickerWithHydratedData | undefined; sticker: OutgoingStickerType | undefined;
reaction: ReactionType | undefined; reaction: ReactionType | undefined;
storyMessage?: MessageModel; storyMessage?: MessageModel;
storyContext?: StoryContextType; storyContext?: StoryContextType;
}> { }> {
const { const editMessageTimestamp = message.get('editMessageTimestamp');
loadAttachmentData,
loadContactData,
loadPreviewData,
loadQuoteData,
loadStickerData,
} = window.Signal.Migrations;
let messageTimestamp: number;
const sentAt = message.get('sent_at'); const sentAt = message.get('sent_at');
const timestamp = message.get('timestamp'); const timestamp = message.get('timestamp');
let mainMessageTimestamp: number;
if (sentAt) { if (sentAt) {
messageTimestamp = sentAt; mainMessageTimestamp = sentAt;
} else if (timestamp) { } else if (timestamp) {
log.error('message lacked sent_at. Falling back to timestamp'); log.error('message lacked sent_at. Falling back to timestamp');
messageTimestamp = timestamp; mainMessageTimestamp = timestamp;
} else { } else {
log.error( log.error(
'message lacked sent_at and timestamp. Falling back to current time' '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 storyId = message.get('storyId');
const [attachmentsWithData, contact, preview, quote, sticker, storyMessage] = // Figure out if we need to upload message body as an attachment.
await Promise.all([ let body = message.get('body');
// We don't update the caches here because (1) we expect the caches to be populated let maybeLongAttachment: AttachmentWithHydratedData | undefined;
// on initial send, so they should be there in the 99% case (2) if you're retrying if (body && body.length > LONG_ATTACHMENT_LIMIT) {
// a failed message across restarts, we don't touch the cache for simplicity. If const data = Bytes.fromString(body);
// 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,
]);
const { body, attachments } = window.Whisper.Message.getLongMessageAttachment( maybeLongAttachment = {
{ contentType: LONG_MESSAGE,
body: message.get('body'), fileName: `long-message-${messageTimestamp}.txt`,
attachments: attachmentsWithData, data,
now: messageTimestamp, 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 storyReaction = message.get('storyReaction');
const isEditedMessage = Boolean(message.get('editHistory'));
return { return {
attachments, attachments: [
...(maybeUploadedLongAttachment ? [maybeUploadedLongAttachment] : []),
...uploadedAttachments,
],
body, body,
contact, contact,
deletedForEveryoneTimestamp: message.get('deletedForEveryoneTimestamp'), deletedForEveryoneTimestamp: message.get('deletedForEveryoneTimestamp'),
editedMessageTimestamp: isEditedMessage ? mainMessageTimestamp : undefined,
expireTimer: message.get('expireTimer'), expireTimer: message.get('expireTimer'),
// TODO: we want filtration here if feature flag doesn't allow format/spoiler sends // TODO: we want filtration here if feature flag doesn't allow format/spoiler sends
bodyRanges: message.get('bodyRanges'), bodyRanges: message.get('bodyRanges'),
messageTimestamp, messageTimestamp,
preview, preview,
quote, quote,
reaction: storyReaction
? {
...storyReaction,
remove: false,
}
: undefined,
sticker, sticker,
storyMessage, storyMessage,
storyContext: storyMessage storyContext: storyMessage
@ -551,15 +602,315 @@ async function getMessageSendData({
timestamp: storyMessage.get('sent_at'), timestamp: storyMessage.get('sent_at'),
} }
: undefined, : undefined,
reaction: storyReaction
? {
...storyReaction,
remove: false,
}
: undefined,
}; };
} }
async function uploadSingleAttachment(
message: MessageModel,
attachment: AttachmentType
): Promise<UploadedAttachmentType> {
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<OutgoingQuoteType | undefined> {
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<OutgoingQuoteAttachmentType> => {
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<Array<OutgoingLinkPreviewType> | 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<OutgoingLinkPreviewType> => {
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<OutgoingStickerType | undefined> {
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<Array<EmbeddedContactWithUploadedAvatar> | 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<EmbeddedContactWithUploadedAvatar> => {
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( async function markMessageFailed(
message: MessageModel, message: MessageModel,
errors: Array<Error> errors: Array<Error>

View file

@ -2,10 +2,7 @@
// SPDX-License-Identifier: AGPL-3.0-only // SPDX-License-Identifier: AGPL-3.0-only
import { isEqual } from 'lodash'; import { isEqual } from 'lodash';
import type { import type { UploadedAttachmentType } from '../../types/Attachment';
AttachmentWithHydratedData,
TextAttachmentType,
} from '../../types/Attachment';
import type { ConversationModel } from '../../models/conversations'; import type { ConversationModel } from '../../models/conversations';
import type { import type {
ConversationQueueJobBundle, ConversationQueueJobBundle,
@ -38,7 +35,9 @@ import { isGroupV2, isMe } from '../../util/whatTypeOfConversation';
import { ourProfileKeyService } from '../../services/ourProfileKey'; import { ourProfileKeyService } from '../../services/ourProfileKey';
import { sendContentMessageToGroup } from '../../util/sendToGroup'; import { sendContentMessageToGroup } from '../../util/sendToGroup';
import { distributionListToSendTarget } from '../../util/distributionListToSendTarget'; import { distributionListToSendTarget } from '../../util/distributionListToSendTarget';
import { uploadAttachment } from '../../util/uploadAttachment';
import { SendMessageChallengeError } from '../../textsecure/Errors'; import { SendMessageChallengeError } from '../../textsecure/Errors';
import type { OutgoingTextAttachmentType } from '../../textsecure/SendMessage';
export async function sendStory( export async function sendStory(
conversation: ConversationModel, conversation: ConversationModel,
@ -136,15 +135,40 @@ export async function sendStory(
return; return;
} }
let textAttachment: TextAttachmentType | undefined; let textAttachment: OutgoingTextAttachmentType | undefined;
let fileAttachment: AttachmentWithHydratedData | undefined; let fileAttachment: UploadedAttachmentType | undefined;
if (attachment.textAttachment) { 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 { } else {
fileAttachment = await window.Signal.Migrations.loadAttachmentData( const hydratedAttachment =
attachment await window.Signal.Migrations.loadAttachmentData(attachment);
);
fileAttachment = await uploadAttachment(hydratedAttachment);
} }
const groupV2 = isGroupV2(conversation.attributes) const groupV2 = isGroupV2(conversation.attributes)

View file

@ -78,6 +78,7 @@ export async function stop(): Promise<void> {
export async function addJob( export async function addJob(
attachment: AttachmentType, attachment: AttachmentType,
// TODO: DESKTOP-5279
job: { messageId: string; type: AttachmentDownloadJobTypeType; index: number } job: { messageId: string; type: AttachmentDownloadJobTypeType; index: number }
): Promise<AttachmentType> { ): Promise<AttachmentType> {
if (!attachment) { if (!attachment) {
@ -482,6 +483,18 @@ async function _addAttachmentToMessage(
return; return;
} }
const maybeReplaceAttachment = (existing: AttachmentType): AttachmentType => {
if (isDownloaded(existing)) {
return existing;
}
if (attachmentSignature !== getAttachmentSignature(existing)) {
return existing;
}
return attachment;
};
if (type === 'attachment') { if (type === 'attachment') {
const attachments = message.get('attachments'); const attachments = message.get('attachments');
@ -498,51 +511,25 @@ async function _addAttachmentToMessage(
...edit, ...edit,
// Loop through all the attachments to find the attachment we intend // Loop through all the attachments to find the attachment we intend
// to replace. // to replace.
attachments: edit.attachments.map(editAttachment => { attachments: edit.attachments.map(item => {
if (isDownloaded(editAttachment)) { const newItem = maybeReplaceAttachment(item);
return editAttachment; handledInEditHistory ||= item !== newItem;
} return newItem;
if (
attachmentSignature !== getAttachmentSignature(editAttachment)
) {
return editAttachment;
}
handledInEditHistory = true;
return attachment;
}), }),
}; };
}); });
if (newEditHistory !== editHistory) { if (handledInEditHistory) {
message.set({ editHistory: newEditHistory }); message.set({ editHistory: newEditHistory });
} }
} }
if (!attachments || attachments.length <= index) { if (attachments) {
throw new Error( message.set({
`_addAttachmentToMessage: attachments didn't exist or index(${index}) was too large` 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; return;
} }
@ -554,69 +541,42 @@ async function _addAttachmentToMessage(
const editHistory = message.get('editHistory'); const editHistory = message.get('editHistory');
if (preview && editHistory) { if (preview && editHistory) {
const newEditHistory = editHistory.map(edit => { const newEditHistory = editHistory.map(edit => {
if (!edit.preview || edit.preview.length <= index) { if (!edit.preview) {
return edit; 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 { return {
...edit, ...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 }); message.set({ editHistory: newEditHistory });
} }
} }
if (!preview || preview.length <= index) { if (preview) {
throw new Error( message.set({
`_addAttachmentToMessage: preview didn't exist or ${index} was too large` 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; return;
} }
@ -628,6 +588,7 @@ async function _addAttachmentToMessage(
`_addAttachmentToMessage: contact didn't exist or ${index} was too large` `_addAttachmentToMessage: contact didn't exist or ${index} was too large`
); );
} }
const item = contact[index]; const item = contact[index];
if (item && item.avatar && item.avatar.avatar) { if (item && item.avatar && item.avatar.avatar) {
_checkOldAttachment(item.avatar, 'avatar', logPrefix); _checkOldAttachment(item.avatar, 'avatar', logPrefix);
@ -653,38 +614,58 @@ async function _addAttachmentToMessage(
if (type === 'quote') { if (type === 'quote') {
const quote = message.get('quote'); const quote = message.get('quote');
if (!quote) { const editHistory = message.get('editHistory');
throw new Error("_addAttachmentToMessage: quote didn't exist"); let handledInEditHistory = false;
} if (editHistory) {
const { attachments } = quote; const newEditHistory = editHistory.map(edit => {
if (!attachments || attachments.length <= index) { if (!edit.quote) {
throw new Error( return edit;
`_addAttachmentToMessage: quote attachments didn't exist or ${index} was too large` }
);
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 (quote) {
if (!item) { const newQuote = {
throw new Error( ...quote,
`_addAttachmentToMessage: quote attachment ${index} was falsey` 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; return;
} }

View file

@ -3,7 +3,6 @@
import type { MessageAttributesType } from '../model-types.d'; import type { MessageAttributesType } from '../model-types.d';
import type { MessageModel } from '../models/messages'; import type { MessageModel } from '../models/messages';
import type { ProcessedDataMessage } from '../textsecure/Types.d';
import * as Errors from '../types/errors'; import * as Errors from '../types/errors';
import * as log from '../logging/log'; import * as log from '../logging/log';
import { drop } from '../util/drop'; import { drop } from '../util/drop';
@ -12,7 +11,7 @@ import { getContactId } from '../messages/helpers';
import { handleEditMessage } from '../util/handleEditMessage'; import { handleEditMessage } from '../util/handleEditMessage';
export type EditAttributesType = { export type EditAttributesType = {
dataMessage: ProcessedDataMessage; conversationId: string;
fromId: string; fromId: string;
message: MessageAttributesType; message: MessageAttributesType;
targetSentTimestamp: number; targetSentTimestamp: number;
@ -29,9 +28,14 @@ export function forMessage(message: MessageModel): Array<EditAttributesType> {
}); });
if (size(matchingEdits) > 0) { 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)); filter(matchingEdits, item => edits.delete(item));
return Array.from(matchingEdits); return result;
} }
return []; return [];
@ -64,7 +68,7 @@ export async function onEdit(edit: EditAttributesType): Promise<void> {
targetConversation.queueJob('Edits.onEdit', async () => { targetConversation.queueJob('Edits.onEdit', async () => {
log.info('Handling edit for', { log.info('Handling edit for', {
targetSentTimestamp: edit.targetSentTimestamp, targetSentTimestamp: edit.targetSentTimestamp,
sentAt: edit.dataMessage.timestamp, sentAt: edit.message.timestamp,
}); });
const messages = await window.Signal.Data.getMessagesBySentAt( const messages = await window.Signal.Data.getMessagesBySentAt(
@ -74,7 +78,7 @@ export async function onEdit(edit: EditAttributesType): Promise<void> {
// Verify authorship // Verify authorship
const targetMessage = messages.find( const targetMessage = messages.find(
m => m =>
edit.message.conversationId === m.conversationId && edit.conversationId === m.conversationId &&
edit.fromId === getContactId(m) edit.fromId === getContactId(m)
); );

View file

@ -286,10 +286,9 @@ export class MessageReceipts extends Collection<MessageReceiptModel> {
const type = receipt.get('type'); const type = receipt.get('type');
try { try {
const messages = const messages = await window.Signal.Data.getMessagesBySentAt(
await window.Signal.Data.getMessagesIncludingEditedBySentAt( messageSentAt
messageSentAt );
);
const message = await getTargetMessage( const message = await getTargetMessage(
sourceConversationId, sourceConversationId,

View file

@ -83,10 +83,9 @@ export class ReadSyncs extends Collection {
async onSync(sync: ReadSyncModel): Promise<void> { async onSync(sync: ReadSyncModel): Promise<void> {
try { try {
const messages = const messages = await window.Signal.Data.getMessagesBySentAt(
await window.Signal.Data.getMessagesIncludingEditedBySentAt( sync.get('timestamp')
sync.get('timestamp') );
);
const found = messages.find(item => { const found = messages.find(item => {
const sender = window.ConversationController.lookupOrCreate({ const sender = window.ConversationController.lookupOrCreate({

View file

@ -63,10 +63,9 @@ export class ViewSyncs extends Collection {
async onSync(sync: ViewSyncModel): Promise<void> { async onSync(sync: ViewSyncModel): Promise<void> {
try { try {
const messages = const messages = await window.Signal.Data.getMessagesBySentAt(
await window.Signal.Data.getMessagesIncludingEditedBySentAt( sync.get('timestamp')
sync.get('timestamp') );
);
const found = messages.find(item => { const found = messages.find(item => {
const sender = window.ConversationController.lookupOrCreate({ const sender = window.ConversationController.lookupOrCreate({

View file

@ -111,7 +111,7 @@ export function getPaymentEventDescription(
export function isQuoteAMatch( export function isQuoteAMatch(
message: MessageAttributesType | null | undefined, message: MessageAttributesType | null | undefined,
conversationId: string, conversationId: string,
quote: QuotedMessageType quote: Pick<QuotedMessageType, 'id' | 'authorUuid' | 'author'>
): message is MessageAttributesType { ): message is MessageAttributesType {
if (!message) { if (!message) {
return false; return false;
@ -124,8 +124,13 @@ export function isQuoteAMatch(
reason: 'helpers.isQuoteAMatch', reason: 'helpers.isQuoteAMatch',
}); });
const isSameTimestamp =
message.sent_at === id ||
message.editHistory?.some(({ timestamp }) => timestamp === id) ||
false;
return ( return (
message.sent_at === id && isSameTimestamp &&
message.conversationId === conversationId && message.conversationId === conversationId &&
getContactId(message) === authorConversation?.id getContactId(message) === authorConversation?.id
); );

14
ts/model-types.d.ts vendored
View file

@ -76,7 +76,7 @@ export type QuotedAttachment = {
export type QuotedMessageType = { export type QuotedMessageType = {
// TODO DESKTOP-3826 // TODO DESKTOP-3826
// eslint-disable-next-line @typescript-eslint/no-explicit-any // eslint-disable-next-line @typescript-eslint/no-explicit-any
attachments: Array<any>; attachments: ReadonlyArray<any>;
payment?: AnyPaymentEvent; payment?: AnyPaymentEvent;
// `author` is an old attribute that holds the author's E164. We shouldn't use it for // `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. // new messages, but old messages might have this attribute.
@ -125,6 +125,7 @@ export type EditHistoryType = {
body?: string; body?: string;
bodyRanges?: ReadonlyArray<RawBodyRange>; bodyRanges?: ReadonlyArray<RawBodyRange>;
preview?: Array<LinkPreviewType>; preview?: Array<LinkPreviewType>;
quote?: QuotedMessageType;
timestamp: number; timestamp: number;
}; };
@ -278,6 +279,16 @@ export type ValidateConversationType = Pick<
'e164' | 'uuid' | 'type' | 'groupId' 'e164' | 'uuid' | 'type' | 'groupId'
>; >;
export type DraftEditMessageType = {
editHistoryLength: number;
attachmentThumbnail?: string;
bodyRanges?: DraftBodyRanges;
body: string;
preview?: LinkPreviewType;
targetMessageId: string;
quote?: QuotedMessageType;
};
export type ConversationAttributesType = { export type ConversationAttributesType = {
accessKey?: string | null; accessKey?: string | null;
addedBy?: string; addedBy?: string;
@ -341,6 +352,7 @@ export type ConversationAttributesType = {
// Shared fields // Shared fields
active_at?: number | null; active_at?: number | null;
draft?: string | null; draft?: string | null;
draftEditMessage?: DraftEditMessageType;
hasPostedStory?: boolean; hasPostedStory?: boolean;
isArchived?: boolean; isArchived?: boolean;
name?: string; name?: string;

View file

@ -38,10 +38,8 @@ import * as Conversation from '../types/Conversation';
import type { StickerType, StickerWithHydratedData } from '../types/Stickers'; import type { StickerType, StickerWithHydratedData } from '../types/Stickers';
import * as Stickers from '../types/Stickers'; import * as Stickers from '../types/Stickers';
import { StorySendMode } from '../types/Stories'; import { StorySendMode } from '../types/Stories';
import type { import type { EmbeddedContactWithHydratedAvatar } from '../types/EmbeddedContact';
ContactWithHydratedAvatar, import type { GroupV2InfoType } from '../textsecure/SendMessage';
GroupV2InfoType,
} from '../textsecure/SendMessage';
import createTaskWithTimeout from '../textsecure/TaskWithTimeout'; import createTaskWithTimeout from '../textsecure/TaskWithTimeout';
import MessageSender from '../textsecure/SendMessage'; import MessageSender from '../textsecure/SendMessage';
import type { import type {
@ -106,7 +104,10 @@ import { getConversationMembers } from '../util/getConversationMembers';
import { updateConversationsWithUuidLookup } from '../updateConversationsWithUuidLookup'; import { updateConversationsWithUuidLookup } from '../updateConversationsWithUuidLookup';
import { ReadStatus } from '../messages/MessageReadStatus'; import { ReadStatus } from '../messages/MessageReadStatus';
import { SendStatus } from '../messages/MessageSendState'; 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 { MINUTE, SECOND, DurationInSeconds } from '../util/durations';
import { concat, filter, map, repeat, zipObject } from '../util/iterables'; import { concat, filter, map, repeat, zipObject } from '../util/iterables';
import * as universalExpireTimer from '../util/universalExpireTimer'; import * as universalExpireTimer from '../util/universalExpireTimer';
@ -1916,6 +1917,7 @@ export class ConversationModel extends window.Backbone
const draftTimestamp = this.get('draftTimestamp'); const draftTimestamp = this.get('draftTimestamp');
const draftPreview = this.getDraftPreview(); const draftPreview = this.getDraftPreview();
const draftText = dropNull(this.get('draft')); const draftText = dropNull(this.get('draft'));
const draftEditMessage = this.get('draftEditMessage');
const shouldShowDraft = Boolean( const shouldShowDraft = Boolean(
this.hasDraft() && draftTimestamp && draftTimestamp >= timestamp this.hasDraft() && draftTimestamp && draftTimestamp >= timestamp
); );
@ -1993,6 +1995,7 @@ export class ConversationModel extends window.Backbone
draftBodyRanges: this.getDraftBodyRanges(), draftBodyRanges: this.getDraftBodyRanges(),
draftPreview, draftPreview,
draftText, draftText,
draftEditMessage,
familyName: this.get('profileFamilyName'), familyName: this.get('profileFamilyName'),
firstName: this.get('profileName'), firstName: this.get('profileName'),
groupDescription: this.get('description'), groupDescription: this.get('description'),
@ -4008,8 +4011,8 @@ export class ConversationModel extends window.Backbone
): Promise< ): Promise<
Array<{ Array<{
contentType: MIMEType; contentType: MIMEType;
fileName: string | null; fileName?: string | null;
thumbnail: ThumbnailType | null; thumbnail?: ThumbnailType | null;
}> }>
> { > {
return getQuoteAttachment(attachments, preview, sticker); 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( async enqueueMessageForSend(
{ {
attachments, attachments,
@ -4117,14 +4199,14 @@ export class ConversationModel extends window.Backbone
}: { }: {
attachments: Array<AttachmentType>; attachments: Array<AttachmentType>;
body: string | undefined; body: string | undefined;
contact?: Array<ContactWithHydratedAvatar>; contact?: Array<EmbeddedContactWithHydratedAvatar>;
bodyRanges?: DraftBodyRanges; bodyRanges?: DraftBodyRanges;
preview?: Array<LinkPreviewType>; preview?: Array<LinkPreviewWithHydratedData>;
quote?: QuotedMessageType; quote?: QuotedMessageType;
sticker?: StickerWithHydratedData; sticker?: StickerWithHydratedData;
}, },
{ {
dontClearDraft, dontClearDraft = false,
sendHQImages, sendHQImages,
storyId, storyId,
timestamp, timestamp,
@ -4156,10 +4238,6 @@ export class ConversationModel extends window.Backbone
this.clearTypingTimers(); this.clearTypingTimers();
const mandatoryProfileSharingEnabled = window.Signal.RemoteConfig.isEnabled(
'desktop.mandatoryProfileSharing'
);
let expirationStartTimestamp: number | undefined; let expirationStartTimestamp: number | undefined;
let expireTimer: DurationInSeconds | undefined; let expireTimer: DurationInSeconds | undefined;
@ -4231,7 +4309,24 @@ export class ConversationModel extends window.Backbone
const model = new window.Whisper.Message(attributes); const model = new window.Whisper.Message(attributes);
const message = window.MessageController.register(model.id, model); const message = window.MessageController.register(model.id, model);
message.cachedOutgoingContactData = contact; 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.cachedOutgoingQuoteData = quote;
message.cachedOutgoingStickerData = sticker; message.cachedOutgoingStickerData = sticker;
@ -4278,53 +4373,12 @@ export class ConversationModel extends window.Backbone
await addStickerPackReference(model.id, sticker.packId); await addStickerPackReference(model.id, sticker.packId);
} }
this.isInReduxBatch = true; this.beforeMessageSend({
batchDispatch(() => { message: model,
try { dontClearDraft,
const { clearUnreadMetrics } = window.reduxActions.conversations; dontAddMessage: false,
clearUnreadMetrics(this.id); now,
extraReduxActions,
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;
}
}); });
const renderDuration = Date.now() - renderStart; const renderDuration = Date.now() - renderStart;

View file

@ -55,10 +55,7 @@ import * as reactionUtil from '../reactions/util';
import * as Stickers from '../types/Stickers'; import * as Stickers from '../types/Stickers';
import * as Errors from '../types/errors'; import * as Errors from '../types/errors';
import * as EmbeddedContact from '../types/EmbeddedContact'; import * as EmbeddedContact from '../types/EmbeddedContact';
import type { import type { AttachmentType } from '../types/Attachment';
AttachmentType,
AttachmentWithHydratedData,
} from '../types/Attachment';
import { isImage, isVideo } from '../types/Attachment'; import { isImage, isVideo } from '../types/Attachment';
import * as Attachment from '../types/Attachment'; import * as Attachment from '../types/Attachment';
import { stringToMIMEType } from '../types/MIME'; import { stringToMIMEType } from '../types/MIME';
@ -138,9 +135,11 @@ import {
conversationQueueJobEnum, conversationQueueJobEnum,
} from '../jobs/conversationJobQueue'; } from '../jobs/conversationJobQueue';
import { notificationService } from '../services/notifications'; 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 log from '../logging/log';
import * as Bytes from '../Bytes';
import { cleanupMessage, deleteMessageData } from '../util/cleanup'; import { cleanupMessage, deleteMessageData } from '../util/cleanup';
import { import {
getContact, getContact,
@ -162,7 +161,7 @@ import type { ConversationQueueJobData } from '../jobs/conversationJobQueue';
import { getMessageById } from '../messages/getMessageById'; import { getMessageById } from '../messages/getMessageById';
import { shouldDownloadStory } from '../util/shouldDownloadStory'; import { shouldDownloadStory } from '../util/shouldDownloadStory';
import { shouldShowStoriesView } from '../state/selectors/stories'; import { shouldShowStoriesView } from '../state/selectors/stories';
import type { ContactWithHydratedAvatar } from '../textsecure/SendMessage'; import type { EmbeddedContactWithHydratedAvatar } from '../types/EmbeddedContact';
import { SeenStatus } from '../MessageSeenStatus'; import { SeenStatus } from '../MessageSeenStatus';
import { isNewReactionReplacingPrevious } from '../reactions/util'; import { isNewReactionReplacingPrevious } from '../reactions/util';
import { parseBoostBadgeListFromServer } from '../badges/parseBadgesFromServer'; import { parseBoostBadgeListFromServer } from '../badges/parseBadgesFromServer';
@ -198,15 +197,6 @@ const { upgradeMessageSchema } = window.Signal.Migrations;
const { getMessageBySender } = window.Signal.Data; const { getMessageBySender } = window.Signal.Data;
export class MessageModel extends window.Backbone.Model<MessageAttributesType> { export class MessageModel extends window.Backbone.Model<MessageAttributesType> {
static getLongMessageAttachment: (opts: {
attachments: Array<AttachmentWithHydratedData>;
body?: string;
now: number;
}) => {
body?: string;
attachments: Array<AttachmentWithHydratedData>;
};
CURRENT_PROTOCOL_VERSION?: number; CURRENT_PROTOCOL_VERSION?: number;
// Set when sending some sync messages, so we get the functionality of // Set when sending some sync messages, so we get the functionality of
@ -226,9 +216,9 @@ export class MessageModel extends window.Backbone.Model<MessageAttributesType> {
syncPromise?: Promise<CallbackResultType | void>; syncPromise?: Promise<CallbackResultType | void>;
cachedOutgoingContactData?: Array<ContactWithHydratedAvatar>; cachedOutgoingContactData?: Array<EmbeddedContactWithHydratedAvatar>;
cachedOutgoingPreviewData?: Array<LinkPreviewType>; cachedOutgoingPreviewData?: Array<LinkPreviewWithHydratedData>;
cachedOutgoingQuoteData?: QuotedMessageType; cachedOutgoingQuoteData?: QuotedMessageType;
@ -1075,14 +1065,25 @@ export class MessageModel extends window.Backbone.Model<MessageAttributesType> {
const inMemoryMessages = window.MessageController.filterBySentAt( const inMemoryMessages = window.MessageController.filterBySentAt(
Number(sentAt) Number(sentAt)
); );
const matchingMessage = find(inMemoryMessages, message => let matchingMessage = find(inMemoryMessages, message =>
isQuoteAMatch(message.attributes, this.get('conversationId'), quote) 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) { if (!matchingMessage) {
log.info( log.info(
`doubleCheckMissingQuoteReference/${logId}: No match for ${sentAt}.` `doubleCheckMissingQuoteReference/${logId}: No match for ${sentAt}.`
); );
return; return;
} }
@ -1500,6 +1501,8 @@ export class MessageModel extends window.Backbone.Model<MessageAttributesType> {
// This is used by sendSyncMessage, then set to null // This is used by sendSyncMessage, then set to null
if ('dataMessage' in result.value && result.value.dataMessage) { if ('dataMessage' in result.value && result.value.dataMessage) {
attributesToUpdate.dataMessage = 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) { if (!this.doNotSave) {
@ -1683,6 +1686,7 @@ export class MessageModel extends window.Backbone.Model<MessageAttributesType> {
const isTotalSuccess: boolean = const isTotalSuccess: boolean =
result.success && !this.get('errors')?.length; result.success && !this.get('errors')?.length;
if (isTotalSuccess) { if (isTotalSuccess) {
delete this.cachedOutgoingContactData;
delete this.cachedOutgoingPreviewData; delete this.cachedOutgoingPreviewData;
delete this.cachedOutgoingQuoteData; delete this.cachedOutgoingQuoteData;
delete this.cachedOutgoingStickerData; delete this.cachedOutgoingStickerData;
@ -1797,10 +1801,18 @@ export class MessageModel extends window.Backbone.Model<MessageAttributesType> {
map(conversationsWithSealedSender, c => c.id) 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( return handleMessageSend(
messaging.sendSyncMessage({ messaging.sendSyncMessage({
encodedDataMessage: dataMessage, encodedDataMessage: dataMessage,
timestamp: this.get('sent_at'), editedMessageTimestamp: isEditedMessage
? mainMessageTimestamp
: undefined,
timestamp,
destination: conv.get('e164'), destination: conv.get('e164'),
destinationUuid: conv.get('uuid'), destinationUuid: conv.get('uuid'),
expirationStartTimestamp: expirationStartTimestamp:
@ -1970,8 +1982,7 @@ export class MessageModel extends window.Backbone.Model<MessageAttributesType> {
queryMessage = matchingMessage; queryMessage = matchingMessage;
} else { } else {
log.info('copyFromQuotedMessage: db lookup needed', id); log.info('copyFromQuotedMessage: db lookup needed', id);
const messages = const messages = await window.Signal.Data.getMessagesBySentAt(id);
await window.Signal.Data.getMessagesIncludingEditedBySentAt(id);
const found = messages.find(item => const found = messages.find(item =>
isQuoteAMatch(item, conversationId, result) isQuoteAMatch(item, conversationId, result)
); );
@ -3090,9 +3101,16 @@ export class MessageModel extends window.Backbone.Model<MessageAttributesType> {
// We want to make sure the message is saved first before applying any edits // We want to make sure the message is saved first before applying any edits
if (!isFirstRun) { if (!isFirstRun) {
const edits = Edits.forMessage(message); const edits = Edits.forMessage(message);
log.info(
`modifyTargetMessage/${this.idForLogging()}: ${
edits.length
} edits in second run`
);
await Promise.all( await Promise.all(
edits.map(editAttributes => 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<MessageAttributesType> {
window.Whisper.Message = MessageModel; 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({ window.Whisper.MessageCollection = window.Backbone.Collection.extend({
model: window.Whisper.Message, model: window.Whisper.Message,
comparator(left: Readonly<MessageModel>, right: Readonly<MessageModel>) { comparator(left: Readonly<MessageModel>, right: Readonly<MessageModel>) {

View file

@ -3,7 +3,7 @@
import { debounce, omit } from 'lodash'; import { debounce, omit } from 'lodash';
import type { LinkPreviewType } from '../types/message/LinkPreviews'; import type { LinkPreviewWithHydratedData } from '../types/message/LinkPreviews';
import type { import type {
LinkPreviewImage, LinkPreviewImage,
LinkPreviewResult, LinkPreviewResult,
@ -237,7 +237,9 @@ export async function addLinkPreview(
} }
} }
export function getLinkPreviewForSend(message: string): Array<LinkPreviewType> { export function getLinkPreviewForSend(
message: string
): Array<LinkPreviewWithHydratedData> {
// Don't generate link previews if user has turned them off // Don't generate link previews if user has turned them off
if (!window.storage.get('linkPreviews', false)) { if (!window.storage.get('linkPreviews', false)) {
return []; return [];
@ -260,8 +262,8 @@ export function getLinkPreviewForSend(message: string): Array<LinkPreviewType> {
} }
export function sanitizeLinkPreview( export function sanitizeLinkPreview(
item: LinkPreviewResult | LinkPreviewType item: LinkPreviewResult | LinkPreviewWithHydratedData
): LinkPreviewType { ): LinkPreviewWithHydratedData {
if (item.image) { if (item.image) {
// We eliminate the ObjectURL here, unneeded for send or save // We eliminate the ObjectURL here, unneeded for send or save
return { return {

View file

@ -42,9 +42,14 @@ import type {
} from './types/Attachment'; } from './types/Attachment';
import type { MessageAttributesType, QuotedMessageType } from './model-types.d'; import type { MessageAttributesType, QuotedMessageType } from './model-types.d';
import type { SignalCoreType } from './window.d'; import type { SignalCoreType } from './window.d';
import type { EmbeddedContactType } from './types/EmbeddedContact'; import type {
import type { ContactWithHydratedAvatar } from './textsecure/SendMessage'; EmbeddedContactType,
import type { LinkPreviewType } from './types/message/LinkPreviews'; EmbeddedContactWithHydratedAvatar,
} from './types/EmbeddedContact';
import type {
LinkPreviewType,
LinkPreviewWithHydratedData,
} from './types/message/LinkPreviews';
import type { StickerType, StickerWithHydratedData } from './types/Stickers'; import type { StickerType, StickerWithHydratedData } from './types/Stickers';
type MigrationsModuleType = { type MigrationsModuleType = {
@ -75,13 +80,13 @@ type MigrationsModuleType = {
) => Promise<AttachmentWithHydratedData>; ) => Promise<AttachmentWithHydratedData>;
loadContactData: ( loadContactData: (
contact: Array<EmbeddedContactType> | undefined contact: Array<EmbeddedContactType> | undefined
) => Promise<Array<ContactWithHydratedAvatar> | undefined>; ) => Promise<Array<EmbeddedContactWithHydratedAvatar> | undefined>;
loadMessage: ( loadMessage: (
message: MessageAttributesType message: MessageAttributesType
) => Promise<MessageAttributesType>; ) => Promise<MessageAttributesType>;
loadPreviewData: ( loadPreviewData: (
preview: Array<LinkPreviewType> | undefined preview: Array<LinkPreviewType> | undefined
) => Promise<Array<LinkPreviewType>>; ) => Promise<Array<LinkPreviewWithHydratedData>>;
loadQuoteData: ( loadQuoteData: (
quote: QuotedMessageType | null | undefined quote: QuotedMessageType | null | undefined
) => Promise<QuotedMessageType | null>; ) => Promise<QuotedMessageType | null>;

View file

@ -561,9 +561,6 @@ export type DataInterface = {
_removeAllMessages: () => Promise<void>; _removeAllMessages: () => Promise<void>;
getAllMessageIds: () => Promise<Array<string>>; getAllMessageIds: () => Promise<Array<string>>;
getMessagesBySentAt: (sentAt: number) => Promise<Array<MessageType>>; getMessagesBySentAt: (sentAt: number) => Promise<Array<MessageType>>;
getMessagesIncludingEditedBySentAt: (
sentAt: number
) => Promise<Array<MessageType>>;
getExpiredMessages: () => Promise<Array<MessageType>>; getExpiredMessages: () => Promise<Array<MessageType>>;
getMessagesUnexpectedlyMissingExpirationStartTimestamp: () => Promise< getMessagesUnexpectedlyMissingExpirationStartTimestamp: () => Promise<
Array<MessageType> Array<MessageType>

View file

@ -257,7 +257,6 @@ const dataInterface: ServerInterface = {
_removeAllMessages, _removeAllMessages,
getAllMessageIds, getAllMessageIds,
getMessagesBySentAt, getMessagesBySentAt,
getMessagesIncludingEditedBySentAt,
getUnreadEditedMessagesAndMarkRead, getUnreadEditedMessagesAndMarkRead,
getExpiredMessages, getExpiredMessages,
getMessagesUnexpectedlyMissingExpirationStartTimestamp, getMessagesUnexpectedlyMissingExpirationStartTimestamp,
@ -3136,17 +3135,19 @@ async function getMessagesBySentAt(
sentAt: number sentAt: number
): Promise<Array<MessageType>> { ): Promise<Array<MessageType>> {
const db = getInstance(); const db = getInstance();
const rows: JSONRows = db
.prepare<Query>( const [query, params] = sql`
` SELECT messages.json, received_at, sent_at FROM edited_messages
SELECT json FROM messages INNER JOIN messages ON
WHERE sent_at = $sent_at messages.id = edited_messages.messageId
ORDER BY received_at DESC, sent_at DESC; WHERE edited_messages.sentAt = ${sentAt}
` UNION
) SELECT json, received_at, sent_at FROM messages
.all({ WHERE sent_at = ${sentAt}
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)); return rows.map(row => jsonToObject(row.json));
} }
@ -5718,27 +5719,6 @@ async function saveEditedMessage(
})(); })();
} }
async function getMessagesIncludingEditedBySentAt(
sentAt: number
): Promise<Array<MessageType>> {
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< async function _getAllEditedMessages(): Promise<
Array<{ messageId: string; sentAt: number }> Array<{ messageId: string; sentAt: number }>
> { > {

View file

@ -4,7 +4,7 @@
import path from 'path'; import path from 'path';
import { debounce, isEqual } from 'lodash'; 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 { ReadonlyDeep } from 'type-fest';
import type { import type {
@ -87,6 +87,7 @@ import { longRunningTaskWrapper } from '../../util/longRunningTaskWrapper';
import { drop } from '../../util/drop'; import { drop } from '../../util/drop';
import { strictAssert } from '../../util/assert'; import { strictAssert } from '../../util/assert';
import { makeQuote } from '../../util/makeQuote'; import { makeQuote } from '../../util/makeQuote';
import { sendEditedMessage as doSendEditedMessage } from '../../util/sendEditedMessage';
import { maybeBlockSendForFormattingModal } from '../../util/maybeBlockSendForFormattingModal'; import { maybeBlockSendForFormattingModal } from '../../util/maybeBlockSendForFormattingModal';
// State // State
@ -138,7 +139,7 @@ const ADD_PENDING_ATTACHMENT = 'composer/ADD_PENDING_ATTACHMENT';
const INCREMENT_SEND_COUNTER = 'composer/INCREMENT_SEND_COUNTER'; const INCREMENT_SEND_COUNTER = 'composer/INCREMENT_SEND_COUNTER';
const REPLACE_ATTACHMENTS = 'composer/REPLACE_ATTACHMENTS'; const REPLACE_ATTACHMENTS = 'composer/REPLACE_ATTACHMENTS';
const RESET_COMPOSER = 'composer/RESET_COMPOSER'; 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_HIGH_QUALITY_SETTING = 'composer/SET_HIGH_QUALITY_SETTING';
const SET_QUOTED_MESSAGE = 'composer/SET_QUOTED_MESSAGE'; const SET_QUOTED_MESSAGE = 'composer/SET_QUOTED_MESSAGE';
const SET_COMPOSER_DISABLED = 'composer/SET_COMPOSER_DISABLED'; const SET_COMPOSER_DISABLED = 'composer/SET_COMPOSER_DISABLED';
@ -238,6 +239,7 @@ export const actions = {
replaceAttachments, replaceAttachments,
resetComposer, resetComposer,
scrollToQuotedMessage, scrollToQuotedMessage,
sendEditedMessage,
sendMultiMediaMessage, sendMultiMediaMessage,
sendStickerMessage, sendStickerMessage,
setComposerDisabledState, setComposerDisabledState,
@ -304,6 +306,7 @@ function onCloseLinkPreview(conversationId: string): NoopActionType {
payload: null, payload: null,
}; };
} }
function onTextTooLong(): ShowToastActionType { function onTextTooLong(): ShowToastActionType {
return { return {
type: SHOW_TOAST, 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<void>
): Promise<void> {
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( function sendMultiMediaMessage(
conversationId: string, conversationId: string,
options: { options: WithPreSendChecksOptions & {
draftAttachments?: ReadonlyArray<AttachmentDraftType>; draftAttachments?: ReadonlyArray<AttachmentDraftType>;
bodyRanges?: DraftBodyRanges;
message?: string;
timestamp?: number; timestamp?: number;
voiceNoteAttachment?: InMemoryAttachmentDraftType;
} }
): ThunkAction< ): ThunkAction<
void, void,
@ -413,73 +561,7 @@ function sendMultiMediaMessage(
const state = getState(); const state = getState();
const sendStart = Date.now(); await withPreSendChecks(conversationId, options, dispatch, async () => {
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 {
let attachments: Array<AttachmentType> = []; let attachments: Array<AttachmentType> = [];
if (voiceNoteAttachment) { if (voiceNoteAttachment) {
attachments = [voiceNoteAttachment]; attachments = [voiceNoteAttachment];
@ -505,48 +587,45 @@ function sendMultiMediaMessage(
? shouldSendHighQualityAttachments ? shouldSendHighQualityAttachments
: state.items['sent-media-quality'] === 'high'; : state.items['sent-media-quality'] === 'high';
const sendDelta = Date.now() - sendStart; try {
await conversation.enqueueMessageForSend(
log.info('Send pre-checks took', sendDelta, 'milliseconds'); {
body: message,
await conversation.enqueueMessageForSend( attachments,
{ quote,
body: message, preview: getLinkPreviewForSend(message),
attachments, bodyRanges,
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));
}, },
} {
); sendHQImages,
} catch (error) { timestamp,
log.error( // We rely on enqueueMessageForSend to call these within redux's batch
'Error pulling attached files before send', extraReduxActions: () => {
Errors.toLogFormat(error) conversation.setMarkedUnread(false);
); resetLinkPreview(conversationId);
dispatch(setComposerDisabledState(conversationId, false)); 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); window.Signal.Data.updateConversation(conversation.attributes);
} }
const draftEditMessage = conversation.get('draftEditMessage');
if (message) { if (message) {
const quote = await makeQuote(message.attributes); const quote = await makeQuote(message.attributes);
@ -676,15 +756,31 @@ export function setQuoteByMessageId(
return; return;
} }
dispatch( if (draftEditMessage) {
setQuotedMessage(conversationId, { conversation.set({
conversationId, draftEditMessage: {
quote, ...draftEditMessage,
}) quote,
); },
});
} else {
dispatch(
setQuotedMessage(conversationId, {
conversationId,
quote,
})
);
}
dispatch(setComposerFocus(conversation.id)); dispatch(setComposerFocus(conversation.id));
dispatch(setComposerDisabledState(conversationId, false)); dispatch(setComposerDisabledState(conversationId, false));
} else if (draftEditMessage) {
conversation.set({
draftEditMessage: {
...draftEditMessage,
quote: undefined,
},
});
} else { } else {
dispatch(setQuotedMessage(conversationId, undefined)); dispatch(setQuotedMessage(conversationId, undefined));
} }

View file

@ -51,8 +51,9 @@ import type {
CustomColorType, CustomColorType,
} from '../../types/Colors'; } from '../../types/Colors';
import type { import type {
LastMessageStatus,
ConversationAttributesType, ConversationAttributesType,
DraftEditMessageType,
LastMessageStatus,
MessageAttributesType, MessageAttributesType,
} from '../../model-types.d'; } from '../../model-types.d';
import type { import type {
@ -76,6 +77,7 @@ import { writeProfile } from '../../services/writeProfile';
import { import {
getConversationUuidsStoppingSend, getConversationUuidsStoppingSend,
getConversationIdsStoppedForVerification, getConversationIdsStoppedForVerification,
getConversationSelector,
getMe, getMe,
getMessagesByConversation, getMessagesByConversation,
} from '../selectors/conversations'; } from '../selectors/conversations';
@ -108,7 +110,11 @@ import {
import { missingCaseError } from '../../util/missingCaseError'; import { missingCaseError } from '../../util/missingCaseError';
import { viewSyncJobQueue } from '../../jobs/viewSyncJobQueue'; import { viewSyncJobQueue } from '../../jobs/viewSyncJobQueue';
import { ReadStatus } from '../../messages/MessageReadStatus'; import { ReadStatus } from '../../messages/MessageReadStatus';
import { isIncoming, isOutgoing } from '../selectors/message'; import {
isIncoming,
isOutgoing,
processBodyRanges,
} from '../selectors/message';
import { getActiveCallState } from '../selectors/calling'; import { getActiveCallState } from '../selectors/calling';
import { sendDeleteForEveryoneMessage } from '../../util/sendDeleteForEveryoneMessage'; import { sendDeleteForEveryoneMessage } from '../../util/sendDeleteForEveryoneMessage';
import type { ShowToastActionType } from './toast'; import type { ShowToastActionType } from './toast';
@ -144,6 +150,7 @@ import type {
SetQuotedMessageActionType, SetQuotedMessageActionType,
} from './composer'; } from './composer';
import { import {
SET_FOCUS,
replaceAttachments, replaceAttachments,
setComposerFocus, setComposerFocus,
setQuoteByMessageId, setQuoteByMessageId,
@ -288,6 +295,7 @@ export type ConversationType = ReadonlyDeep<
shouldShowDraft?: boolean; shouldShowDraft?: boolean;
// Full information for re-hydrating composition area // Full information for re-hydrating composition area
draftText?: string; draftText?: string;
draftEditMessage?: DraftEditMessageType;
draftBodyRanges?: DraftBodyRanges; draftBodyRanges?: DraftBodyRanges;
// Summary for the left pane // Summary for the left pane
draftPreview?: DraftPreviewType; draftPreview?: DraftPreviewType;
@ -1003,6 +1011,7 @@ export const actions = {
deleteMessages, deleteMessages,
deleteMessagesForEveryone, deleteMessagesForEveryone,
destroyMessages, destroyMessages,
discardEditMessage,
discardMessages, discardMessages,
doubleCheckMissingQuoteReference, doubleCheckMissingQuoteReference,
generateNewGroupLink, generateNewGroupLink,
@ -1063,6 +1072,7 @@ export const actions = {
setIsFetchingUUID, setIsFetchingUUID,
setIsNearBottom, setIsNearBottom,
setMessageLoadingState, setMessageLoadingState,
setMessageToEdit,
setMuteExpiration, setMuteExpiration,
setPinned, setPinned,
setPreJoinConversation, setPreJoinConversation,
@ -1717,6 +1727,73 @@ function destroyMessages(
}; };
} }
function discardEditMessage(
conversationId: string
): ThunkAction<void, RootStateType, unknown, never> {
return () => {
window.ConversationController.get(conversationId)?.set(
{
draftEditMessage: undefined,
draftBodyRanges: undefined,
draft: undefined,
quotedMessageId: undefined,
},
{ unset: true }
);
};
}
function setMessageToEdit(
conversationId: string,
messageId: string
): ThunkAction<void, RootStateType, unknown, SetFocusActionType> {
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( function generateNewGroupLink(
conversationId: string conversationId: string
): ThunkAction<void, RootStateType, unknown, NoopActionType> { ): ThunkAction<void, RootStateType, unknown, NoopActionType> {

View file

@ -718,6 +718,10 @@ function copyOverMessageAttributesIntoEditHistory(
return messageAttributes.editHistory.map(editedMessageAttributes => ({ return messageAttributes.editHistory.map(editedMessageAttributes => ({
...messageAttributes, ...messageAttributes,
// Always take attachments from the edited message (they might be absent)
attachments: undefined,
quote: undefined,
preview: [],
...editedMessageAttributes, ...editedMessageAttributes,
// For timestamp uniqueness of messages // For timestamp uniqueness of messages
sent_at: editedMessageAttributes.timestamp, sent_at: editedMessageAttributes.timestamp,

View file

@ -7,6 +7,8 @@ import filesize from 'filesize';
import getDirection from 'direction'; import getDirection from 'direction';
import emojiRegex from 'emoji-regex'; import emojiRegex from 'emoji-regex';
import LinkifyIt from 'linkify-it'; import LinkifyIt from 'linkify-it';
import type { ReadonlyDeep } from 'type-fest';
import type { StateType } from '../reducer'; import type { StateType } from '../reducer';
import type { import type {
LastMessageStatus, LastMessageStatus,
@ -66,6 +68,7 @@ import { isNotNil } from '../../util/isNotNil';
import { isMoreRecentThan } from '../../util/timestamp'; import { isMoreRecentThan } from '../../util/timestamp';
import * as iterables from '../../util/iterables'; import * as iterables from '../../util/iterables';
import { strictAssert } from '../../util/assert'; import { strictAssert } from '../../util/assert';
import { canEditMessages } from '../../util/canEditMessages';
import { getAccountSelector } from './accounts'; import { getAccountSelector } from './accounts';
import { import {
@ -127,6 +130,7 @@ import { getTitleNoDefault, getNumber } from '../../util/getTitle';
export { isIncoming, isOutgoing, isStory }; export { isIncoming, isOutgoing, isStory };
const MAX_EDIT_COUNT = 10;
const THREE_HOURS = 3 * HOUR; const THREE_HOURS = 3 * HOUR;
const linkify = LinkifyIt(); const linkify = LinkifyIt();
@ -502,9 +506,8 @@ const getPropsForStoryReplyContext = (
}; };
export const getPropsForQuote = ( export const getPropsForQuote = (
message: Pick< message: ReadonlyDeep<
MessageWithUIFieldsType, Pick<MessageWithUIFieldsType, 'conversationId' | 'quote'>
'conversationId' | 'quote' | 'payment'
>, >,
{ {
conversationSelector, conversationSelector,
@ -717,6 +720,7 @@ export const getPropsForMessage = (
storyReplyContext, storyReplyContext,
textAttachment, textAttachment,
payment, payment,
canEditMessage: canEditMessage(message),
canDeleteForEveryone: canDeleteForEveryone(message), canDeleteForEveryone: canDeleteForEveryone(message),
canDownload: canDownload(message, conversationSelector), canDownload: canDownload(message, conversationSelector),
canReact: canReact(message, ourConversationId, 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( export function canDownload(
message: MessageWithUIFieldsType, message: MessageWithUIFieldsType,
conversationSelector: GetConversationByIdType conversationSelector: GetConversationByIdType

View file

@ -4,6 +4,7 @@
import React from 'react'; import React from 'react';
import { connect } from 'react-redux'; import { connect } from 'react-redux';
import { get } from 'lodash'; import { get } from 'lodash';
import { mapDispatchToProps } from '../actions'; import { mapDispatchToProps } from '../actions';
import type { Props as ComponentPropsType } from '../../components/CompositionArea'; import type { Props as ComponentPropsType } from '../../components/CompositionArea';
import { CompositionArea } 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!`); throw new Error(`Conversation id ${id} not found!`);
} }
const { announcementsOnly, areWeAdmin, draftText, draftBodyRanges } = const {
conversation; announcementsOnly,
areWeAdmin,
draftEditMessage,
draftText,
draftBodyRanges,
} = conversation;
const receivedPacks = getReceivedStickerPacks(state); const receivedPacks = getReceivedStickerPacks(state);
const installedPacks = getInstalledStickerPacks(state); const installedPacks = getInstalledStickerPacks(state);
@ -82,6 +88,7 @@ const mapStateToProps = (state: StateType, props: ExternalProps) => {
const composerStateForConversationIdSelector = const composerStateForConversationIdSelector =
getComposerStateForConversationIdSelector(state); getComposerStateForConversationIdSelector(state);
const composerState = composerStateForConversationIdSelector(id);
const { const {
attachments: draftAttachments, attachments: draftAttachments,
focusCounter, focusCounter,
@ -89,10 +96,17 @@ const mapStateToProps = (state: StateType, props: ExternalProps) => {
linkPreviewLoading, linkPreviewLoading,
linkPreviewResult, linkPreviewResult,
messageCompositionId, messageCompositionId,
quotedMessage,
sendCounter, sendCounter,
shouldSendHighQualityAttachments, shouldSendHighQualityAttachments,
} = composerStateForConversationIdSelector(id); } = composerState;
let { quotedMessage } = composerState;
if (!quotedMessage && draftEditMessage?.quote) {
quotedMessage = {
conversationId: id,
quote: draftEditMessage.quote,
};
}
const recentEmojis = selectRecentEmojis(state); const recentEmojis = selectRecentEmojis(state);
@ -107,6 +121,7 @@ const mapStateToProps = (state: StateType, props: ExternalProps) => {
return { return {
// Base // Base
conversationId: id, conversationId: id,
draftEditMessage,
focusCounter, focusCounter,
getPreferredBadge: getPreferredBadgeSelector(state), getPreferredBadge: getPreferredBadgeSelector(state),
i18n: getIntl(state), i18n: getIntl(state),
@ -141,6 +156,8 @@ const mapStateToProps = (state: StateType, props: ExternalProps) => {
ourConversationId: getUserConversationId(state), ourConversationId: getUserConversationId(state),
}) })
: undefined, : undefined,
quotedMessageAuthorUuid: quotedMessage?.quote?.authorUuid,
quotedMessageSentAt: quotedMessage?.quote?.id,
// Emojis // Emojis
recentEmojis, recentEmojis,
skinTone: getEmojiSkinTone(state), skinTone: getEmojiSkinTone(state),

View file

@ -44,6 +44,7 @@ export function SmartMessageDetail(): JSX.Element | null {
markAttachmentAsCorrupted, markAttachmentAsCorrupted,
messageExpanded, messageExpanded,
openGiftBadge, openGiftBadge,
retryMessageSend,
popPanelForConversation, popPanelForConversation,
pushPanelForConversation, pushPanelForConversation,
saveAttachment, saveAttachment,
@ -91,6 +92,7 @@ export function SmartMessageDetail(): JSX.Element | null {
message={message} message={message}
messageExpanded={messageExpanded} messageExpanded={messageExpanded}
openGiftBadge={openGiftBadge} openGiftBadge={openGiftBadge}
retryMessageSend={retryMessageSend}
pushPanelForConversation={pushPanelForConversation} pushPanelForConversation={pushPanelForConversation}
receivedAt={receivedAt} receivedAt={receivedAt}
renderAudioAttachment={renderAudioAttachment} renderAudioAttachment={renderAudioAttachment}

View file

@ -123,6 +123,7 @@ export function SmartTimelineItem(props: ExternalProps): JSX.Element {
saveAttachment, saveAttachment,
targetMessage, targetMessage,
toggleSelectMessage, toggleSelectMessage,
setMessageToEdit,
showConversation, showConversation,
showExpiredIncomingTapToViewToast, showExpiredIncomingTapToViewToast,
showExpiredOutgoingTapToViewToast, showExpiredOutgoingTapToViewToast,
@ -190,6 +191,7 @@ export function SmartTimelineItem(props: ExternalProps): JSX.Element {
scrollToQuotedMessage={scrollToQuotedMessage} scrollToQuotedMessage={scrollToQuotedMessage}
targetMessage={targetMessage} targetMessage={targetMessage}
setQuoteByMessageId={setQuoteByMessageId} setQuoteByMessageId={setQuoteByMessageId}
setMessageToEdit={setMessageToEdit}
showContactModal={showContactModal} showContactModal={showContactModal}
showConversation={showConversation} showConversation={showConversation}
showExpiredIncomingTapToViewToast={showExpiredIncomingTapToViewToast} showExpiredIncomingTapToViewToast={showExpiredIncomingTapToViewToast}

View file

@ -11,6 +11,7 @@ import {
decryptProfileName, decryptProfileName,
encryptProfile, encryptProfile,
decryptProfile, decryptProfile,
getAttachmentSizeBucket,
getRandomBytes, getRandomBytes,
constantTimeEqual, constantTimeEqual,
generateRegistrationId, generateRegistrationId,
@ -30,6 +31,36 @@ import {
bytesToUuid, bytesToUuid,
} from '../Crypto'; } 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('Crypto', () => {
describe('encrypting and decrypting profile data', () => { describe('encrypting and decrypting profile data', () => {
const NAME_PADDED_LENGTH = 53; const NAME_PADDED_LENGTH = 53;
@ -507,4 +538,53 @@ describe('Crypto', () => {
assert.isUndefined(bytesToUuid(new Uint8Array(Array(17).fill(0x22)))); 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<string>();
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'));
});
});
}); });

View file

@ -918,6 +918,7 @@ describe('both/state/ducks/stories', () => {
attachments: [ attachments: [
{ {
contentType: IMAGE_JPEG, contentType: IMAGE_JPEG,
digest: 'digest',
size: 0, size: 0,
}, },
], ],
@ -961,6 +962,7 @@ describe('both/state/ducks/stories', () => {
url: 'https://signal.org', url: 'https://signal.org',
image: { image: {
contentType: IMAGE_JPEG, contentType: IMAGE_JPEG,
digest: 'digest-1',
size: 0, size: 0,
}, },
}; };
@ -969,6 +971,7 @@ describe('both/state/ducks/stories', () => {
attachments: [ attachments: [
{ {
contentType: TEXT_ATTACHMENT, contentType: TEXT_ATTACHMENT,
digest: 'digest-2',
size: 0, size: 0,
textAttachment: { textAttachment: {
preview, preview,

View file

@ -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<string>();
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'));
});
});
});

View file

@ -204,15 +204,20 @@ export default class OutgoingMessage {
const contentProto = this.getContentProtoBytes(); const contentProto = this.getContentProtoBytes();
const { timestamp, contentHint, recipients, urgent } = this; const { timestamp, contentHint, recipients, urgent } = this;
let dataMessage: Uint8Array | undefined; let dataMessage: Uint8Array | undefined;
let editMessage: Uint8Array | undefined;
let hasPniSignatureMessage = false; let hasPniSignatureMessage = false;
if (proto instanceof Proto.Content) { if (proto instanceof Proto.Content) {
if (proto.dataMessage) { if (proto.dataMessage) {
dataMessage = Proto.DataMessage.encode(proto.dataMessage).finish(); dataMessage = Proto.DataMessage.encode(proto.dataMessage).finish();
} else if (proto.editMessage) {
editMessage = Proto.EditMessage.encode(proto.editMessage).finish();
} }
hasPniSignatureMessage = Boolean(proto.pniSignatureMessage); hasPniSignatureMessage = Boolean(proto.pniSignatureMessage);
} else if (proto instanceof Proto.DataMessage) { } else if (proto instanceof Proto.DataMessage) {
dataMessage = Proto.DataMessage.encode(proto).finish(); dataMessage = Proto.DataMessage.encode(proto).finish();
} else if (proto instanceof Proto.EditMessage) {
editMessage = Proto.EditMessage.encode(proto).finish();
} }
this.callback({ this.callback({
@ -223,6 +228,7 @@ export default class OutgoingMessage {
contentHint, contentHint,
dataMessage, dataMessage,
editMessage,
recipients, recipients,
contentProto, contentProto,
timestamp, timestamp,

View file

@ -13,7 +13,6 @@ import {
SenderKeyDistributionMessage, SenderKeyDistributionMessage,
} from '@signalapp/libsignal-client'; } from '@signalapp/libsignal-client';
import type { QuotedMessageType } from '../model-types.d';
import type { ConversationModel } from '../models/conversations'; import type { ConversationModel } from '../models/conversations';
import { GLOBAL_ZONE } from '../SignalProtocolStore'; import { GLOBAL_ZONE } from '../SignalProtocolStore';
import { assertDev, strictAssert } from '../util/assert'; import { assertDev, strictAssert } from '../util/assert';
@ -21,9 +20,10 @@ import { parseIntOrThrow } from '../util/parseIntOrThrow';
import { Address } from '../types/Address'; import { Address } from '../types/Address';
import { QualifiedAddress } from '../types/QualifiedAddress'; import { QualifiedAddress } from '../types/QualifiedAddress';
import { SenderKeys } from '../LibSignalStores'; import { SenderKeys } from '../LibSignalStores';
import type { LinkPreviewType } from '../types/message/LinkPreviews'; import type {
import { MIMETypeToString } from '../types/MIME'; TextAttachmentType,
import type * as Attachment from '../types/Attachment'; UploadedAttachmentType,
} from '../types/Attachment';
import type { UUID } from '../types/UUID'; import type { UUID } from '../types/UUID';
import type { import type {
ChallengeType, ChallengeType,
@ -49,7 +49,7 @@ import type {
} from './OutgoingMessage'; } from './OutgoingMessage';
import OutgoingMessage from './OutgoingMessage'; import OutgoingMessage from './OutgoingMessage';
import * as Bytes from '../Bytes'; import * as Bytes from '../Bytes';
import { getRandomBytes, getZeroes, encryptAttachment } from '../Crypto'; import { getRandomBytes } from '../Crypto';
import { import {
MessageError, MessageError,
SignedPreKeyRotationError, SignedPreKeyRotationError,
@ -57,8 +57,8 @@ import {
HTTPError, HTTPError,
NoSenderKeyError, NoSenderKeyError,
} from './Errors'; } from './Errors';
import type { RawBodyRange } from '../types/BodyRange';
import { BodyRange } from '../types/BodyRange'; import { BodyRange } from '../types/BodyRange';
import type { RawBodyRange } from '../types/BodyRange';
import type { StoryContextType } from '../types/Util'; import type { StoryContextType } from '../types/Util';
import type { import type {
LinkPreviewImage, LinkPreviewImage,
@ -71,13 +71,12 @@ import { uuidToBytes } from '../util/uuidToBytes';
import type { DurationInSeconds } from '../util/durations'; import type { DurationInSeconds } from '../util/durations';
import { SignalService as Proto } from '../protobuf'; import { SignalService as Proto } from '../protobuf';
import * as log from '../logging/log'; import * as log from '../logging/log';
import type { Avatar, EmbeddedContactType } from '../types/EmbeddedContact'; import type { EmbeddedContactWithUploadedAvatar } from '../types/EmbeddedContact';
import { import {
numberToPhoneType, numberToPhoneType,
numberToEmailType, numberToEmailType,
numberToAddressType, numberToAddressType,
} from '../types/EmbeddedContact'; } from '../types/EmbeddedContact';
import type { StickerWithHydratedData } from '../types/Stickers';
import { missingCaseError } from '../util/missingCaseError'; import { missingCaseError } from '../util/missingCaseError';
export type SendMetadataType = { export type SendMetadataType = {
@ -92,9 +91,33 @@ export type SendOptionsType = {
online?: boolean; online?: boolean;
}; };
type QuoteAttachmentType = { export type OutgoingQuoteAttachmentType = Readonly<{
thumbnail?: AttachmentType; contentType: string;
attachmentPointer?: Proto.IAttachmentPointer; fileName?: string;
thumbnail: UploadedAttachmentType;
}>;
export type OutgoingQuoteType = Readonly<{
isGiftBadge?: boolean;
id?: number;
authorUuid?: string;
text?: string;
attachments: ReadonlyArray<OutgoingQuoteAttachmentType>;
bodyRanges?: ReadonlyArray<RawBodyRange>;
}>;
export type OutgoingLinkPreviewType = Readonly<{
title?: string;
description?: string;
domain?: string;
url: string;
isStickerPack?: boolean;
image?: Readonly<UploadedAttachmentType>;
date?: number;
}>;
export type OutgoingTextAttachmentType = Omit<TextAttachmentType, 'preview'> & {
preview?: OutgoingLinkPreviewType;
}; };
export type GroupV2InfoType = { export type GroupV2InfoType = {
@ -108,9 +131,13 @@ type GroupCallUpdateType = {
eraId: string; eraId: string;
}; };
export type StickerType = StickerWithHydratedData & { export type OutgoingStickerType = Readonly<{
attachmentPointer?: Proto.IAttachmentPointer; packId: string;
}; packKey: string;
stickerId: number;
emoji?: string;
data: Readonly<UploadedAttachmentType>;
}>;
export type ReactionType = { export type ReactionType = {
emoji?: string; emoji?: string;
@ -119,22 +146,6 @@ export type ReactionType = {
targetTimestamp?: number; 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({ export const singleProtoJobDataSchema = z.object({
contentHint: z.number(), contentHint: z.number(),
identifier: z.string(), identifier: z.string(),
@ -147,35 +158,12 @@ export const singleProtoJobDataSchema = z.object({
export type SingleProtoJobData = z.infer<typeof singleProtoJobDataSchema>; export type SingleProtoJobData = z.infer<typeof singleProtoJobDataSchema>;
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 = { export type MessageOptionsType = {
attachments?: ReadonlyArray<AttachmentType> | null; attachments?: ReadonlyArray<UploadedAttachmentType>;
body?: string; body?: string;
bodyRanges?: ReadonlyArray<RawBodyRange>; bodyRanges?: ReadonlyArray<RawBodyRange>;
contact?: Array<ContactWithHydratedAvatar>; contact?: ReadonlyArray<EmbeddedContactWithUploadedAvatar>;
editedMessageTimestamp?: number;
expireTimer?: DurationInSeconds; expireTimer?: DurationInSeconds;
flags?: number; flags?: number;
group?: { group?: {
@ -184,11 +172,11 @@ export type MessageOptionsType = {
}; };
groupV2?: GroupV2InfoType; groupV2?: GroupV2InfoType;
needsSync?: boolean; needsSync?: boolean;
preview?: ReadonlyArray<LinkPreviewType>; preview?: ReadonlyArray<OutgoingLinkPreviewType>;
profileKey?: Uint8Array; profileKey?: Uint8Array;
quote?: QuotedMessageType | null; quote?: OutgoingQuoteType;
recipients: ReadonlyArray<string>; recipients: ReadonlyArray<string>;
sticker?: StickerWithHydratedData; sticker?: OutgoingStickerType;
reaction?: ReactionType; reaction?: ReactionType;
deletedForEveryoneTimestamp?: number; deletedForEveryoneTimestamp?: number;
timestamp: number; timestamp: number;
@ -196,32 +184,33 @@ export type MessageOptionsType = {
storyContext?: StoryContextType; storyContext?: StoryContextType;
}; };
export type GroupSendOptionsType = { export type GroupSendOptionsType = {
attachments?: Array<AttachmentType>; attachments?: ReadonlyArray<UploadedAttachmentType>;
bodyRanges?: ReadonlyArray<RawBodyRange>; bodyRanges?: ReadonlyArray<RawBodyRange>;
contact?: Array<ContactWithHydratedAvatar>; contact?: ReadonlyArray<EmbeddedContactWithUploadedAvatar>;
deletedForEveryoneTimestamp?: number; deletedForEveryoneTimestamp?: number;
editedMessageTimestamp?: number;
expireTimer?: DurationInSeconds; expireTimer?: DurationInSeconds;
flags?: number; flags?: number;
groupCallUpdate?: GroupCallUpdateType; groupCallUpdate?: GroupCallUpdateType;
groupV2?: GroupV2InfoType; groupV2?: GroupV2InfoType;
messageText?: string; messageText?: string;
preview?: ReadonlyArray<LinkPreviewType>; preview?: ReadonlyArray<OutgoingLinkPreviewType>;
profileKey?: Uint8Array; profileKey?: Uint8Array;
quote?: QuotedMessageType | null; quote?: OutgoingQuoteType;
reaction?: ReactionType; reaction?: ReactionType;
sticker?: StickerWithHydratedData; sticker?: OutgoingStickerType;
storyContext?: StoryContextType; storyContext?: StoryContextType;
timestamp: number; timestamp: number;
}; };
class Message { class Message {
attachments: ReadonlyArray<AttachmentType>; attachments: ReadonlyArray<UploadedAttachmentType>;
body?: string; body?: string;
bodyRanges?: ReadonlyArray<RawBodyRange>; bodyRanges?: ReadonlyArray<RawBodyRange>;
contact?: Array<ContactWithHydratedAvatar>; contact?: ReadonlyArray<EmbeddedContactWithUploadedAvatar>;
expireTimer?: DurationInSeconds; expireTimer?: DurationInSeconds;
@ -236,15 +225,15 @@ class Message {
needsSync?: boolean; needsSync?: boolean;
preview?: ReadonlyArray<LinkPreviewType>; preview?: ReadonlyArray<OutgoingLinkPreviewType>;
profileKey?: Uint8Array; profileKey?: Uint8Array;
quote?: QuotedMessageType | null; quote?: OutgoingQuoteType;
recipients: ReadonlyArray<string>; recipients: ReadonlyArray<string>;
sticker?: StickerType; sticker?: OutgoingStickerType;
reaction?: ReactionType; reaction?: ReactionType;
@ -252,8 +241,6 @@ class Message {
dataMessage?: Proto.DataMessage; dataMessage?: Proto.DataMessage;
attachmentPointers: Array<Proto.IAttachmentPointer> = [];
deletedForEveryoneTimestamp?: number; deletedForEveryoneTimestamp?: number;
groupCallUpdate?: GroupCallUpdateType; groupCallUpdate?: GroupCallUpdateType;
@ -346,7 +333,7 @@ class Message {
const proto = new Proto.DataMessage(); const proto = new Proto.DataMessage();
proto.timestamp = Long.fromNumber(this.timestamp); proto.timestamp = Long.fromNumber(this.timestamp);
proto.attachments = this.attachmentPointers; proto.attachments = this.attachments.slice();
if (this.body) { if (this.body) {
proto.body = this.body; proto.body = this.body;
@ -383,10 +370,7 @@ class Message {
proto.sticker.packKey = Bytes.fromBase64(this.sticker.packKey); proto.sticker.packKey = Bytes.fromBase64(this.sticker.packKey);
proto.sticker.stickerId = this.sticker.stickerId; proto.sticker.stickerId = this.sticker.stickerId;
proto.sticker.emoji = this.sticker.emoji; proto.sticker.emoji = this.sticker.emoji;
proto.sticker.data = this.sticker.data;
if (this.sticker.attachmentPointer) {
proto.sticker.data = this.sticker.attachmentPointer;
}
} }
if (this.reaction) { if (this.reaction) {
proto.reaction = new Proto.DataMessage.Reaction(); proto.reaction = new Proto.DataMessage.Reaction();
@ -406,82 +390,83 @@ class Message {
item.url = preview.url; item.url = preview.url;
item.description = preview.description || null; item.description = preview.description || null;
item.date = preview.date || null; item.date = preview.date || null;
if (preview.attachmentPointer) { if (preview.image) {
item.image = preview.attachmentPointer; item.image = preview.image;
} }
return item; return item;
}); });
} }
if (Array.isArray(this.contact)) { if (Array.isArray(this.contact)) {
proto.contact = this.contact.map(contact => { proto.contact = this.contact.map(
const contactProto = new Proto.DataMessage.Contact(); (contact: EmbeddedContactWithUploadedAvatar) => {
if (contact.name) { const contactProto = new Proto.DataMessage.Contact();
const nameProto: Proto.DataMessage.Contact.IName = { if (contact.name) {
givenName: contact.name.givenName, const nameProto: Proto.DataMessage.Contact.IName = {
familyName: contact.name.familyName, givenName: contact.name.givenName,
prefix: contact.name.prefix, familyName: contact.name.familyName,
suffix: contact.name.suffix, prefix: contact.name.prefix,
middleName: contact.name.middleName, suffix: contact.name.suffix,
displayName: contact.name.displayName, 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,
}; };
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); return new Proto.DataMessage.Contact.Phone(numberProto);
}); });
} }
if (Array.isArray(contact.email)) { if (Array.isArray(contact.email)) {
contactProto.email = contact.email.map(email => { contactProto.email = contact.email.map(email => {
const emailProto: Proto.DataMessage.Contact.IEmail = { const emailProto: Proto.DataMessage.Contact.IEmail = {
value: email.value, value: email.value,
type: numberToEmailType(email.type), type: numberToEmailType(email.type),
label: email.label, label: email.label,
}; };
return new Proto.DataMessage.Contact.Email(emailProto); return new Proto.DataMessage.Contact.Email(emailProto);
}); });
} }
if (Array.isArray(contact.address)) { if (Array.isArray(contact.address)) {
contactProto.address = contact.address.map(address => { contactProto.address = contact.address.map(address => {
const addressProto: Proto.DataMessage.Contact.IPostalAddress = { const addressProto: Proto.DataMessage.Contact.IPostalAddress = {
type: numberToAddressType(address.type), type: numberToAddressType(address.type),
label: address.label, label: address.label,
street: address.street, street: address.street,
pobox: address.pobox, pobox: address.pobox,
neighborhood: address.neighborhood, neighborhood: address.neighborhood,
city: address.city, city: address.city,
region: address.region, region: address.region,
postcode: address.postcode, postcode: address.postcode,
country: address.country, country: address.country,
}; };
return new Proto.DataMessage.Contact.PostalAddress(addressProto); return new Proto.DataMessage.Contact.PostalAddress(addressProto);
}); });
} }
if (contact.avatar && contact.avatar.attachmentPointer) { if (contact.avatar?.avatar) {
const avatarProto = new Proto.DataMessage.Contact.Avatar(); const avatarProto = new Proto.DataMessage.Contact.Avatar();
avatarProto.avatar = contact.avatar.attachmentPointer; avatarProto.avatar = contact.avatar.avatar;
avatarProto.isProfile = Boolean(contact.avatar.isProfile); avatarProto.isProfile = Boolean(contact.avatar.isProfile);
contactProto.avatar = avatarProto; contactProto.avatar = avatarProto;
} }
if (contact.organization) { if (contact.organization) {
contactProto.organization = contact.organization; contactProto.organization = contact.organization;
} }
return contactProto; return contactProto;
}); }
);
} }
if (this.quote) { if (this.quote) {
const { QuotedAttachment } = Proto.DataMessage.Quote;
const { BodyRange: ProtoBodyRange, Quote } = Proto.DataMessage; const { BodyRange: ProtoBodyRange, Quote } = Proto.DataMessage;
proto.quote = new Quote(); proto.quote = new Quote();
@ -497,21 +482,7 @@ class Message {
this.quote.id === undefined ? null : Long.fromNumber(this.quote.id); this.quote.id === undefined ? null : Long.fromNumber(this.quote.id);
quote.authorUuid = this.quote.authorUuid || null; quote.authorUuid = this.quote.authorUuid || null;
quote.text = this.quote.text || null; quote.text = this.quote.text || null;
quote.attachments = (this.quote.attachments || []).map( quote.attachments = this.quote.attachments.slice() || [];
(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;
}
);
const bodyRanges = this.quote.bodyRanges || []; const bodyRanges = this.quote.bodyRanges || [];
quote.bodyRanges = bodyRanges.map(range => { quote.bodyRanges = bodyRanges.map(range => {
const bodyRange = new ProtoBodyRange(); const bodyRange = new ProtoBodyRange();
@ -665,13 +636,6 @@ export default class MessageSender {
// Attachment upload functions // 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 { static getRandomPadding(): Uint8Array {
// Generate a random int from 1 and 512 // Generate a random int from 1 and 512
const buffer = getRandomBytes(2); const buffer = getRandomBytes(2);
@ -681,216 +645,11 @@ export default class MessageSender {
return getRandomBytes(paddingLength); return getRandomBytes(paddingLength);
} }
getPaddedAttachment(data: Readonly<Uint8Array>): 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<AttachmentType> &
Pick<AttachmentType, 'data' | 'size' | 'contentType'>
>
): Promise<Proto.IAttachmentPointer> {
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<void> {
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<void> {
try {
const preview = await Promise.all(
(message.preview || []).map(async (item: Readonly<LinkPreviewType>) => {
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<void> {
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<void> {
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<void> {
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 // Proto assembly
async getTextAttachmentProto( getTextAttachmentProto(
attachmentAttrs: Attachment.TextAttachmentType attachmentAttrs: OutgoingTextAttachmentType
): Promise<Proto.TextAttachment> { ): Proto.TextAttachment {
const textAttachment = new Proto.TextAttachment(); const textAttachment = new Proto.TextAttachment();
if (attachmentAttrs.text) { if (attachmentAttrs.text) {
@ -910,15 +669,8 @@ export default class MessageSender {
} }
if (attachmentAttrs.preview) { 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 = { textAttachment.preview = {
image, image: attachmentAttrs.preview.image,
title: attachmentAttrs.preview.title, title: attachmentAttrs.preview.title,
url: attachmentAttrs.preview.url, url: attachmentAttrs.preview.url,
}; };
@ -950,20 +702,17 @@ export default class MessageSender {
textAttachment, textAttachment,
}: { }: {
allowsReplies?: boolean; allowsReplies?: boolean;
fileAttachment?: AttachmentType; fileAttachment?: UploadedAttachmentType;
groupV2?: GroupV2InfoType; groupV2?: GroupV2InfoType;
profileKey: Uint8Array; profileKey: Uint8Array;
textAttachment?: Attachment.TextAttachmentType; textAttachment?: OutgoingTextAttachmentType;
}): Promise<Proto.StoryMessage> { }): Promise<Proto.StoryMessage> {
const storyMessage = new Proto.StoryMessage(); const storyMessage = new Proto.StoryMessage();
storyMessage.profileKey = profileKey; storyMessage.profileKey = profileKey;
if (fileAttachment) { if (fileAttachment) {
try { try {
const attachmentPointer = await this.makeAttachmentPointer( storyMessage.fileAttachment = fileAttachment;
fileAttachment
);
storyMessage.fileAttachment = attachmentPointer;
} catch (error) { } catch (error) {
if (error instanceof HTTPError) { if (error instanceof HTTPError) {
throw new MessageError(message, error); throw new MessageError(message, error);
@ -974,9 +723,7 @@ export default class MessageSender {
} }
if (textAttachment) { if (textAttachment) {
storyMessage.textAttachment = await this.getTextAttachmentProto( storyMessage.textAttachment = this.getTextAttachmentProto(textAttachment);
textAttachment
);
} }
if (groupV2) { if (groupV2) {
@ -1006,7 +753,16 @@ export default class MessageSender {
const dataMessage = message.toProto(); const dataMessage = message.toProto();
const contentMessage = new Proto.Content(); 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; const { includePniSignatureMessage } = options;
if (includePniSignatureMessage) { if (includePniSignatureMessage) {
@ -1033,13 +789,6 @@ export default class MessageSender {
attributes: Readonly<MessageOptionsType> attributes: Readonly<MessageOptionsType>
): Promise<Message> { ): Promise<Message> {
const message = new Message(attributes); 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; return message;
} }
@ -1094,6 +843,7 @@ export default class MessageSender {
bodyRanges, bodyRanges,
contact, contact,
deletedForEveryoneTimestamp, deletedForEveryoneTimestamp,
editedMessageTimestamp,
expireTimer, expireTimer,
flags, flags,
groupCallUpdate, groupCallUpdate,
@ -1144,6 +894,7 @@ export default class MessageSender {
body: messageText, body: messageText,
contact, contact,
deletedForEveryoneTimestamp, deletedForEveryoneTimestamp,
editedMessageTimestamp,
expireTimer, expireTimer,
flags, flags,
groupCallUpdate, groupCallUpdate,
@ -1353,6 +1104,7 @@ export default class MessageSender {
contact, contact,
contentHint, contentHint,
deletedForEveryoneTimestamp, deletedForEveryoneTimestamp,
editedMessageTimestamp,
expireTimer, expireTimer,
groupId, groupId,
identifier, identifier,
@ -1369,21 +1121,22 @@ export default class MessageSender {
urgent, urgent,
includePniSignatureMessage, includePniSignatureMessage,
}: Readonly<{ }: Readonly<{
attachments: ReadonlyArray<AttachmentType> | undefined; attachments: ReadonlyArray<UploadedAttachmentType> | undefined;
bodyRanges?: ReadonlyArray<RawBodyRange>; bodyRanges?: ReadonlyArray<RawBodyRange>;
contact?: Array<ContactWithHydratedAvatar>; contact?: ReadonlyArray<EmbeddedContactWithUploadedAvatar>;
contentHint: number; contentHint: number;
deletedForEveryoneTimestamp: number | undefined; deletedForEveryoneTimestamp: number | undefined;
editedMessageTimestamp?: number;
expireTimer: DurationInSeconds | undefined; expireTimer: DurationInSeconds | undefined;
groupId: string | undefined; groupId: string | undefined;
identifier: string; identifier: string;
messageText: string | undefined; messageText: string | undefined;
options?: SendOptionsType; options?: SendOptionsType;
preview?: ReadonlyArray<LinkPreviewType> | undefined; preview?: ReadonlyArray<OutgoingLinkPreviewType> | undefined;
profileKey?: Uint8Array; profileKey?: Uint8Array;
quote?: QuotedMessageType | null; quote?: OutgoingQuoteType;
reaction?: ReactionType; reaction?: ReactionType;
sticker?: StickerWithHydratedData; sticker?: OutgoingStickerType;
storyContext?: StoryContextType; storyContext?: StoryContextType;
story?: boolean; story?: boolean;
timestamp: number; timestamp: number;
@ -1397,6 +1150,7 @@ export default class MessageSender {
body: messageText, body: messageText,
contact, contact,
deletedForEveryoneTimestamp, deletedForEveryoneTimestamp,
editedMessageTimestamp,
expireTimer, expireTimer,
preview, preview,
profileKey, profileKey,
@ -1421,6 +1175,7 @@ export default class MessageSender {
// Note: this is used for sending real messages to your other devices after sending a // Note: this is used for sending real messages to your other devices after sending a
// message to others. // message to others.
async sendSyncMessage({ async sendSyncMessage({
editedMessageTimestamp,
encodedDataMessage, encodedDataMessage,
timestamp, timestamp,
destination, destination,
@ -1434,6 +1189,7 @@ export default class MessageSender {
storyMessage, storyMessage,
storyMessageRecipients, storyMessageRecipients,
}: Readonly<{ }: Readonly<{
editedMessageTimestamp?: number;
encodedDataMessage?: Uint8Array; encodedDataMessage?: Uint8Array;
timestamp: number; timestamp: number;
destination: string | undefined; destination: string | undefined;
@ -1452,7 +1208,13 @@ export default class MessageSender {
const sentMessage = new Proto.SyncMessage.Sent(); const sentMessage = new Proto.SyncMessage.Sent();
sentMessage.timestamp = Long.fromNumber(timestamp); 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); const dataMessage = Proto.DataMessage.decode(encodedDataMessage);
sentMessage.message = dataMessage; sentMessage.message = dataMessage;
} }

View file

@ -251,6 +251,7 @@ export type CallbackResultType = {
errors?: Array<CustomError>; errors?: Array<CustomError>;
unidentifiedDeliveries?: Array<string>; unidentifiedDeliveries?: Array<string>;
dataMessage?: Uint8Array; dataMessage?: Uint8Array;
editMessage?: Uint8Array;
// If this send is not the final step in a multi-step send, we shouldn't treat its // 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. // results we would treat a one-step send.

View file

@ -948,7 +948,7 @@ export type WebAPIType = {
postBatchIdentityCheck: ( postBatchIdentityCheck: (
elements: VerifyAciRequestType elements: VerifyAciRequestType
) => Promise<VerifyAciResponseType>; ) => Promise<VerifyAciResponseType>;
putAttachment: (encryptedBin: Uint8Array) => Promise<string>; putEncryptedAttachment: (encryptedBin: Uint8Array) => Promise<string>;
putProfile: ( putProfile: (
jsonData: ProfileRequestDataType jsonData: ProfileRequestDataType
) => Promise<UploadAvatarHeadersType | undefined>; ) => Promise<UploadAvatarHeadersType | undefined>;
@ -1280,7 +1280,7 @@ export function initialize({
onOffline, onOffline,
onOnline, onOnline,
postBatchIdentityCheck, postBatchIdentityCheck,
putAttachment, putEncryptedAttachment,
putProfile, putProfile,
putStickers, putStickers,
reconnect, reconnect,
@ -2507,7 +2507,7 @@ export function initialize({
attachmentIdString: string; attachmentIdString: string;
}; };
async function putAttachment(encryptedBin: Uint8Array) { async function putEncryptedAttachment(encryptedBin: Uint8Array) {
const response = (await _ajax({ const response = (await _ajax({
call: 'attachmentId', call: 'attachmentId',
httpType: 'GET', httpType: 'GET',

View file

@ -27,7 +27,8 @@ import { ThemeType } from './Util';
import * as GoogleChrome from '../util/GoogleChrome'; import * as GoogleChrome from '../util/GoogleChrome';
import { ReadStatus } from '../messages/MessageReadStatus'; import { ReadStatus } from '../messages/MessageReadStatus';
import type { MessageStatusType } from '../components/conversation/Message'; 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_WIDTH = 300;
const MAX_HEIGHT = MAX_WIDTH * 1.5; const MAX_HEIGHT = MAX_WIDTH * 1.5;
@ -84,6 +85,16 @@ export type AttachmentType = {
key?: string; key?: string;
}; };
export type UploadedAttachmentType = Proto.IAttachmentPointer &
Readonly<{
// Required fields
cdnId: Long;
key: Uint8Array;
size: number;
digest: Uint8Array;
contentType: string;
}>;
export type AttachmentWithHydratedData = AttachmentType & { export type AttachmentWithHydratedData = AttachmentType & {
data: Uint8Array; data: Uint8Array;
}; };
@ -1006,6 +1017,6 @@ export const canBeDownloaded = (
}; };
export function getAttachmentSignature(attachment: AttachmentType): string { export function getAttachmentSignature(attachment: AttachmentType): string {
softAssert(attachment.digest, 'attachment missing digest'); strictAssert(attachment.digest, 'attachment missing digest');
return attachment.digest || String(attachment.blurHash); return attachment.digest;
} }

View file

@ -11,17 +11,22 @@ import {
format as formatPhoneNumber, format as formatPhoneNumber,
parse as parsePhoneNumber, parse as parsePhoneNumber,
} from './PhoneNumber'; } from './PhoneNumber';
import type { AttachmentType, migrateDataToFileSystem } from './Attachment'; import type {
AttachmentType,
AttachmentWithHydratedData,
UploadedAttachmentType,
migrateDataToFileSystem,
} from './Attachment';
import { toLogFormat } from './errors'; import { toLogFormat } from './errors';
import type { LoggerType } from './Logging'; import type { LoggerType } from './Logging';
import type { UUIDStringType } from './UUID'; import type { UUIDStringType } from './UUID';
export type EmbeddedContactType = { type GenericEmbeddedContactType<AvatarType> = {
name?: Name; name?: Name;
number?: Array<Phone>; number?: Array<Phone>;
email?: Array<Email>; email?: Array<Email>;
address?: Array<PostalAddress>; address?: Array<PostalAddress>;
avatar?: Avatar; avatar?: AvatarType;
organization?: string; organization?: string;
// Populated by selector // Populated by selector
@ -29,6 +34,12 @@ export type EmbeddedContactType = {
uuid?: UUIDStringType; uuid?: UUIDStringType;
}; };
export type EmbeddedContactType = GenericEmbeddedContactType<Avatar>;
export type EmbeddedContactWithHydratedAvatar =
GenericEmbeddedContactType<AvatarWithHydratedData>;
export type EmbeddedContactWithUploadedAvatar =
GenericEmbeddedContactType<UploadedAvatar>;
type Name = { type Name = {
givenName?: string; givenName?: string;
familyName?: string; familyName?: string;
@ -75,11 +86,15 @@ export type PostalAddress = {
country?: string; country?: string;
}; };
export type Avatar = { type GenericAvatar<Attachment> = {
avatar: AttachmentType; avatar: Attachment;
isProfile: boolean; isProfile: boolean;
}; };
export type Avatar = GenericAvatar<AttachmentType>;
export type AvatarWithHydratedData = GenericAvatar<AttachmentWithHydratedData>;
export type UploadedAvatar = GenericAvatar<UploadedAttachmentType>;
const DEFAULT_PHONE_TYPE = Proto.DataMessage.Contact.Phone.Type.HOME; const DEFAULT_PHONE_TYPE = Proto.DataMessage.Contact.Phone.Type.HOME;
const DEFAULT_EMAIL_TYPE = Proto.DataMessage.Contact.Email.Type.HOME; const DEFAULT_EMAIL_TYPE = Proto.DataMessage.Contact.Email.Type.HOME;
const DEFAULT_ADDRESS_TYPE = Proto.DataMessage.Contact.PostalAddress.Type.HOME; const DEFAULT_ADDRESS_TYPE = Proto.DataMessage.Contact.PostalAddress.Type.HOME;

View file

@ -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;
}
}

View file

@ -8,11 +8,9 @@ import LinkifyIt from 'linkify-it';
import { maybeParseUrl } from '../util/url'; import { maybeParseUrl } from '../util/url';
import { replaceEmojiWithSpaces } from '../util/emoji'; import { replaceEmojiWithSpaces } from '../util/emoji';
import type { AttachmentType } from './Attachment'; import type { AttachmentWithHydratedData } from './Attachment';
export type LinkPreviewImage = AttachmentType & { export type LinkPreviewImage = AttachmentWithHydratedData;
data: Uint8Array;
};
export type LinkPreviewResult = { export type LinkPreviewResult = {
title: string | null; title: string | null;

View file

@ -20,13 +20,19 @@ import { initializeAttachmentMetadata } from './message/initializeAttachmentMeta
import type * as MIME from './MIME'; import type * as MIME from './MIME';
import type { LoggerType } from './Logging'; import type { LoggerType } from './Logging';
import type { EmbeddedContactType } from './EmbeddedContact'; import type {
EmbeddedContactType,
EmbeddedContactWithHydratedAvatar,
} from './EmbeddedContact';
import type { import type {
MessageAttributesType, MessageAttributesType,
QuotedMessageType, QuotedMessageType,
} from '../model-types.d'; } from '../model-types.d';
import type { LinkPreviewType } from './message/LinkPreviews'; import type {
LinkPreviewType,
LinkPreviewWithHydratedData,
} from './message/LinkPreviews';
import type { StickerType, StickerWithHydratedData } from './Stickers'; import type { StickerType, StickerWithHydratedData } from './Stickers';
export { hasExpiration } from './Message'; export { hasExpiration } from './Message';
@ -714,28 +720,33 @@ export const loadContactData = (
loadAttachmentData: LoadAttachmentType loadAttachmentData: LoadAttachmentType
): (( ): ((
contact: Array<EmbeddedContactType> | undefined contact: Array<EmbeddedContactType> | undefined
) => Promise<Array<EmbeddedContactType> | undefined>) => { ) => Promise<Array<EmbeddedContactWithHydratedAvatar> | undefined>) => {
if (!isFunction(loadAttachmentData)) { if (!isFunction(loadAttachmentData)) {
throw new TypeError('loadContactData: loadAttachmentData is required'); throw new TypeError('loadContactData: loadAttachmentData is required');
} }
return async ( return async (
contact: Array<EmbeddedContactType> | undefined contact: Array<EmbeddedContactType> | undefined
): Promise<Array<EmbeddedContactType> | undefined> => { ): Promise<Array<EmbeddedContactWithHydratedAvatar> | undefined> => {
if (!contact) { if (!contact) {
return undefined; return undefined;
} }
return Promise.all( return Promise.all(
contact.map( contact.map(
async (item: EmbeddedContactType): Promise<EmbeddedContactType> => { async (
item: EmbeddedContactType
): Promise<EmbeddedContactWithHydratedAvatar> => {
if ( if (
!item || !item ||
!item.avatar || !item.avatar ||
!item.avatar.avatar || !item.avatar.avatar ||
!item.avatar.avatar.path !item.avatar.avatar.path
) { ) {
return item; return {
...item,
avatar: undefined,
};
} }
return { return {
@ -758,7 +769,7 @@ export const loadPreviewData = (
loadAttachmentData: LoadAttachmentType loadAttachmentData: LoadAttachmentType
): (( ): ((
preview: Array<LinkPreviewType> | undefined preview: Array<LinkPreviewType> | undefined
) => Promise<Array<LinkPreviewType>>) => { ) => Promise<Array<LinkPreviewWithHydratedData>>) => {
if (!isFunction(loadAttachmentData)) { if (!isFunction(loadAttachmentData)) {
throw new TypeError('loadPreviewData: loadAttachmentData is required'); throw new TypeError('loadPreviewData: loadAttachmentData is required');
} }
@ -769,16 +780,22 @@ export const loadPreviewData = (
} }
return Promise.all( return Promise.all(
preview.map(async item => { preview.map(
if (!item.image) { async (item: LinkPreviewType): Promise<LinkPreviewWithHydratedData> => {
return item; if (!item.image) {
} return {
...item,
// Pacify typescript
image: undefined,
};
}
return { return {
...item, ...item,
image: await loadAttachmentData(item.image), image: await loadAttachmentData(item.image),
}; };
}) }
)
); );
}; };
}; };

View file

@ -7,6 +7,7 @@ export enum ToastType {
AlreadyRequestedToJoin = 'AlreadyRequestedToJoin', AlreadyRequestedToJoin = 'AlreadyRequestedToJoin',
Blocked = 'Blocked', Blocked = 'Blocked',
BlockedGroup = 'BlockedGroup', BlockedGroup = 'BlockedGroup',
CannotEditMessage = 'CannotEditMessage',
CannotForwardEmptyMessage = 'CannotForwardEmptyMessage', CannotForwardEmptyMessage = 'CannotForwardEmptyMessage',
CannotMixMultiAndNonMultiAttachments = 'CannotMixMultiAndNonMultiAttachments', CannotMixMultiAndNonMultiAttachments = 'CannotMixMultiAndNonMultiAttachments',
CannotOpenGiftBadgeIncoming = 'CannotOpenGiftBadgeIncoming', CannotOpenGiftBadgeIncoming = 'CannotOpenGiftBadgeIncoming',
@ -54,6 +55,7 @@ export type AnyToast =
| { toastType: ToastType.AlreadyRequestedToJoin } | { toastType: ToastType.AlreadyRequestedToJoin }
| { toastType: ToastType.Blocked } | { toastType: ToastType.Blocked }
| { toastType: ToastType.BlockedGroup } | { toastType: ToastType.BlockedGroup }
| { toastType: ToastType.CannotEditMessage }
| { toastType: ToastType.CannotForwardEmptyMessage } | { toastType: ToastType.CannotForwardEmptyMessage }
| { toastType: ToastType.CannotMixMultiAndNonMultiAttachments } | { toastType: ToastType.CannotMixMultiAndNonMultiAttachments }
| { toastType: ToastType.CannotOpenGiftBadgeIncoming } | { toastType: ToastType.CannotOpenGiftBadgeIncoming }

View file

@ -1,14 +1,18 @@
// Copyright 2021 Signal Messenger, LLC // Copyright 2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only // SPDX-License-Identifier: AGPL-3.0-only
import type { AttachmentType } from '../Attachment'; import type { AttachmentType, AttachmentWithHydratedData } from '../Attachment';
export type LinkPreviewType = { type GenericLinkPreviewType<Image> = {
title?: string; title?: string;
description?: string; description?: string;
domain?: string; domain?: string;
url: string; url: string;
isStickerPack?: boolean; isStickerPack?: boolean;
image?: Readonly<AttachmentType>; image?: Readonly<Image>;
date?: number; date?: number;
}; };
export type LinkPreviewType = GenericLinkPreviewType<AttachmentType>;
export type LinkPreviewWithHydratedData =
GenericLinkPreviewType<AttachmentWithHydratedData>;

View file

@ -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');
}

View file

@ -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;
}

View file

@ -3,17 +3,17 @@
import type { AttachmentType } from '../types/Attachment'; import type { AttachmentType } from '../types/Attachment';
import type { EditAttributesType } from '../messageModifiers/Edits'; 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 type { LinkPreviewType } from '../types/message/LinkPreviews';
import * as log from '../logging/log'; import * as log from '../logging/log';
import { ReadStatus } from '../messages/MessageReadStatus'; import { ReadStatus } from '../messages/MessageReadStatus';
import dataInterface from '../sql/Client'; import dataInterface from '../sql/Client';
import { drop } from './drop'; import { drop } from './drop';
import { import { getAttachmentSignature, isVoiceMessage } from '../types/Attachment';
getAttachmentSignature,
isDownloaded,
isVoiceMessage,
} from '../types/Attachment';
import { getMessageIdForLogging } from './idForLogging'; import { getMessageIdForLogging } from './idForLogging';
import { hasErrors } from '../state/selectors/message'; import { hasErrors } from '../state/selectors/message';
import { isIncoming, isOutgoing } from '../messages/helpers'; 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 // 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. // then the original message becomes the first item in the edit history.
const editHistory: Array<EditHistoryType> = mainMessage.editHistory || [ let editHistory: Array<EditHistoryType> = mainMessage.editHistory || [
{ {
attachments: mainMessage.attachments, attachments: mainMessage.attachments,
body: mainMessage.body, body: mainMessage.body,
@ -76,46 +76,59 @@ export async function handleEditMessage(
return; 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 = const upgradedEditedMessageData =
await window.Signal.Migrations.upgradeMessageSchema( await window.Signal.Migrations.upgradeMessageSchema(editAttributes.message);
messageAttributesForUpgrade
);
// Copies over the attachments from the main message if they're the same // Copies over the attachments from the main message if they're the same
// and they have already been downloaded. // and they have already been downloaded.
const attachmentSignatures: Map<string, AttachmentType> = new Map(); const attachmentSignatures: Map<string, AttachmentType> = new Map();
const previewSignatures: Map<string, LinkPreviewType> = new Map(); const previewSignatures: Map<string, LinkPreviewType> = new Map();
const quoteSignatures: Map<string, AttachmentType> = new Map();
mainMessage.attachments?.forEach(attachment => { mainMessage.attachments?.forEach(attachment => {
if (!isDownloaded(attachment)) {
return;
}
const signature = getAttachmentSignature(attachment); const signature = getAttachmentSignature(attachment);
attachmentSignatures.set(signature, attachment); if (signature) {
attachmentSignatures.set(signature, attachment);
}
}); });
mainMessage.preview?.forEach(preview => { mainMessage.preview?.forEach(preview => {
if (!preview.image || !isDownloaded(preview.image)) { if (!preview.image) {
return; return;
} }
const signature = getAttachmentSignature(preview.image); 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 = const nextEditedMessageAttachments =
upgradedEditedMessageData.attachments?.map(attachment => { upgradedEditedMessageData.attachments?.map(attachment => {
const signature = getAttachmentSignature(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( const nextEditedMessagePreview = upgradedEditedMessageData.preview?.map(
preview => { preview => {
if (!preview.image) { if (!preview.image) {
@ -123,22 +136,69 @@ export async function handleEditMessage(
} }
const signature = getAttachmentSignature(preview.image); const signature = getAttachmentSignature(preview.image);
const existingPreview = previewSignatures.get(signature); const existingPreview = signature
return existingPreview || preview; ? 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 = { const editedMessage: EditHistoryType = {
attachments: nextEditedMessageAttachments, attachments: nextEditedMessageAttachments,
body: upgradedEditedMessageData.body, body: upgradedEditedMessageData.body,
bodyRanges: upgradedEditedMessageData.bodyRanges, bodyRanges: upgradedEditedMessageData.bodyRanges,
preview: nextEditedMessagePreview, preview: nextEditedMessagePreview,
timestamp: upgradedEditedMessageData.timestamp, timestamp: upgradedEditedMessageData.timestamp,
quote: nextEditedMessageQuote,
}; };
// The edit history works like a queue where the newest edits are at the top. // 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. // 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 // Update all the editable attributes on the main message also updating the
// edit history. // edit history.
@ -149,6 +209,7 @@ export async function handleEditMessage(
editHistory, editHistory,
editMessageTimestamp: upgradedEditedMessageData.timestamp, editMessageTimestamp: upgradedEditedMessageData.timestamp,
preview: editedMessage.preview, preview: editedMessage.preview,
quote: editedMessage.quote,
}); });
// Queue up any downloads in case they're different, update the fields if so. // Queue up any downloads in case they're different, update the fields if so.

View file

@ -59,8 +59,8 @@ export async function getQuoteAttachment(
): Promise< ): Promise<
Array<{ Array<{
contentType: MIMEType; contentType: MIMEType;
fileName: string | null; fileName?: string | null;
thumbnail: ThumbnailType | null; thumbnail?: ThumbnailType | null;
}> }>
> { > {
const { getAbsoluteAttachmentPath, loadAttachmentData } = const { getAbsoluteAttachmentPath, loadAttachmentData } =

View file

@ -4,7 +4,10 @@
import { orderBy } from 'lodash'; import { orderBy } from 'lodash';
import type { AttachmentType } from '../types/Attachment'; import type { AttachmentType } from '../types/Attachment';
import { isVoiceMessage } 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 type { MessageAttributesType, QuotedMessageType } from '../model-types';
import * as log from '../logging/log'; import * as log from '../logging/log';
import { SafetyNumberChangeSource } from '../components/SafetyNumberChangeDialog'; import { SafetyNumberChangeSource } from '../components/SafetyNumberChangeDialog';
@ -16,7 +19,7 @@ import {
import { isNotNil } from './isNotNil'; import { isNotNil } from './isNotNil';
import { resetLinkPreview } from '../services/LinkPreview'; import { resetLinkPreview } from '../services/LinkPreview';
import { getRecipientsByConversation } from './getRecipientsByConversation'; import { getRecipientsByConversation } from './getRecipientsByConversation';
import type { ContactWithHydratedAvatar } from '../textsecure/SendMessage'; import type { EmbeddedContactWithHydratedAvatar } from '../types/EmbeddedContact';
import type { import type {
DraftBodyRanges, DraftBodyRanges,
HydratedBodyRangesType, HydratedBodyRangesType,
@ -177,8 +180,8 @@ export async function maybeForwardMessages(
attachments: Array<AttachmentType>; attachments: Array<AttachmentType>;
body: string | undefined; body: string | undefined;
bodyRanges?: DraftBodyRanges; bodyRanges?: DraftBodyRanges;
contact?: Array<ContactWithHydratedAvatar>; contact?: Array<EmbeddedContactWithHydratedAvatar>;
preview?: Array<LinkPreviewType>; preview?: Array<LinkPreviewWithHydratedData>;
quote?: QuotedMessageType; quote?: QuotedMessageType;
sticker?: StickerWithHydratedData; sticker?: StickerWithHydratedData;
}; };

View file

@ -28,6 +28,7 @@ import {
} from '../types/Attachment'; } from '../types/Attachment';
import type { StickerType } from '../types/Stickers'; import type { StickerType } from '../types/Stickers';
import type { LinkPreviewType } from '../types/message/LinkPreviews'; import type { LinkPreviewType } from '../types/message/LinkPreviews';
import { isNotNil } from './isNotNil';
type ReturnType = { type ReturnType = {
bodyAttachment?: AttachmentType; bodyAttachment?: AttachmentType;
@ -111,6 +112,18 @@ export async function queueAttachmentDownloads(
); );
count += previewCount; 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 || []; const contactsToQueue = message.contact || [];
log.info( log.info(
`${idLog}: Queueing ${contactsToQueue.length} contact attachment downloads` `${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; let { sticker } = message;
if (sticker && sticker.data && sticker.data.path) { if (sticker && sticker.data && sticker.data.path) {
log.info(`${idLog}: Sticker attachment already downloaded`); log.info(`${idLog}: Sticker attachment already downloaded`);
@ -226,11 +205,6 @@ export async function queueAttachmentDownloads(
log.info(`${idLog}: Looping through ${editHistory.length} edits`); log.info(`${idLog}: Looping through ${editHistory.length} edits`);
editHistory = await Promise.all( editHistory = await Promise.all(
editHistory.map(async edit => { 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 } = const { attachments: editAttachments, count: editAttachmentsCount } =
await queueNormalAttachments( await queueNormalAttachments(
idLog, idLog,
@ -239,15 +213,22 @@ export async function queueAttachmentDownloads(
attachments attachments
); );
count += editAttachmentsCount; 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 } = const { preview: editPreview, count: editPreviewCount } =
await queuePreviews(idLog, messageId, edit.preview, preview); await queuePreviews(idLog, messageId, edit.preview, preview);
count += editPreviewCount; count += editPreviewCount;
if (editPreviewCount !== 0) {
log.info(
`${idLog}: Queueing ${editPreviewCount} preview attachment ` +
`downloads (edited:${edit.timestamp})`
);
}
return { return {
...edit, ...edit,
@ -293,7 +274,9 @@ async function queueNormalAttachments(
const attachmentSignatures: Map<string, AttachmentType> = new Map(); const attachmentSignatures: Map<string, AttachmentType> = new Map();
otherAttachments?.forEach(attachment => { otherAttachments?.forEach(attachment => {
const signature = getAttachmentSignature(attachment); const signature = getAttachmentSignature(attachment);
attachmentSignatures.set(signature, attachment); if (signature) {
attachmentSignatures.set(signature, attachment);
}
}); });
let count = 0; let count = 0;
@ -415,3 +398,98 @@ async function queuePreviews(
count, 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<QuotedMessageType>
): 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<string, AttachmentType> = 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,
};
}

View file

@ -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<LinkPreviewType>;
quoteSentAt?: number;
quoteAuthorUuid?: string;
targetMessageId: string;
}
): Promise<void> {
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);
}

View file

@ -142,8 +142,9 @@ export async function sendStoryMessage(
const attachments: Array<AttachmentType> = [attachment]; const attachments: Array<AttachmentType> = [attachment];
const linkPreview = attachment?.textAttachment?.preview; const linkPreview = attachment?.textAttachment?.preview;
const { loadPreviewData } = window.Signal.Migrations;
const sanitizedLinkPreview = linkPreview const sanitizedLinkPreview = linkPreview
? sanitizeLinkPreview(linkPreview) ? sanitizeLinkPreview((await loadPreviewData([linkPreview]))[0])
: undefined; : undefined;
// If a text attachment has a link preview we remove it from the // If a text attachment has a link preview we remove it from the
// textAttachment data structure and instead process the preview and add // textAttachment data structure and instead process the preview and add

View file

@ -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<unknown>,
getLogLine: (duration: number) => string
): Promise<void> {
const start = Date.now();
try {
await func();
} finally {
const duration = Date.now() - start;
if (duration > threshold) {
log.info(getLogLine(duration));
}
}
}

View file

@ -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<UploadedAttachmentType> {
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,
};
}