Send edited messages support
Co-authored-by: Fedor Indutnyy <indutny@signal.org>
This commit is contained in:
parent
d380817a44
commit
1f2cde6d04
79 changed files with 2507 additions and 1175 deletions
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…
Add table
Add a link
Reference in a new issue