Send edited messages support

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -2,6 +2,7 @@
// SPDX-License-Identifier: AGPL-3.0-only
import { isNumber } from 'lodash';
import PQueue from 'p-queue';
import * as Errors from '../../types/errors';
import { strictAssert } from '../../util/assert';
@ -13,20 +14,30 @@ import { getSendOptions } from '../../util/getSendOptions';
import { SignalService as Proto } from '../../protobuf';
import { handleMessageSend } from '../../util/handleMessageSend';
import { findAndFormatContact } from '../../util/findAndFormatContact';
import { uploadAttachment } from '../../util/uploadAttachment';
import type { CallbackResultType } from '../../textsecure/Types.d';
import { isSent } from '../../messages/MessageSendState';
import { isOutgoing, canReact } from '../../state/selectors/message';
import type {
AttachmentType,
ContactWithHydratedAvatar,
ReactionType,
OutgoingQuoteType,
OutgoingQuoteAttachmentType,
OutgoingLinkPreviewType,
OutgoingStickerType,
} from '../../textsecure/SendMessage';
import type { LinkPreviewType } from '../../types/message/LinkPreviews';
import type {
AttachmentType,
UploadedAttachmentType,
AttachmentWithHydratedData,
} from '../../types/Attachment';
import { LONG_MESSAGE, MIMETypeToString } from '../../types/MIME';
import type { RawBodyRange } from '../../types/BodyRange';
import type {
EmbeddedContactWithHydratedAvatar,
EmbeddedContactWithUploadedAvatar,
} from '../../types/EmbeddedContact';
import type { StoryContextType } from '../../types/Util';
import type { LoggerType } from '../../types/Logging';
import type { StickerWithHydratedData } from '../../types/Stickers';
import type { QuotedMessageType } from '../../model-types.d';
import type {
ConversationQueueJobBundle,
NormalMessageSendJobData,
@ -39,6 +50,10 @@ import { isConversationAccepted } from '../../util/isConversationAccepted';
import { sendToGroup } from '../../util/sendToGroup';
import type { DurationInSeconds } from '../../util/durations';
import type { UUIDStringType } from '../../types/UUID';
import * as Bytes from '../../Bytes';
const LONG_ATTACHMENT_LIMIT = 2048;
const MAX_CONCURRENT_ATTACHMENT_UPLOADS = 5;
export async function sendNormalMessage(
conversation: ConversationModel,
@ -149,15 +164,16 @@ export async function sendNormalMessage(
body,
contact,
deletedForEveryoneTimestamp,
editedMessageTimestamp,
expireTimer,
bodyRanges,
messageTimestamp,
preview,
quote,
reaction,
sticker,
storyMessage,
storyContext,
reaction,
} = await getMessageSendData({ log, message });
if (reaction) {
@ -211,6 +227,7 @@ export async function sendNormalMessage(
bodyRanges,
contact,
deletedForEveryoneTimestamp,
editedMessageTimestamp,
expireTimer,
groupV2: conversation.getGroupV2Info({
members: recipientIdentifiersWithoutMe,
@ -256,6 +273,7 @@ export async function sendNormalMessage(
bodyRanges,
contact,
deletedForEveryoneTimestamp,
editedMessageTimestamp,
expireTimer,
groupV2: groupV2Info,
messageText: body,
@ -309,6 +327,7 @@ export async function sendNormalMessage(
contact,
contentHint: ContentHint.RESENDABLE,
deletedForEveryoneTimestamp,
editedMessageTimestamp,
expireTimer,
groupId: undefined,
identifier: recipientIdentifiersWithoutMe[0],
@ -466,83 +485,115 @@ async function getMessageSendData({
log: LoggerType;
message: MessageModel;
}>): Promise<{
attachments: Array<AttachmentType>;
attachments: Array<UploadedAttachmentType>;
body: undefined | string;
contact?: Array<ContactWithHydratedAvatar>;
contact?: Array<EmbeddedContactWithUploadedAvatar>;
deletedForEveryoneTimestamp: undefined | number;
editedMessageTimestamp: number | undefined;
expireTimer: undefined | DurationInSeconds;
bodyRanges: undefined | ReadonlyArray<RawBodyRange>;
messageTimestamp: number;
preview: Array<LinkPreviewType>;
quote: QuotedMessageType | null;
sticker: StickerWithHydratedData | undefined;
preview: Array<OutgoingLinkPreviewType> | undefined;
quote: OutgoingQuoteType | undefined;
sticker: OutgoingStickerType | undefined;
reaction: ReactionType | undefined;
storyMessage?: MessageModel;
storyContext?: StoryContextType;
}> {
const {
loadAttachmentData,
loadContactData,
loadPreviewData,
loadQuoteData,
loadStickerData,
} = window.Signal.Migrations;
let messageTimestamp: number;
const editMessageTimestamp = message.get('editMessageTimestamp');
const sentAt = message.get('sent_at');
const timestamp = message.get('timestamp');
let mainMessageTimestamp: number;
if (sentAt) {
messageTimestamp = sentAt;
mainMessageTimestamp = sentAt;
} else if (timestamp) {
log.error('message lacked sent_at. Falling back to timestamp');
messageTimestamp = timestamp;
mainMessageTimestamp = timestamp;
} else {
log.error(
'message lacked sent_at and timestamp. Falling back to current time'
);
messageTimestamp = Date.now();
mainMessageTimestamp = Date.now();
}
const messageTimestamp = editMessageTimestamp || mainMessageTimestamp;
const storyId = message.get('storyId');
const [attachmentsWithData, contact, preview, quote, sticker, storyMessage] =
await Promise.all([
// We don't update the caches here because (1) we expect the caches to be populated
// on initial send, so they should be there in the 99% case (2) if you're retrying
// a failed message across restarts, we don't touch the cache for simplicity. If
// sends are failing, let's not add the complication of a cache.
Promise.all((message.get('attachments') ?? []).map(loadAttachmentData)),
message.cachedOutgoingContactData ||
loadContactData(message.get('contact')),
message.cachedOutgoingPreviewData ||
loadPreviewData(message.get('preview')),
message.cachedOutgoingQuoteData || loadQuoteData(message.get('quote')),
message.cachedOutgoingStickerData ||
loadStickerData(message.get('sticker')),
storyId ? getMessageById(storyId) : undefined,
]);
// Figure out if we need to upload message body as an attachment.
let body = message.get('body');
let maybeLongAttachment: AttachmentWithHydratedData | undefined;
if (body && body.length > LONG_ATTACHMENT_LIMIT) {
const data = Bytes.fromString(body);
const { body, attachments } = window.Whisper.Message.getLongMessageAttachment(
{
body: message.get('body'),
attachments: attachmentsWithData,
now: messageTimestamp,
}
);
maybeLongAttachment = {
contentType: LONG_MESSAGE,
fileName: `long-message-${messageTimestamp}.txt`,
data,
size: data.byteLength,
};
body = body.slice(0, LONG_ATTACHMENT_LIMIT);
}
const uploadQueue = new PQueue({
concurrency: MAX_CONCURRENT_ATTACHMENT_UPLOADS,
});
const [
uploadedAttachments,
maybeUploadedLongAttachment,
contact,
preview,
quote,
sticker,
storyMessage,
] = await Promise.all([
uploadQueue.addAll(
(message.get('attachments') ?? []).map(
attachment => () => uploadSingleAttachment(message, attachment)
)
),
uploadQueue.add(async () =>
maybeLongAttachment ? uploadAttachment(maybeLongAttachment) : undefined
),
uploadMessageContacts(message, uploadQueue),
uploadMessagePreviews(message, uploadQueue),
uploadMessageQuote(message, uploadQueue),
uploadMessageSticker(message, uploadQueue),
storyId ? getMessageById(storyId) : undefined,
]);
// Save message after uploading attachments
await window.Signal.Data.saveMessage(message.attributes, {
ourUuid: window.textsecure.storage.user.getCheckedUuid().toString(),
});
const storyReaction = message.get('storyReaction');
const isEditedMessage = Boolean(message.get('editHistory'));
return {
attachments,
attachments: [
...(maybeUploadedLongAttachment ? [maybeUploadedLongAttachment] : []),
...uploadedAttachments,
],
body,
contact,
deletedForEveryoneTimestamp: message.get('deletedForEveryoneTimestamp'),
editedMessageTimestamp: isEditedMessage ? mainMessageTimestamp : undefined,
expireTimer: message.get('expireTimer'),
// TODO: we want filtration here if feature flag doesn't allow format/spoiler sends
bodyRanges: message.get('bodyRanges'),
messageTimestamp,
preview,
quote,
reaction: storyReaction
? {
...storyReaction,
remove: false,
}
: undefined,
sticker,
storyMessage,
storyContext: storyMessage
@ -551,15 +602,315 @@ async function getMessageSendData({
timestamp: storyMessage.get('sent_at'),
}
: undefined,
reaction: storyReaction
? {
...storyReaction,
remove: false,
}
: undefined,
};
}
async function uploadSingleAttachment(
message: MessageModel,
attachment: AttachmentType
): Promise<UploadedAttachmentType> {
const { loadAttachmentData } = window.Signal.Migrations;
const withData = await loadAttachmentData(attachment);
const uploaded = await uploadAttachment(withData);
// Add digest to the attachment
const logId = `uploadSingleAttachment(${message.idForLogging()}`;
const oldAttachments = message.get('attachments');
strictAssert(
oldAttachments !== undefined,
`${logId}: Attachment was uploaded, but message doesn't ` +
'have attachments anymore'
);
const index = oldAttachments.indexOf(attachment);
strictAssert(
index !== -1,
`${logId}: Attachment was uploaded, but isn't in the message anymore`
);
const newAttachments = [...oldAttachments];
newAttachments[index].digest = Bytes.toBase64(uploaded.digest);
message.set('attachments', newAttachments);
return uploaded;
}
async function uploadMessageQuote(
message: MessageModel,
uploadQueue: PQueue
): Promise<OutgoingQuoteType | undefined> {
const { loadQuoteData } = window.Signal.Migrations;
// We don't update the caches here because (1) we expect the caches to be populated
// on initial send, so they should be there in the 99% case (2) if you're retrying
// a failed message across restarts, we don't touch the cache for simplicity. If
// sends are failing, let's not add the complication of a cache.
const loadedQuote =
message.cachedOutgoingQuoteData ||
(await loadQuoteData(message.get('quote')));
if (!loadedQuote) {
return undefined;
}
const uploadedAttachments = await uploadQueue.addAll(
loadedQuote.attachments.map(
attachment => async (): Promise<OutgoingQuoteAttachmentType> => {
const { thumbnail } = attachment;
strictAssert(thumbnail, 'Quote attachment must have a thumbnail');
const uploaded = await uploadAttachment(thumbnail);
return {
contentType: MIMETypeToString(thumbnail.contentType),
fileName: attachment.fileName,
thumbnail: uploaded,
};
}
)
);
// Update message with attachment digests
const logId = `uploadMessageQuote(${message.idForLogging()}`;
const oldQuote = message.get('quote');
strictAssert(oldQuote, `${logId}: Quote is gone after upload`);
const newQuote = {
...oldQuote,
attachments: oldQuote.attachments.map((attachment, index) => {
strictAssert(
attachment.path === loadedQuote.attachments.at(index)?.path,
`${logId}: Quote attachment ${index} was updated from under us`
);
strictAssert(
attachment.thumbnail,
`${logId}: Quote attachment ${index} no longer has a thumbnail`
);
return {
...attachment,
thumbnail: {
...attachment.thumbnail,
digest: Bytes.toBase64(uploadedAttachments[index].thumbnail.digest),
},
};
}),
};
message.set('quote', newQuote);
return {
isGiftBadge: loadedQuote.isGiftBadge,
id: loadedQuote.id,
authorUuid: loadedQuote.authorUuid,
text: loadedQuote.text,
bodyRanges: loadedQuote.bodyRanges,
attachments: uploadedAttachments,
};
}
async function uploadMessagePreviews(
message: MessageModel,
uploadQueue: PQueue
): Promise<Array<OutgoingLinkPreviewType> | undefined> {
const { loadPreviewData } = window.Signal.Migrations;
// See uploadMessageQuote for comment on how we do caching for these
// attachments.
const loadedPreviews =
message.cachedOutgoingPreviewData ||
(await loadPreviewData(message.get('preview')));
if (!loadedPreviews) {
return undefined;
}
if (loadedPreviews.length === 0) {
return [];
}
const uploadedPreviews = await uploadQueue.addAll(
loadedPreviews.map(
preview => async (): Promise<OutgoingLinkPreviewType> => {
if (!preview.image) {
return {
...preview,
// Pacify typescript
image: undefined,
};
}
return {
...preview,
image: await uploadAttachment(preview.image),
};
}
)
);
// Update message with attachment digests
const logId = `uploadMessagePreviews(${message.idForLogging()}`;
const oldPreview = message.get('preview');
strictAssert(oldPreview, `${logId}: Link preview is gone after upload`);
const newPreview = oldPreview.map((preview, index) => {
strictAssert(
preview.image?.path === loadedPreviews.at(index)?.image?.path,
`${logId}: Preview attachment ${index} was updated from under us`
);
const uploaded = uploadedPreviews.at(index);
if (!preview.image || !uploaded?.image) {
return preview;
}
return {
...preview,
image: {
...preview.image,
digest: Bytes.toBase64(uploaded.image.digest),
},
};
});
message.set('preview', newPreview);
return uploadedPreviews;
}
async function uploadMessageSticker(
message: MessageModel,
uploadQueue: PQueue
): Promise<OutgoingStickerType | undefined> {
const { loadStickerData } = window.Signal.Migrations;
// See uploadMessageQuote for comment on how we do caching for these
// attachments.
const sticker =
message.cachedOutgoingStickerData ||
(await loadStickerData(message.get('sticker')));
if (!sticker) {
return undefined;
}
const uploaded = await uploadQueue.add(() => uploadAttachment(sticker.data));
// Add digest to the attachment
const logId = `uploadMessageSticker(${message.idForLogging()}`;
const oldSticker = message.get('sticker');
strictAssert(
oldSticker?.data !== undefined,
`${logId}: Sticker was uploaded, but message doesn't ` +
'have a sticker anymore'
);
strictAssert(
oldSticker.data.path === sticker.data?.path,
`${logId}: Sticker was uploaded, but message has a different sticker`
);
message.set('sticker', {
...oldSticker,
data: {
...oldSticker.data,
digest: Bytes.toBase64(uploaded.digest),
},
});
return {
...sticker,
data: uploaded,
};
}
async function uploadMessageContacts(
message: MessageModel,
uploadQueue: PQueue
): Promise<Array<EmbeddedContactWithUploadedAvatar> | undefined> {
const { loadContactData } = window.Signal.Migrations;
// See uploadMessageQuote for comment on how we do caching for these
// attachments.
const contacts =
message.cachedOutgoingContactData ||
(await loadContactData(message.get('contact')));
if (!contacts) {
return undefined;
}
if (contacts.length === 0) {
return [];
}
const uploadedContacts = await uploadQueue.addAll(
contacts.map(
contact => async (): Promise<EmbeddedContactWithUploadedAvatar> => {
const avatar = contact.avatar?.avatar;
// Pacify typescript
if (contact.avatar === undefined || !avatar) {
return {
...contact,
avatar: undefined,
};
}
const uploaded = await uploadAttachment(avatar);
return {
...contact,
avatar: {
...contact.avatar,
avatar: uploaded,
},
};
}
)
);
// Add digest to the attachment
const logId = `uploadMessageContacts(${message.idForLogging()}`;
const oldContact = message.get('contact');
strictAssert(oldContact, `${logId}: Contacts are gone after upload`);
const newContact = oldContact.map((contact, index) => {
const loaded: EmbeddedContactWithHydratedAvatar | undefined =
contacts.at(index);
if (!contact.avatar) {
strictAssert(
loaded?.avatar === undefined,
`${logId}: Avatar erased in the message`
);
return contact;
}
strictAssert(
loaded !== undefined &&
loaded.avatar !== undefined &&
loaded.avatar.avatar.path === contact.avatar.avatar.path,
`${logId}: Avatar has incorrect path`
);
const uploaded = uploadedContacts.at(index);
strictAssert(
uploaded !== undefined && uploaded.avatar !== undefined,
`${logId}: Avatar wasn't uploaded properly`
);
return {
...contact,
avatar: {
...contact.avatar,
avatar: {
...contact.avatar.avatar,
digest: Bytes.toBase64(uploaded.avatar.avatar.digest),
},
},
};
});
message.set('contact', newContact);
return uploadedContacts;
}
async function markMessageFailed(
message: MessageModel,
errors: Array<Error>

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

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

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -1,110 +0,0 @@
// Copyright 2019 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import { assert } from 'chai';
import MessageSender from '../../textsecure/SendMessage';
import type { WebAPIType } from '../../textsecure/WebAPI';
const BUCKET_SIZES = [
541, 568, 596, 626, 657, 690, 725, 761, 799, 839, 881, 925, 972, 1020, 1071,
1125, 1181, 1240, 1302, 1367, 1436, 1507, 1583, 1662, 1745, 1832, 1924, 2020,
2121, 2227, 2339, 2456, 2579, 2708, 2843, 2985, 3134, 3291, 3456, 3629, 3810,
4001, 4201, 4411, 4631, 4863, 5106, 5361, 5629, 5911, 6207, 6517, 6843, 7185,
7544, 7921, 8318, 8733, 9170, 9629, 10110, 10616, 11146, 11704, 12289, 12903,
13549, 14226, 14937, 15684, 16469, 17292, 18157, 19065, 20018, 21019, 22070,
23173, 24332, 25549, 26826, 28167, 29576, 31054, 32607, 34238, 35950, 37747,
39634, 41616, 43697, 45882, 48176, 50585, 53114, 55770, 58558, 61486, 64561,
67789, 71178, 74737, 78474, 82398, 86518, 90843, 95386, 100155, 105163,
110421, 115942, 121739, 127826, 134217, 140928, 147975, 155373, 163142,
171299, 179864, 188858, 198300, 208215, 218626, 229558, 241036, 253087,
265742, 279029, 292980, 307629, 323011, 339161, 356119, 373925, 392622,
412253, 432866, 454509, 477234, 501096, 526151, 552458, 580081, 609086,
639540, 671517, 705093, 740347, 777365, 816233, 857045, 899897, 944892,
992136, 1041743, 1093831, 1148522, 1205948, 1266246, 1329558, 1396036,
1465838, 1539130, 1616086, 1696890, 1781735, 1870822, 1964363, 2062581,
2165710, 2273996, 2387695, 2507080, 2632434, 2764056, 2902259, 3047372,
3199740, 3359727, 3527714, 3704100, 3889305, 4083770, 4287958, 4502356,
4727474, 4963848, 5212040, 5472642, 5746274, 6033588, 6335268, 6652031,
6984633, 7333864, 7700558, 8085585, 8489865, 8914358, 9360076, 9828080,
10319484, 10835458, 11377231, 11946092, 12543397, 13170567, 13829095,
14520550, 15246578, 16008907, 16809352, 17649820, 18532311, 19458926,
20431872, 21453466, 22526139, 23652446, 24835069, 26076822, 27380663,
28749697, 30187181, 31696540, 33281368, 34945436, 36692708, 38527343,
40453710, 42476396, 44600216, 46830227, 49171738, 51630325, 54211841,
56922433, 59768555, 62756983, 65894832, 69189573, 72649052, 76281505,
80095580, 84100359, 88305377, 92720646, 97356678, 102224512, 107335738,
];
describe('SendMessage', () => {
let sendMessage: MessageSender;
before(() => {
sendMessage = new MessageSender({} as unknown as WebAPIType);
});
describe('#_getAttachmentSizeBucket', () => {
it('properly calculates first bucket', () => {
for (let size = 0, max = BUCKET_SIZES[0]; size < max; size += 1) {
assert.strictEqual(
BUCKET_SIZES[0],
sendMessage._getAttachmentSizeBucket(size)
);
}
});
it('properly calculates entire table', () => {
let count = 0;
const failures = new Array<string>();
for (let i = 0, max = BUCKET_SIZES.length - 1; i < max; i += 1) {
// Exact
if (
BUCKET_SIZES[i] !==
sendMessage._getAttachmentSizeBucket(BUCKET_SIZES[i])
) {
count += 1;
failures.push(
`${
BUCKET_SIZES[i]
} does not equal ${sendMessage._getAttachmentSizeBucket(
BUCKET_SIZES[i]
)}`
);
}
// Just under
if (
BUCKET_SIZES[i] !==
sendMessage._getAttachmentSizeBucket(BUCKET_SIZES[i] - 1)
) {
count += 1;
failures.push(
`${
BUCKET_SIZES[i]
} does not equal ${sendMessage._getAttachmentSizeBucket(
BUCKET_SIZES[i] - 1
)}`
);
}
// Just over
if (
BUCKET_SIZES[i + 1] !==
sendMessage._getAttachmentSizeBucket(BUCKET_SIZES[i] + 1)
) {
count += 1;
failures.push(
`${
BUCKET_SIZES[i + 1]
} does not equal ${sendMessage._getAttachmentSizeBucket(
BUCKET_SIZES[i] + 1
)}`
);
}
}
assert.strictEqual(count, 0, failures.join('\n'));
});
});
});

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -0,0 +1,13 @@
// Copyright 2023 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import type { ToastType } from './Toast';
export class ErrorWithToast extends Error {
public toastType: ToastType;
constructor(message: string, toastType: ToastType) {
super(message);
this.toastType = toastType;
}
}

View file

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

View file

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

View file

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

View file

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

View file

@ -0,0 +1,8 @@
// Copyright 2023 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import { isEnabled } from '../RemoteConfig';
export function canEditMessages(): boolean {
return isEnabled('desktop.editMessageSend');
}

View file

@ -0,0 +1,19 @@
// Copyright 2023 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import type { MessageAttributesType } from '../model-types.d';
import type { ProcessedDataMessage } from '../textsecure/Types.d';
export function copyDataMessageIntoMessage(
dataMessage: ProcessedDataMessage,
message: MessageAttributesType
): MessageAttributesType {
return {
...message,
...dataMessage,
// TODO: DESKTOP-5278
// There are type conflicts between MessageAttributesType and the protos
// that are passed in here.
// eslint-disable-next-line @typescript-eslint/no-explicit-any
} as any as MessageAttributesType;
}

View file

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

View file

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

View file

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

View file

@ -28,6 +28,7 @@ import {
} from '../types/Attachment';
import type { StickerType } from '../types/Stickers';
import type { LinkPreviewType } from '../types/message/LinkPreviews';
import { isNotNil } from './isNotNil';
type ReturnType = {
bodyAttachment?: AttachmentType;
@ -111,6 +112,18 @@ export async function queueAttachmentDownloads(
);
count += previewCount;
log.info(
`${idLog}: Queueing ${message.quote?.attachments?.length ?? 0} ` +
'quote attachment downloads'
);
const { quote, count: thumbnailCount } = await queueQuoteAttachments(
idLog,
messageId,
message.quote,
message.editHistory?.map(x => x.quote).filter(isNotNil) ?? []
);
count += thumbnailCount;
const contactsToQueue = message.contact || [];
log.info(
`${idLog}: Queueing ${contactsToQueue.length} contact attachment downloads`
@ -141,40 +154,6 @@ export async function queueAttachmentDownloads(
})
);
let { quote } = message;
const quoteAttachmentsToQueue =
quote && quote.attachments ? quote.attachments : [];
log.info(
`${idLog}: Queueing ${quoteAttachmentsToQueue.length} quote attachment downloads`
);
if (quote && quoteAttachmentsToQueue.length > 0) {
quote = {
...quote,
attachments: await Promise.all(
(quote?.attachments || []).map(async (item, index) => {
if (!item.thumbnail) {
return item;
}
// We've already downloaded this!
if (item.thumbnail.path) {
log.info(`${idLog}: Quote attachment already downloaded`);
return item;
}
count += 1;
return {
...item,
thumbnail: await AttachmentDownloads.addJob(item.thumbnail, {
messageId,
type: 'quote',
index,
}),
};
})
),
};
}
let { sticker } = message;
if (sticker && sticker.data && sticker.data.path) {
log.info(`${idLog}: Sticker attachment already downloaded`);
@ -226,11 +205,6 @@ export async function queueAttachmentDownloads(
log.info(`${idLog}: Looping through ${editHistory.length} edits`);
editHistory = await Promise.all(
editHistory.map(async edit => {
const editAttachmentsToQueue = edit.attachments || [];
log.info(
`${idLog}: Queueing ${editAttachmentsToQueue.length} normal attachment downloads (edited:${edit.timestamp})`
);
const { attachments: editAttachments, count: editAttachmentsCount } =
await queueNormalAttachments(
idLog,
@ -239,15 +213,22 @@ export async function queueAttachmentDownloads(
attachments
);
count += editAttachmentsCount;
if (editAttachmentsCount !== 0) {
log.info(
`${idLog}: Queueing ${editAttachmentsCount} normal attachment ` +
`downloads (edited:${edit.timestamp})`
);
}
log.info(
`${idLog}: Queueing ${
(edit.preview || []).length
} preview attachment downloads (edited:${edit.timestamp})`
);
const { preview: editPreview, count: editPreviewCount } =
await queuePreviews(idLog, messageId, edit.preview, preview);
count += editPreviewCount;
if (editPreviewCount !== 0) {
log.info(
`${idLog}: Queueing ${editPreviewCount} preview attachment ` +
`downloads (edited:${edit.timestamp})`
);
}
return {
...edit,
@ -293,7 +274,9 @@ async function queueNormalAttachments(
const attachmentSignatures: Map<string, AttachmentType> = new Map();
otherAttachments?.forEach(attachment => {
const signature = getAttachmentSignature(attachment);
attachmentSignatures.set(signature, attachment);
if (signature) {
attachmentSignatures.set(signature, attachment);
}
});
let count = 0;
@ -415,3 +398,98 @@ async function queuePreviews(
count,
};
}
function getQuoteThumbnailSignature(
quote: QuotedMessageType,
thumbnail?: AttachmentType
): string | undefined {
if (!thumbnail) {
return undefined;
}
return `<${quote.id}>${getAttachmentSignature(thumbnail)}`;
}
async function queueQuoteAttachments(
idLog: string,
messageId: string,
quote: QuotedMessageType | undefined,
otherQuotes: ReadonlyArray<QuotedMessageType>
): Promise<{ quote?: QuotedMessageType; count: number }> {
let count = 0;
if (!quote) {
return { quote, count };
}
const quoteAttachmentsToQueue =
quote && quote.attachments ? quote.attachments : [];
if (quoteAttachmentsToQueue.length === 0) {
return { quote, count };
}
// Similar to queueNormalAttachments' logic for detecting same attachments
// except here we also pick by quote sent timestamp.
const thumbnailSignatures: Map<string, AttachmentType> = new Map();
otherQuotes.forEach(otherQuote => {
for (const attachment of otherQuote.attachments) {
const signature = getQuoteThumbnailSignature(
otherQuote,
attachment.thumbnail
);
if (!signature) {
continue;
}
thumbnailSignatures.set(signature, attachment);
}
});
return {
quote: {
...quote,
attachments: await Promise.all(
quote.attachments.map(async (item, index) => {
if (!item.thumbnail) {
return item;
}
// We've already downloaded this!
if (isDownloaded(item.thumbnail)) {
log.info(`${idLog}: Quote attachment already downloaded`);
return item;
}
const signature = getQuoteThumbnailSignature(quote, item.thumbnail);
const existingThumbnail = signature
? thumbnailSignatures.get(signature)
: undefined;
// We've already downloaded this elsewhere!
if (
existingThumbnail &&
(isDownloading(existingThumbnail) ||
isDownloaded(existingThumbnail))
) {
log.info(
`${idLog}: Preview already downloaded elsewhere. Replacing`
);
// Incrementing count so that we update the message's fields downstream
count += 1;
return {
...item,
thumbnail: existingThumbnail,
};
}
count += 1;
return {
...item,
thumbnail: await AttachmentDownloads.addJob(item.thumbnail, {
messageId,
type: 'quote',
index,
}),
};
})
),
},
count,
};
}

View file

@ -0,0 +1,243 @@
// Copyright 2023 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import type { DraftBodyRanges } from '../types/BodyRange';
import type { LinkPreviewType } from '../types/message/LinkPreviews';
import type {
MessageAttributesType,
QuotedMessageType,
} from '../model-types.d';
import * as log from '../logging/log';
import type { AttachmentType } from '../types/Attachment';
import { ErrorWithToast } from '../types/ErrorWithToast';
import { SendStatus } from '../messages/MessageSendState';
import { ToastType } from '../types/Toast';
import { UUID } from '../types/UUID';
import { canEditMessage } from '../state/selectors/message';
import {
conversationJobQueue,
conversationQueueJobEnum,
} from '../jobs/conversationJobQueue';
import { concat, filter, map, repeat, zipObject, find } from './iterables';
import { getConversationIdForLogging } from './idForLogging';
import { isQuoteAMatch } from '../messages/helpers';
import { getMessageById } from '../messages/getMessageById';
import { handleEditMessage } from './handleEditMessage';
import { incrementMessageCounter } from './incrementMessageCounter';
import { isGroupV1 } from './whatTypeOfConversation';
import { isNotNil } from './isNotNil';
import { isSignalConversation } from './isSignalConversation';
import { strictAssert } from './assert';
import { timeAndLogIfTooLong } from './timeAndLogIfTooLong';
import { makeQuote } from './makeQuote';
const SEND_REPORT_THRESHOLD_MS = 25;
export async function sendEditedMessage(
conversationId: string,
{
body,
bodyRanges,
preview,
quoteSentAt,
quoteAuthorUuid,
targetMessageId,
}: {
body?: string;
bodyRanges?: DraftBodyRanges;
preview: Array<LinkPreviewType>;
quoteSentAt?: number;
quoteAuthorUuid?: string;
targetMessageId: string;
}
): Promise<void> {
const { messaging } = window.textsecure;
strictAssert(messaging, 'messaging not available');
const conversation = window.ConversationController.get(conversationId);
strictAssert(conversation, 'no conversation found');
const idLog = `sendEditedMessage(${getConversationIdForLogging(
conversation.attributes
)})`;
const targetMessage = await getMessageById(targetMessageId);
strictAssert(targetMessage, 'could not find message to edit');
if (isGroupV1(conversation.attributes)) {
log.warn(`${idLog}: can't send to gv1`);
return;
}
if (isSignalConversation(conversation.attributes)) {
log.warn(`${idLog}: can't send to Signal`);
return;
}
if (!canEditMessage(targetMessage.attributes)) {
throw new ErrorWithToast(
`${idLog}: cannot edit`,
ToastType.CannotEditMessage
);
}
const timestamp = Date.now();
log.info(`${idLog}: sending ${timestamp}`);
conversation.clearTypingTimers();
const ourConversation =
window.ConversationController.getOurConversationOrThrow();
const fromId = ourConversation.id;
const recipientMaybeConversations = map(
conversation.getRecipients({
isStoryReply: false,
}),
identifier => window.ConversationController.get(identifier)
);
const recipientConversations = filter(recipientMaybeConversations, isNotNil);
const recipientConversationIds = concat(
map(recipientConversations, c => c.id),
[fromId]
);
const sendStateByConversationId = zipObject(
recipientConversationIds,
repeat({
status: SendStatus.Pending,
updatedAt: timestamp,
})
);
// Resetting send state for the target message
targetMessage.set({ sendStateByConversationId });
// Can't send both preview and attachments
const attachments =
preview && preview.length ? [] : targetMessage.get('attachments') || [];
const fixNewAttachment = (
attachment: AttachmentType,
temporaryDigest: string
): AttachmentType => {
// Check if this is an existing attachment or a new attachment coming
// from composer
if (attachment.digest) {
return attachment;
}
// Generated semi-unique digest so that `handleEditMessage` understand
// it is a new attachment
return {
...attachment,
digest: `${temporaryDigest}:${attachment.path}`,
};
};
let quote: QuotedMessageType | undefined;
if (quoteSentAt !== undefined && quoteAuthorUuid !== undefined) {
const existingQuote = targetMessage.get('quote');
// Keep the quote if unchanged.
if (quoteSentAt === existingQuote?.id) {
quote = existingQuote;
} else {
const messages = await window.Signal.Data.getMessagesBySentAt(
quoteSentAt
);
const matchingMessage = find(messages, item =>
isQuoteAMatch(item, conversationId, {
id: quoteSentAt,
authorUuid: quoteAuthorUuid,
})
);
if (matchingMessage) {
quote = await makeQuote(matchingMessage);
}
}
}
// An ephemeral message that we just use to handle the edit
const tmpMessage: MessageAttributesType = {
attachments: attachments?.map((attachment, index) =>
fixNewAttachment(attachment, `attachment:${index}`)
),
body,
bodyRanges,
conversationId,
preview: preview?.map((entry, index) => {
const image =
entry.image && fixNewAttachment(entry.image, `preview:${index}`);
if (entry.image === image) {
return entry;
}
return {
...entry,
image,
};
}),
id: UUID.generate().toString(),
quote,
received_at: incrementMessageCounter(),
received_at_ms: timestamp,
sent_at: timestamp,
timestamp,
type: 'outgoing',
};
// Building up the dependencies for handling the edit message
const editAttributes = {
conversationId,
fromId,
message: tmpMessage,
targetSentTimestamp: targetMessage.attributes.timestamp,
};
// Takes care of putting the message in the edit history, replacing the
// main message's values, and updating the conversation's properties.
await handleEditMessage(targetMessage.attributes, editAttributes);
// Inserting the send into a job and saving it to the message
await timeAndLogIfTooLong(
SEND_REPORT_THRESHOLD_MS,
() =>
conversationJobQueue.add(
{
type: conversationQueueJobEnum.enum.NormalMessage,
conversationId,
messageId: targetMessageId,
revision: conversation.get('revision'),
},
async jobToInsert => {
log.info(
`${idLog}: saving message ${targetMessageId} and job ${jobToInsert.id}`
);
await window.Signal.Data.saveMessage(targetMessage.attributes, {
jobToInsert,
ourUuid: window.textsecure.storage.user.getCheckedUuid().toString(),
});
}
),
duration => `${idLog}: db save took ${duration}ms`
);
// Does the same render dance that models/conversations does when we call
// enqueueMessageForSend. Calls redux actions, clears drafts, unarchives, and
// updates storage service if needed.
await timeAndLogIfTooLong(
SEND_REPORT_THRESHOLD_MS,
async () => {
conversation.beforeMessageSend({
message: targetMessage,
dontClearDraft: false,
dontAddMessage: true,
now: timestamp,
});
},
duration => `${idLog}: batchDisptach took ${duration}ms`
);
window.Signal.Data.updateConversation(conversation.attributes);
}

View file

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

View file

@ -0,0 +1,20 @@
// Copyright 2023 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import * as log from '../logging/log';
export async function timeAndLogIfTooLong(
threshold: number,
func: () => Promise<unknown>,
getLogLine: (duration: number) => string
): Promise<void> {
const start = Date.now();
try {
await func();
} finally {
const duration = Date.now() - start;
if (duration > threshold) {
log.info(getLogLine(duration));
}
}
}

View file

@ -0,0 +1,41 @@
// Copyright 2023 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import Long from 'long';
import type {
AttachmentWithHydratedData,
UploadedAttachmentType,
} from '../types/Attachment';
import { MIMETypeToString } from '../types/MIME';
import { padAndEncryptAttachment, getRandomBytes } from '../Crypto';
import { strictAssert } from './assert';
export async function uploadAttachment(
attachment: AttachmentWithHydratedData
): Promise<UploadedAttachmentType> {
const keys = getRandomBytes(64);
const encrypted = padAndEncryptAttachment(attachment.data, keys);
const { server } = window.textsecure;
strictAssert(server, 'WebAPI must be initialized');
const attachmentIdString = await server.putEncryptedAttachment(
encrypted.ciphertext
);
return {
cdnId: Long.fromString(attachmentIdString),
key: keys,
size: attachment.data.byteLength,
digest: encrypted.digest,
contentType: MIMETypeToString(attachment.contentType),
fileName: attachment.fileName,
flags: attachment.flags,
width: attachment.width,
height: attachment.height,
caption: attachment.caption,
blurHash: attachment.blurHash,
};
}