Send edited messages support
Co-authored-by: Fedor Indutnyy <indutny@signal.org>
This commit is contained in:
parent
d380817a44
commit
1f2cde6d04
|
@ -2161,6 +2161,9 @@
|
|||
"icu:accept": {
|
||||
"messageformat": "Accept"
|
||||
},
|
||||
"icu:edit": {
|
||||
"messageformat": "Edit"
|
||||
},
|
||||
"forward": {
|
||||
"message": "Forward",
|
||||
"description": "(deleted 03/29/2023)"
|
||||
|
@ -3538,6 +3541,10 @@
|
|||
"messageformat": "Delete failed",
|
||||
"description": "Shown on a message which was deleted for everyone if the delete wasn't successfully sent to anyone"
|
||||
},
|
||||
"icu:editFailed": {
|
||||
"messageformat": "Edit failed, click for details",
|
||||
"description": "Shown on a message which was edited if the edit wasn't successfully sent to anyone"
|
||||
},
|
||||
"sendPaused": {
|
||||
"message": "Send paused",
|
||||
"description": "(deleted 03/29/2023) Shown on outgoing message if it cannot be sent immediately"
|
||||
|
@ -4102,6 +4109,10 @@
|
|||
"messageformat": "Failed to fetch phone number. Check your connection and try again.",
|
||||
"description": "Shown if request to Signal servers to find phone number fails"
|
||||
},
|
||||
"icu:ToastManager__CannotEditMessage": {
|
||||
"messageformat": "Edits can only be applied within 3 hours from the time you sent this message.",
|
||||
"description": "Error message when you try to send an edit after message becomes too old"
|
||||
},
|
||||
"startConversation--username-not-found": {
|
||||
"message": "User not found. $atUsername$ is not a Signal user; make sure you’ve entered the complete username.",
|
||||
"description": "(deleted 03/29/2023) Shown in dialog if username is not found. Note that 'username' will be the output of at-username"
|
||||
|
@ -8355,6 +8366,18 @@
|
|||
"messageformat": "Checking contact's registration status",
|
||||
"description": "Displayed while checking if the contact is SMS-only"
|
||||
},
|
||||
"icu:CompositionArea__edit-action--discard": {
|
||||
"messageformat": "Discard message",
|
||||
"description": "aria-label for discard edit button"
|
||||
},
|
||||
"icu:CompositionArea__edit-action--send": {
|
||||
"messageformat": "Send edited message",
|
||||
"description": "aria-label for send edit button"
|
||||
},
|
||||
"icu:CompositionInput__editing-message": {
|
||||
"messageformat": "Edit message",
|
||||
"description": "Status text displayed above composition input when editing a message"
|
||||
},
|
||||
"countMutedConversationsDescription": {
|
||||
"message": "Include muted conversations in badge count",
|
||||
"description": "(deleted 03/29/2023) Description for counting muted conversations in badge setting"
|
||||
|
@ -12363,6 +12386,14 @@
|
|||
"messageformat": "Edit history",
|
||||
"description": "Modal title for the edit history messages modal"
|
||||
},
|
||||
"icu:ResendMessageEdit__body": {
|
||||
"messageformat": "This edit could not be sent. Check your connection and try again",
|
||||
"description": "Modal body for the confirmation dialog shown to user when attempting to resend message edit"
|
||||
},
|
||||
"icu:ResendMessageEdit__button": {
|
||||
"messageformat": "Send again",
|
||||
"description": "Button text for the confirmation dialog shown to user when attempting to resend message edit"
|
||||
},
|
||||
"WhatsNew__modal-title": {
|
||||
"message": "What's New",
|
||||
"description": "(deleted 03/29/2023) Title for the whats new modal"
|
||||
|
|
3
images/icons/v3/check.svg
Normal file
3
images/icons/v3/check.svg
Normal 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
3
images/icons/v3/edit.svg
Normal 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
3
images/icons/v3/x.svg
Normal 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 |
|
@ -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(
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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 {
|
||||
|
|
21
stylesheets/components/EditHistoryMessagesModal.scss
Normal file
21
stylesheets/components/EditHistoryMessagesModal.scss
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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';
|
||||
|
|
22
ts/Crypto.ts
22
ts/Crypto.ts
|
@ -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) {
|
||||
|
|
|
@ -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'
|
||||
|
|
|
@ -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,
|
||||
};
|
||||
|
||||
|
|
|
@ -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'),
|
||||
|
|
|
@ -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}
|
||||
|
|
|
@ -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'),
|
||||
|
|
|
@ -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}
|
||||
|
|
|
@ -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 => {
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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);
|
||||
}}
|
||||
|
|
|
@ -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:
|
||||
|
|
|
@ -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}>
|
||||
|
|
|
@ -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}
|
||||
|
|
|
@ -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}
|
||||
|
|
|
@ -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(
|
||||
|
|
|
@ -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}
|
||||
|
|
|
@ -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}
|
||||
|
|
|
@ -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'),
|
||||
|
|
|
@ -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'),
|
||||
|
|
|
@ -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'),
|
||||
|
|
|
@ -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}
|
||||
|
|
|
@ -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',
|
||||
|
|
|
@ -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:
|
||||
|
|
|
@ -8,6 +8,7 @@ import { useChain, useSpring, useSpringRef } from '@react-spring/web';
|
|||
export type ModalConfigType = {
|
||||
opacity: number;
|
||||
transform?: string;
|
||||
marginTop?: string;
|
||||
};
|
||||
|
||||
enum ModalState {
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
@ -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)
|
||||
);
|
||||
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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({
|
||||
|
|
|
@ -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({
|
||||
|
|
|
@ -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
14
ts/model-types.d.ts
vendored
|
@ -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;
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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>) {
|
||||
|
|
|
@ -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 {
|
||||
|
|
15
ts/signal.ts
15
ts/signal.ts
|
@ -42,9 +42,14 @@ import type {
|
|||
} from './types/Attachment';
|
||||
import type { MessageAttributesType, QuotedMessageType } from './model-types.d';
|
||||
import type { SignalCoreType } from './window.d';
|
||||
import type { EmbeddedContactType } from './types/EmbeddedContact';
|
||||
import type { ContactWithHydratedAvatar } from './textsecure/SendMessage';
|
||||
import type { LinkPreviewType } from './types/message/LinkPreviews';
|
||||
import type {
|
||||
EmbeddedContactType,
|
||||
EmbeddedContactWithHydratedAvatar,
|
||||
} from './types/EmbeddedContact';
|
||||
import type {
|
||||
LinkPreviewType,
|
||||
LinkPreviewWithHydratedData,
|
||||
} from './types/message/LinkPreviews';
|
||||
import type { StickerType, StickerWithHydratedData } from './types/Stickers';
|
||||
|
||||
type MigrationsModuleType = {
|
||||
|
@ -75,13 +80,13 @@ type MigrationsModuleType = {
|
|||
) => Promise<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>;
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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 }>
|
||||
> {
|
||||
|
|
|
@ -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));
|
||||
}
|
||||
|
|
|
@ -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> {
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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),
|
||||
|
|
|
@ -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}
|
||||
|
|
|
@ -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}
|
||||
|
|
|
@ -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'));
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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'));
|
||||
});
|
||||
});
|
||||
});
|
|
@ -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,
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
|
1
ts/textsecure/Types.d.ts
vendored
1
ts/textsecure/Types.d.ts
vendored
|
@ -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.
|
||||
|
|
|
@ -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',
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
|
|
13
ts/types/ErrorWithToast.ts
Normal file
13
ts/types/ErrorWithToast.ts
Normal 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;
|
||||
}
|
||||
}
|
|
@ -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;
|
||||
|
|
|
@ -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),
|
||||
};
|
||||
}
|
||||
)
|
||||
);
|
||||
};
|
||||
};
|
||||
|
|
|
@ -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 }
|
||||
|
|
|
@ -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>;
|
||||
|
|
8
ts/util/canEditMessages.ts
Normal file
8
ts/util/canEditMessages.ts
Normal 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');
|
||||
}
|
19
ts/util/copyDataMessageIntoMessage.ts
Normal file
19
ts/util/copyDataMessageIntoMessage.ts
Normal 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;
|
||||
}
|
|
@ -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.
|
||||
|
|
|
@ -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 } =
|
||||
|
|
|
@ -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;
|
||||
};
|
||||
|
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
|
243
ts/util/sendEditedMessage.ts
Normal file
243
ts/util/sendEditedMessage.ts
Normal 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);
|
||||
}
|
|
@ -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
|
||||
|
|
20
ts/util/timeAndLogIfTooLong.ts
Normal file
20
ts/util/timeAndLogIfTooLong.ts
Normal 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));
|
||||
}
|
||||
}
|
||||
}
|
41
ts/util/uploadAttachment.ts
Normal file
41
ts/util/uploadAttachment.ts
Normal 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,
|
||||
};
|
||||
}
|
Loading…
Reference in New Issue
Block a user