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": {
"messageformat": "Accept"
},
"icu:edit": {
"messageformat": "Edit"
},
"forward": {
"message": "Forward",
"description": "(deleted 03/29/2023)"
@ -3538,6 +3541,10 @@
"messageformat": "Delete failed",
"description": "Shown on a message which was deleted for everyone if the delete wasn't successfully sent to anyone"
},
"icu:editFailed": {
"messageformat": "Edit failed, click for details",
"description": "Shown on a message which was edited if the edit wasn't successfully sent to anyone"
},
"sendPaused": {
"message": "Send paused",
"description": "(deleted 03/29/2023) Shown on outgoing message if it cannot be sent immediately"
@ -4102,6 +4109,10 @@
"messageformat": "Failed to fetch phone number. Check your connection and try again.",
"description": "Shown if request to Signal servers to find phone number fails"
},
"icu:ToastManager__CannotEditMessage": {
"messageformat": "Edits can only be applied within 3 hours from the time you sent this message.",
"description": "Error message when you try to send an edit after message becomes too old"
},
"startConversation--username-not-found": {
"message": "User not found. $atUsername$ is not a Signal user; make sure 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"
@ -8355,6 +8366,18 @@
"messageformat": "Checking contact's registration status",
"description": "Displayed while checking if the contact is SMS-only"
},
"icu:CompositionArea__edit-action--discard": {
"messageformat": "Discard message",
"description": "aria-label for discard edit button"
},
"icu:CompositionArea__edit-action--send": {
"messageformat": "Send edited message",
"description": "aria-label for send edit button"
},
"icu:CompositionInput__editing-message": {
"messageformat": "Edit message",
"description": "Status text displayed above composition input when editing a message"
},
"countMutedConversationsDescription": {
"message": "Include muted conversations in badge count",
"description": "(deleted 03/29/2023) Description for counting muted conversations in badge setting"
@ -12363,6 +12386,14 @@
"messageformat": "Edit history",
"description": "Modal title for the edit history messages modal"
},
"icu:ResendMessageEdit__body": {
"messageformat": "This edit could not be sent. Check your connection and try again",
"description": "Modal body for the confirmation dialog shown to user when attempting to resend message edit"
},
"icu:ResendMessageEdit__button": {
"messageformat": "Send again",
"description": "Button text for the confirmation dialog shown to user when attempting to resend message edit"
},
"WhatsNew__modal-title": {
"message": "What's New",
"description": "(deleted 03/29/2023) Title for the whats new modal"

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 {
@include light-theme {
@include color-svg(

View File

@ -49,6 +49,37 @@
margin-right: 12px;
}
}
&__edit-button {
@include button-reset;
@include rounded-corners;
align-items: center;
background-color: $color-gray-45;
display: flex;
height: 28px;
justify-content: center;
width: 28px;
&::before {
content: '';
height: 20px;
width: 20px;
}
&--discard {
&::before {
@include color-svg('../images/icons/v3/x.svg', $color-white);
}
}
&--accept {
background-color: $color-ultramarine;
margin-left: 16px;
&::before {
@include color-svg('../images/icons/v3/check.svg', $color-white);
}
}
}
&__send-button {
display: flex;
justify-content: center;
@ -69,6 +100,7 @@
&__input {
flex-grow: 1;
margin: 0 6px;
position: relative;
&--large {
margin: 0;

View File

@ -4,7 +4,7 @@
.module-composition-input {
&__quill {
height: 100%;
padding-left: 6px;
padding-left: 12px;
.ql-editor {
caret-color: transparent;
@ -81,7 +81,7 @@
&__scroller {
$padding-top: 6px;
padding: $padding-top;
padding: $padding-top 0;
min-height: calc(32px - 2 * $border-size);
max-height: calc(72px - 2 * $border-size);
@ -333,6 +333,35 @@
stroke: $color-white;
}
&__editing-message {
@include font-body-2-bold;
margin-top: 10px;
user-select: none;
&::before {
content: '';
display: inline-block;
height: 16px;
margin: 0 8px 0 10px;
width: 16px;
vertical-align: middle;
@include color-svg('../images/icons/v3/edit.svg', $color-black);
@include dark-theme {
@include color-svg('../images/icons/v3/edit.svg', $color-gray-15);
}
}
&__attachment img {
height: 18px;
position: absolute;
right: 8px;
top: 8px;
width: 18px;
}
}
}
div.CompositionInput__link-preview {

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/DisappearingTimerSelect.scss';
@import './components/EditConversationAttributesModal.scss';
@import './components/EditHistoryMessagesModal.scss';
@import './components/EditUsernameModalBody.scss';
@import './components/ForwardMessageModal.scss';
@import './components/GradientDial.scss';

View File

@ -454,8 +454,8 @@ export function decryptAttachment(
}
export function encryptAttachment(
plaintext: Uint8Array,
keys: Uint8Array
plaintext: Readonly<Uint8Array>,
keys: Readonly<Uint8Array>
): EncryptedAttachment {
if (!(plaintext instanceof Uint8Array)) {
throw new TypeError(
@ -485,6 +485,24 @@ export function encryptAttachment(
};
}
export function getAttachmentSizeBucket(size: number): number {
return Math.max(
541,
Math.floor(1.05 ** Math.ceil(Math.log(size) / Math.log(1.05)))
);
}
export function padAndEncryptAttachment(
data: Readonly<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 {
const iv = getRandomBytes(PROFILE_IV_LENGTH);
if (key.byteLength !== PROFILE_KEY_LENGTH) {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -68,6 +68,14 @@ export function ToastManager({
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) {
return (
<Toast onClose={hideToast}>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

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

View File

@ -38,10 +38,8 @@ import * as Conversation from '../types/Conversation';
import type { StickerType, StickerWithHydratedData } from '../types/Stickers';
import * as Stickers from '../types/Stickers';
import { StorySendMode } from '../types/Stories';
import type {
ContactWithHydratedAvatar,
GroupV2InfoType,
} from '../textsecure/SendMessage';
import type { EmbeddedContactWithHydratedAvatar } from '../types/EmbeddedContact';
import type { GroupV2InfoType } from '../textsecure/SendMessage';
import createTaskWithTimeout from '../textsecure/TaskWithTimeout';
import MessageSender from '../textsecure/SendMessage';
import type {
@ -106,7 +104,10 @@ import { getConversationMembers } from '../util/getConversationMembers';
import { updateConversationsWithUuidLookup } from '../updateConversationsWithUuidLookup';
import { ReadStatus } from '../messages/MessageReadStatus';
import { SendStatus } from '../messages/MessageSendState';
import type { LinkPreviewType } from '../types/message/LinkPreviews';
import type {
LinkPreviewType,
LinkPreviewWithHydratedData,
} from '../types/message/LinkPreviews';
import { MINUTE, SECOND, DurationInSeconds } from '../util/durations';
import { concat, filter, map, repeat, zipObject } from '../util/iterables';
import * as universalExpireTimer from '../util/universalExpireTimer';
@ -1916,6 +1917,7 @@ export class ConversationModel extends window.Backbone
const draftTimestamp = this.get('draftTimestamp');
const draftPreview = this.getDraftPreview();
const draftText = dropNull(this.get('draft'));
const draftEditMessage = this.get('draftEditMessage');
const shouldShowDraft = Boolean(
this.hasDraft() && draftTimestamp && draftTimestamp >= timestamp
);
@ -1993,6 +1995,7 @@ export class ConversationModel extends window.Backbone
draftBodyRanges: this.getDraftBodyRanges(),
draftPreview,
draftText,
draftEditMessage,
familyName: this.get('profileFamilyName'),
firstName: this.get('profileName'),
groupDescription: this.get('description'),
@ -4008,8 +4011,8 @@ export class ConversationModel extends window.Backbone
): Promise<
Array<{
contentType: MIMEType;
fileName: string | null;
thumbnail: ThumbnailType | null;
fileName?: string | null;
thumbnail?: ThumbnailType | null;
}>
> {
return getQuoteAttachment(attachments, preview, sticker);
@ -4105,6 +4108,85 @@ export class ConversationModel extends window.Backbone
}
}
batchReduxChanges(callback: () => void): void {
strictAssert(!this.isInReduxBatch, 'Nested redux batching is not allowed');
this.isInReduxBatch = true;
batchDispatch(() => {
try {
callback();
} finally {
this.isInReduxBatch = false;
}
});
}
beforeMessageSend({
message,
dontAddMessage,
dontClearDraft,
now,
extraReduxActions,
}: {
message: MessageModel;
dontAddMessage: boolean;
dontClearDraft: boolean;
now: number;
extraReduxActions?: () => void;
}): void {
this.batchReduxChanges(() => {
const { clearUnreadMetrics } = window.reduxActions.conversations;
clearUnreadMetrics(this.id);
const mandatoryProfileSharingEnabled =
window.Signal.RemoteConfig.isEnabled('desktop.mandatoryProfileSharing');
const enabledProfileSharing = Boolean(
mandatoryProfileSharingEnabled && !this.get('profileSharing')
);
const unarchivedConversation = Boolean(this.get('isArchived'));
log.info(
`beforeMessageSend(${this.idForLogging()}): ` +
`clearDraft(${!dontClearDraft}) addMessage(${!dontAddMessage})`
);
if (!dontAddMessage) {
this.doAddSingleMessage(message, { isJustSent: true });
}
const draftProperties = dontClearDraft
? {}
: {
draft: '',
draftEditMessage: undefined,
draftBodyRanges: [],
draftTimestamp: null,
quotedMessageId: undefined,
lastMessageAuthor: message.getAuthorText(),
lastMessage: message.getNotificationText(),
lastMessageStatus: 'sending' as const,
};
this.set({
...draftProperties,
...(enabledProfileSharing ? { profileSharing: true } : {}),
...(dontAddMessage
? {}
: this.incrementSentMessageCount({ dry: true })),
active_at: now,
timestamp: now,
...(unarchivedConversation ? { isArchived: false } : {}),
});
if (enabledProfileSharing) {
this.captureChange('beforeMessageSend/mandatoryProfileSharing');
}
if (unarchivedConversation) {
this.captureChange('beforeMessageSend/unarchive');
}
extraReduxActions?.();
});
}
async enqueueMessageForSend(
{
attachments,
@ -4117,14 +4199,14 @@ export class ConversationModel extends window.Backbone
}: {
attachments: Array<AttachmentType>;
body: string | undefined;
contact?: Array<ContactWithHydratedAvatar>;
contact?: Array<EmbeddedContactWithHydratedAvatar>;
bodyRanges?: DraftBodyRanges;
preview?: Array<LinkPreviewType>;
preview?: Array<LinkPreviewWithHydratedData>;
quote?: QuotedMessageType;
sticker?: StickerWithHydratedData;
},
{
dontClearDraft,
dontClearDraft = false,
sendHQImages,
storyId,
timestamp,
@ -4156,10 +4238,6 @@ export class ConversationModel extends window.Backbone
this.clearTypingTimers();
const mandatoryProfileSharingEnabled = window.Signal.RemoteConfig.isEnabled(
'desktop.mandatoryProfileSharing'
);
let expirationStartTimestamp: number | undefined;
let expireTimer: DurationInSeconds | undefined;
@ -4231,7 +4309,24 @@ export class ConversationModel extends window.Backbone
const model = new window.Whisper.Message(attributes);
const message = window.MessageController.register(model.id, model);
message.cachedOutgoingContactData = contact;
message.cachedOutgoingPreviewData = preview;
// Attach path to preview images so that sendNormalMessage can use them to
// update digests on attachments.
if (preview) {
message.cachedOutgoingPreviewData = preview.map((item, index) => {
if (!item.image) {
return item;
}
return {
...item,
image: {
...item.image,
path: attributes.preview?.at(index)?.image?.path,
},
};
});
}
message.cachedOutgoingQuoteData = quote;
message.cachedOutgoingStickerData = sticker;
@ -4278,53 +4373,12 @@ export class ConversationModel extends window.Backbone
await addStickerPackReference(model.id, sticker.packId);
}
this.isInReduxBatch = true;
batchDispatch(() => {
try {
const { clearUnreadMetrics } = window.reduxActions.conversations;
clearUnreadMetrics(this.id);
const enabledProfileSharing = Boolean(
mandatoryProfileSharingEnabled && !this.get('profileSharing')
);
const unarchivedConversation = Boolean(this.get('isArchived'));
this.doAddSingleMessage(model, { isJustSent: true });
log.info(
`enqueueMessageForSend(${this.idForLogging()}): clearDraft(${!dontClearDraft})`
);
const draftProperties = dontClearDraft
? {}
: {
draft: '',
draftBodyRanges: [],
draftTimestamp: null,
lastMessageAuthor: model.getAuthorText(),
lastMessage: model.getNotificationText(),
lastMessageStatus: 'sending' as const,
};
this.set({
...draftProperties,
...(enabledProfileSharing ? { profileSharing: true } : {}),
...this.incrementSentMessageCount({ dry: true }),
active_at: now,
timestamp: now,
...(unarchivedConversation ? { isArchived: false } : {}),
});
if (enabledProfileSharing) {
this.captureChange('enqueueMessageForSend/mandatoryProfileSharing');
}
if (unarchivedConversation) {
this.captureChange('enqueueMessageForSend/unarchive');
}
extraReduxActions?.();
} finally {
this.isInReduxBatch = false;
}
this.beforeMessageSend({
message: model,
dontClearDraft,
dontAddMessage: false,
now,
extraReduxActions,
});
const renderDuration = Date.now() - renderStart;

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -4,7 +4,7 @@
import path from 'path';
import { debounce, isEqual } from 'lodash';
import type { ThunkAction } from 'redux-thunk';
import type { ThunkAction, ThunkDispatch } from 'redux-thunk';
import type { ReadonlyDeep } from 'type-fest';
import type {
@ -87,6 +87,7 @@ import { longRunningTaskWrapper } from '../../util/longRunningTaskWrapper';
import { drop } from '../../util/drop';
import { strictAssert } from '../../util/assert';
import { makeQuote } from '../../util/makeQuote';
import { sendEditedMessage as doSendEditedMessage } from '../../util/sendEditedMessage';
import { maybeBlockSendForFormattingModal } from '../../util/maybeBlockSendForFormattingModal';
// State
@ -138,7 +139,7 @@ const ADD_PENDING_ATTACHMENT = 'composer/ADD_PENDING_ATTACHMENT';
const INCREMENT_SEND_COUNTER = 'composer/INCREMENT_SEND_COUNTER';
const REPLACE_ATTACHMENTS = 'composer/REPLACE_ATTACHMENTS';
const RESET_COMPOSER = 'composer/RESET_COMPOSER';
const SET_FOCUS = 'composer/SET_FOCUS';
export const SET_FOCUS = 'composer/SET_FOCUS';
const SET_HIGH_QUALITY_SETTING = 'composer/SET_HIGH_QUALITY_SETTING';
const SET_QUOTED_MESSAGE = 'composer/SET_QUOTED_MESSAGE';
const SET_COMPOSER_DISABLED = 'composer/SET_COMPOSER_DISABLED';
@ -238,6 +239,7 @@ export const actions = {
replaceAttachments,
resetComposer,
scrollToQuotedMessage,
sendEditedMessage,
sendMultiMediaMessage,
sendStickerMessage,
setComposerDisabledState,
@ -304,6 +306,7 @@ function onCloseLinkPreview(conversationId: string): NoopActionType {
payload: null,
};
}
function onTextTooLong(): ShowToastActionType {
return {
type: SHOW_TOAST,
@ -377,14 +380,159 @@ export function handleLeaveConversation(
};
}
// eslint-disable-next-line local-rules/type-alias-readonlydeep
type WithPreSendChecksOptions = Readonly<{
bodyRanges?: DraftBodyRanges;
message?: string;
voiceNoteAttachment?: InMemoryAttachmentDraftType;
}>;
async function withPreSendChecks(
conversationId: string,
options: WithPreSendChecksOptions,
dispatch: ThunkDispatch<
RootStateType,
unknown,
SetComposerDisabledStateActionType | ShowToastActionType
>,
body: () => Promise<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(
conversationId: string,
options: {
options: WithPreSendChecksOptions & {
draftAttachments?: ReadonlyArray<AttachmentDraftType>;
bodyRanges?: DraftBodyRanges;
message?: string;
timestamp?: number;
voiceNoteAttachment?: InMemoryAttachmentDraftType;
}
): ThunkAction<
void,
@ -413,73 +561,7 @@ function sendMultiMediaMessage(
const state = getState();
const sendStart = Date.now();
const recipientsByConversation = getRecipientsByConversation([
conversation.attributes,
]);
try {
dispatch(setComposerDisabledState(conversationId, true));
const sendAnyway = await blockSendUntilConversationsAreVerified(
recipientsByConversation,
SafetyNumberChangeSource.MessageSend
);
if (!sendAnyway) {
dispatch(setComposerDisabledState(conversationId, false));
return;
}
} catch (error) {
dispatch(setComposerDisabledState(conversationId, false));
log.error(
'sendMessage block until verified error:',
Errors.toLogFormat(error)
);
return;
}
try {
if (bodyRanges?.length && !window.storage.get('formattingWarningShown')) {
const sendAnyway = await maybeBlockSendForFormattingModal(bodyRanges);
if (!sendAnyway) {
dispatch(setComposerDisabledState(conversationId, false));
return;
}
drop(window.storage.put('formattingWarningShown', true));
}
} catch (error) {
dispatch(setComposerDisabledState(conversationId, false));
log.error(
'sendMessage block for formatting modal:',
Errors.toLogFormat(error)
);
return;
}
conversation.clearTypingTimers();
const toast = shouldShowInvalidMessageToast(conversation.attributes);
if (toast != null) {
dispatch({
type: SHOW_TOAST,
payload: toast,
});
dispatch(setComposerDisabledState(conversationId, false));
return;
}
if (
!message.length &&
!hasDraftAttachments(conversation.attributes.draftAttachments, {
includePending: false,
}) &&
!voiceNoteAttachment
) {
dispatch(setComposerDisabledState(conversationId, false));
return;
}
try {
await withPreSendChecks(conversationId, options, dispatch, async () => {
let attachments: Array<AttachmentType> = [];
if (voiceNoteAttachment) {
attachments = [voiceNoteAttachment];
@ -505,48 +587,45 @@ function sendMultiMediaMessage(
? shouldSendHighQualityAttachments
: state.items['sent-media-quality'] === 'high';
const sendDelta = Date.now() - sendStart;
log.info('Send pre-checks took', sendDelta, 'milliseconds');
await conversation.enqueueMessageForSend(
{
body: message,
attachments,
quote,
preview: getLinkPreviewForSend(message),
bodyRanges,
},
{
sendHQImages,
timestamp,
// We rely on enqueueMessageForSend to call these within redux's batch
extraReduxActions: () => {
conversation.setMarkedUnread(false);
resetLinkPreview(conversationId);
drop(
clearConversationDraftAttachments(
conversationId,
draftAttachments
)
);
setQuoteByMessageId(conversationId, undefined)(
dispatch,
getState,
undefined
);
dispatch(incrementSendCounter(conversationId));
dispatch(setComposerDisabledState(conversationId, false));
try {
await conversation.enqueueMessageForSend(
{
body: message,
attachments,
quote,
preview: getLinkPreviewForSend(message),
bodyRanges,
},
}
);
} catch (error) {
log.error(
'Error pulling attached files before send',
Errors.toLogFormat(error)
);
dispatch(setComposerDisabledState(conversationId, false));
}
{
sendHQImages,
timestamp,
// We rely on enqueueMessageForSend to call these within redux's batch
extraReduxActions: () => {
conversation.setMarkedUnread(false);
resetLinkPreview(conversationId);
drop(
clearConversationDraftAttachments(
conversationId,
draftAttachments
)
);
setQuoteByMessageId(conversationId, undefined)(
dispatch,
getState,
undefined
);
dispatch(incrementSendCounter(conversationId));
dispatch(setComposerDisabledState(conversationId, false));
},
}
);
} catch (error) {
log.error(
'Error pulling attached files before send',
Errors.toLogFormat(error)
);
}
});
};
}
@ -668,6 +747,7 @@ export function setQuoteByMessageId(
window.Signal.Data.updateConversation(conversation.attributes);
}
const draftEditMessage = conversation.get('draftEditMessage');
if (message) {
const quote = await makeQuote(message.attributes);
@ -676,15 +756,31 @@ export function setQuoteByMessageId(
return;
}
dispatch(
setQuotedMessage(conversationId, {
conversationId,
quote,
})
);
if (draftEditMessage) {
conversation.set({
draftEditMessage: {
...draftEditMessage,
quote,
},
});
} else {
dispatch(
setQuotedMessage(conversationId, {
conversationId,
quote,
})
);
}
dispatch(setComposerFocus(conversation.id));
dispatch(setComposerDisabledState(conversationId, false));
} else if (draftEditMessage) {
conversation.set({
draftEditMessage: {
...draftEditMessage,
quote: undefined,
},
});
} else {
dispatch(setQuotedMessage(conversationId, undefined));
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -11,6 +11,7 @@ import {
decryptProfileName,
encryptProfile,
decryptProfile,
getAttachmentSizeBucket,
getRandomBytes,
constantTimeEqual,
generateRegistrationId,
@ -30,6 +31,36 @@ import {
bytesToUuid,
} from '../Crypto';
const BUCKET_SIZES = [
541, 568, 596, 626, 657, 690, 725, 761, 799, 839, 881, 925, 972, 1020, 1071,
1125, 1181, 1240, 1302, 1367, 1436, 1507, 1583, 1662, 1745, 1832, 1924, 2020,
2121, 2227, 2339, 2456, 2579, 2708, 2843, 2985, 3134, 3291, 3456, 3629, 3810,
4001, 4201, 4411, 4631, 4863, 5106, 5361, 5629, 5911, 6207, 6517, 6843, 7185,
7544, 7921, 8318, 8733, 9170, 9629, 10110, 10616, 11146, 11704, 12289, 12903,
13549, 14226, 14937, 15684, 16469, 17292, 18157, 19065, 20018, 21019, 22070,
23173, 24332, 25549, 26826, 28167, 29576, 31054, 32607, 34238, 35950, 37747,
39634, 41616, 43697, 45882, 48176, 50585, 53114, 55770, 58558, 61486, 64561,
67789, 71178, 74737, 78474, 82398, 86518, 90843, 95386, 100155, 105163,
110421, 115942, 121739, 127826, 134217, 140928, 147975, 155373, 163142,
171299, 179864, 188858, 198300, 208215, 218626, 229558, 241036, 253087,
265742, 279029, 292980, 307629, 323011, 339161, 356119, 373925, 392622,
412253, 432866, 454509, 477234, 501096, 526151, 552458, 580081, 609086,
639540, 671517, 705093, 740347, 777365, 816233, 857045, 899897, 944892,
992136, 1041743, 1093831, 1148522, 1205948, 1266246, 1329558, 1396036,
1465838, 1539130, 1616086, 1696890, 1781735, 1870822, 1964363, 2062581,
2165710, 2273996, 2387695, 2507080, 2632434, 2764056, 2902259, 3047372,
3199740, 3359727, 3527714, 3704100, 3889305, 4083770, 4287958, 4502356,
4727474, 4963848, 5212040, 5472642, 5746274, 6033588, 6335268, 6652031,
6984633, 7333864, 7700558, 8085585, 8489865, 8914358, 9360076, 9828080,
10319484, 10835458, 11377231, 11946092, 12543397, 13170567, 13829095,
14520550, 15246578, 16008907, 16809352, 17649820, 18532311, 19458926,
20431872, 21453466, 22526139, 23652446, 24835069, 26076822, 27380663,
28749697, 30187181, 31696540, 33281368, 34945436, 36692708, 38527343,
40453710, 42476396, 44600216, 46830227, 49171738, 51630325, 54211841,
56922433, 59768555, 62756983, 65894832, 69189573, 72649052, 76281505,
80095580, 84100359, 88305377, 92720646, 97356678, 102224512, 107335738,
];
describe('Crypto', () => {
describe('encrypting and decrypting profile data', () => {
const NAME_PADDED_LENGTH = 53;
@ -507,4 +538,53 @@ describe('Crypto', () => {
assert.isUndefined(bytesToUuid(new Uint8Array(Array(17).fill(0x22))));
});
});
describe('getAttachmentSizeBucket', () => {
it('properly calculates first bucket', () => {
for (let size = 0, max = BUCKET_SIZES[0]; size < max; size += 1) {
assert.strictEqual(BUCKET_SIZES[0], getAttachmentSizeBucket(size));
}
});
it('properly calculates entire table', () => {
let count = 0;
const failures = new Array<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: [
{
contentType: IMAGE_JPEG,
digest: 'digest',
size: 0,
},
],
@ -961,6 +962,7 @@ describe('both/state/ducks/stories', () => {
url: 'https://signal.org',
image: {
contentType: IMAGE_JPEG,
digest: 'digest-1',
size: 0,
},
};
@ -969,6 +971,7 @@ describe('both/state/ducks/stories', () => {
attachments: [
{
contentType: TEXT_ATTACHMENT,
digest: 'digest-2',
size: 0,
textAttachment: {
preview,

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

View File

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

View File

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

View File

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

View File

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

View File

@ -11,17 +11,22 @@ import {
format as formatPhoneNumber,
parse as parsePhoneNumber,
} from './PhoneNumber';
import type { AttachmentType, migrateDataToFileSystem } from './Attachment';
import type {
AttachmentType,
AttachmentWithHydratedData,
UploadedAttachmentType,
migrateDataToFileSystem,
} from './Attachment';
import { toLogFormat } from './errors';
import type { LoggerType } from './Logging';
import type { UUIDStringType } from './UUID';
export type EmbeddedContactType = {
type GenericEmbeddedContactType<AvatarType> = {
name?: Name;
number?: Array<Phone>;
email?: Array<Email>;
address?: Array<PostalAddress>;
avatar?: Avatar;
avatar?: AvatarType;
organization?: string;
// Populated by selector
@ -29,6 +34,12 @@ export type EmbeddedContactType = {
uuid?: UUIDStringType;
};
export type EmbeddedContactType = GenericEmbeddedContactType<Avatar>;
export type EmbeddedContactWithHydratedAvatar =
GenericEmbeddedContactType<AvatarWithHydratedData>;
export type EmbeddedContactWithUploadedAvatar =
GenericEmbeddedContactType<UploadedAvatar>;
type Name = {
givenName?: string;
familyName?: string;
@ -75,11 +86,15 @@ export type PostalAddress = {
country?: string;
};
export type Avatar = {
avatar: AttachmentType;
type GenericAvatar<Attachment> = {
avatar: Attachment;
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_EMAIL_TYPE = Proto.DataMessage.Contact.Email.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 { replaceEmojiWithSpaces } from '../util/emoji';
import type { AttachmentType } from './Attachment';
import type { AttachmentWithHydratedData } from './Attachment';
export type LinkPreviewImage = AttachmentType & {
data: Uint8Array;
};
export type LinkPreviewImage = AttachmentWithHydratedData;
export type LinkPreviewResult = {
title: string | null;

View File

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

View File

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

View File

@ -1,14 +1,18 @@
// Copyright 2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import type { AttachmentType } from '../Attachment';
import type { AttachmentType, AttachmentWithHydratedData } from '../Attachment';
export type LinkPreviewType = {
type GenericLinkPreviewType<Image> = {
title?: string;
description?: string;
domain?: string;
url: string;
isStickerPack?: boolean;
image?: Readonly<AttachmentType>;
image?: Readonly<Image>;
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 { EditAttributesType } from '../messageModifiers/Edits';
import type { EditHistoryType, MessageAttributesType } from '../model-types.d';
import type {
EditHistoryType,
MessageAttributesType,
QuotedMessageType,
} from '../model-types.d';
import type { LinkPreviewType } from '../types/message/LinkPreviews';
import * as log from '../logging/log';
import { ReadStatus } from '../messages/MessageReadStatus';
import dataInterface from '../sql/Client';
import { drop } from './drop';
import {
getAttachmentSignature,
isDownloaded,
isVoiceMessage,
} from '../types/Attachment';
import { getAttachmentSignature, isVoiceMessage } from '../types/Attachment';
import { getMessageIdForLogging } from './idForLogging';
import { hasErrors } from '../state/selectors/message';
import { isIncoming, isOutgoing } from '../messages/helpers';
@ -56,7 +56,7 @@ export async function handleEditMessage(
// Pull out the edit history from the main message. If this is the first edit
// then the original message becomes the first item in the edit history.
const editHistory: Array<EditHistoryType> = mainMessage.editHistory || [
let editHistory: Array<EditHistoryType> = mainMessage.editHistory || [
{
attachments: mainMessage.attachments,
body: mainMessage.body,
@ -76,46 +76,59 @@ export async function handleEditMessage(
return;
}
const messageAttributesForUpgrade: MessageAttributesType = {
...editAttributes.message,
...editAttributes.dataMessage,
// There are type conflicts between MessageAttributesType and protos passed in here
// eslint-disable-next-line @typescript-eslint/no-explicit-any
} as any as MessageAttributesType;
const upgradedEditedMessageData =
await window.Signal.Migrations.upgradeMessageSchema(
messageAttributesForUpgrade
);
await window.Signal.Migrations.upgradeMessageSchema(editAttributes.message);
// Copies over the attachments from the main message if they're the same
// and they have already been downloaded.
const attachmentSignatures: Map<string, AttachmentType> = new Map();
const previewSignatures: Map<string, LinkPreviewType> = new Map();
const quoteSignatures: Map<string, AttachmentType> = new Map();
mainMessage.attachments?.forEach(attachment => {
if (!isDownloaded(attachment)) {
return;
}
const signature = getAttachmentSignature(attachment);
attachmentSignatures.set(signature, attachment);
if (signature) {
attachmentSignatures.set(signature, attachment);
}
});
mainMessage.preview?.forEach(preview => {
if (!preview.image || !isDownloaded(preview.image)) {
if (!preview.image) {
return;
}
const signature = getAttachmentSignature(preview.image);
previewSignatures.set(signature, preview);
if (signature) {
previewSignatures.set(signature, preview);
}
});
if (mainMessage.quote) {
for (const attachment of mainMessage.quote.attachments) {
if (!attachment.thumbnail) {
continue;
}
const signature = getAttachmentSignature(attachment.thumbnail);
if (signature) {
quoteSignatures.set(signature, attachment);
}
}
}
let newAttachments = 0;
const nextEditedMessageAttachments =
upgradedEditedMessageData.attachments?.map(attachment => {
const signature = getAttachmentSignature(attachment);
const existingAttachment = attachmentSignatures.get(signature);
const existingAttachment = signature
? attachmentSignatures.get(signature)
: undefined;
return existingAttachment || attachment;
if (existingAttachment) {
return existingAttachment;
}
newAttachments += 1;
return attachment;
});
let newPreviews = 0;
const nextEditedMessagePreview = upgradedEditedMessageData.preview?.map(
preview => {
if (!preview.image) {
@ -123,22 +136,69 @@ export async function handleEditMessage(
}
const signature = getAttachmentSignature(preview.image);
const existingPreview = previewSignatures.get(signature);
return existingPreview || preview;
const existingPreview = signature
? previewSignatures.get(signature)
: undefined;
if (existingPreview) {
return existingPreview;
}
newPreviews += 1;
return preview;
}
);
let newQuoteThumbnails = 0;
const { quote: upgradedQuote } = upgradedEditedMessageData;
let nextEditedMessageQuote: QuotedMessageType | undefined;
if (!upgradedQuote) {
// Quote dropped
log.info(`${idLog}: dropping quote`);
} else if (!upgradedQuote.id || upgradedQuote.id === mainMessage.quote?.id) {
// Quote preserved
nextEditedMessageQuote = mainMessage.quote;
} else {
// Quote updated!
nextEditedMessageQuote = {
...upgradedQuote,
attachments: upgradedQuote.attachments.map(attachment => {
if (!attachment.thumbnail) {
return attachment;
}
const signature = getAttachmentSignature(attachment.thumbnail);
const existingThumbnail = signature
? quoteSignatures.get(signature)
: undefined;
if (existingThumbnail) {
return {
...attachment,
thumbnail: existingThumbnail,
};
}
newQuoteThumbnails += 1;
return attachment;
}),
};
}
log.info(
`${idLog}: editing message, added ${newAttachments} attachments, ` +
`${newPreviews} previews, ${newQuoteThumbnails} quote thumbnails`
);
const editedMessage: EditHistoryType = {
attachments: nextEditedMessageAttachments,
body: upgradedEditedMessageData.body,
bodyRanges: upgradedEditedMessageData.bodyRanges,
preview: nextEditedMessagePreview,
timestamp: upgradedEditedMessageData.timestamp,
quote: nextEditedMessageQuote,
};
// The edit history works like a queue where the newest edits are at the top.
// Here we unshift the latest edit onto the edit history.
editHistory.unshift(editedMessage);
editHistory = [editedMessage, ...editHistory];
// Update all the editable attributes on the main message also updating the
// edit history.
@ -149,6 +209,7 @@ export async function handleEditMessage(
editHistory,
editMessageTimestamp: upgradedEditedMessageData.timestamp,
preview: editedMessage.preview,
quote: editedMessage.quote,
});
// Queue up any downloads in case they're different, update the fields if so.

View File

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

View File

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

View File

@ -28,6 +28,7 @@ import {
} from '../types/Attachment';
import type { StickerType } from '../types/Stickers';
import type { LinkPreviewType } from '../types/message/LinkPreviews';
import { isNotNil } from './isNotNil';
type ReturnType = {
bodyAttachment?: AttachmentType;
@ -111,6 +112,18 @@ export async function queueAttachmentDownloads(
);
count += previewCount;
log.info(
`${idLog}: Queueing ${message.quote?.attachments?.length ?? 0} ` +
'quote attachment downloads'
);
const { quote, count: thumbnailCount } = await queueQuoteAttachments(
idLog,
messageId,
message.quote,
message.editHistory?.map(x => x.quote).filter(isNotNil) ?? []
);
count += thumbnailCount;
const contactsToQueue = message.contact || [];
log.info(
`${idLog}: Queueing ${contactsToQueue.length} contact attachment downloads`
@ -141,40 +154,6 @@ export async function queueAttachmentDownloads(
})
);
let { quote } = message;
const quoteAttachmentsToQueue =
quote && quote.attachments ? quote.attachments : [];
log.info(
`${idLog}: Queueing ${quoteAttachmentsToQueue.length} quote attachment downloads`
);
if (quote && quoteAttachmentsToQueue.length > 0) {
quote = {
...quote,
attachments: await Promise.all(
(quote?.attachments || []).map(async (item, index) => {
if (!item.thumbnail) {
return item;
}
// We've already downloaded this!
if (item.thumbnail.path) {
log.info(`${idLog}: Quote attachment already downloaded`);
return item;
}
count += 1;
return {
...item,
thumbnail: await AttachmentDownloads.addJob(item.thumbnail, {
messageId,
type: 'quote',
index,
}),
};
})
),
};
}
let { sticker } = message;
if (sticker && sticker.data && sticker.data.path) {
log.info(`${idLog}: Sticker attachment already downloaded`);
@ -226,11 +205,6 @@ export async function queueAttachmentDownloads(
log.info(`${idLog}: Looping through ${editHistory.length} edits`);
editHistory = await Promise.all(
editHistory.map(async edit => {
const editAttachmentsToQueue = edit.attachments || [];
log.info(
`${idLog}: Queueing ${editAttachmentsToQueue.length} normal attachment downloads (edited:${edit.timestamp})`
);
const { attachments: editAttachments, count: editAttachmentsCount } =
await queueNormalAttachments(
idLog,
@ -239,15 +213,22 @@ export async function queueAttachmentDownloads(
attachments
);
count += editAttachmentsCount;
if (editAttachmentsCount !== 0) {
log.info(
`${idLog}: Queueing ${editAttachmentsCount} normal attachment ` +
`downloads (edited:${edit.timestamp})`
);
}
log.info(
`${idLog}: Queueing ${
(edit.preview || []).length
} preview attachment downloads (edited:${edit.timestamp})`
);
const { preview: editPreview, count: editPreviewCount } =
await queuePreviews(idLog, messageId, edit.preview, preview);
count += editPreviewCount;
if (editPreviewCount !== 0) {
log.info(
`${idLog}: Queueing ${editPreviewCount} preview attachment ` +
`downloads (edited:${edit.timestamp})`
);
}
return {
...edit,
@ -293,7 +274,9 @@ async function queueNormalAttachments(
const attachmentSignatures: Map<string, AttachmentType> = new Map();
otherAttachments?.forEach(attachment => {
const signature = getAttachmentSignature(attachment);
attachmentSignatures.set(signature, attachment);
if (signature) {
attachmentSignatures.set(signature, attachment);
}
});
let count = 0;
@ -415,3 +398,98 @@ async function queuePreviews(
count,
};
}
function getQuoteThumbnailSignature(
quote: QuotedMessageType,
thumbnail?: AttachmentType
): string | undefined {
if (!thumbnail) {
return undefined;
}
return `<${quote.id}>${getAttachmentSignature(thumbnail)}`;
}
async function queueQuoteAttachments(
idLog: string,
messageId: string,
quote: QuotedMessageType | undefined,
otherQuotes: ReadonlyArray<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 linkPreview = attachment?.textAttachment?.preview;
const { loadPreviewData } = window.Signal.Migrations;
const sanitizedLinkPreview = linkPreview
? sanitizeLinkPreview(linkPreview)
? sanitizeLinkPreview((await loadPreviewData([linkPreview]))[0])
: undefined;
// If a text attachment has a link preview we remove it from the
// textAttachment data structure and instead process the preview and add

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