Receive support for editing messages
This commit is contained in:
parent
2781e621ad
commit
36e21c0134
46 changed files with 2053 additions and 405 deletions
|
@ -6463,6 +6463,14 @@
|
||||||
"messageformat": "Signal desktop no longer works on this computer. To use Signal desktop again, update your computer’s version of {OS}.",
|
"messageformat": "Signal desktop no longer works on this computer. To use Signal desktop again, update your computer’s version of {OS}.",
|
||||||
"description": "Body of a dialog displayed on unsupported operating systems"
|
"description": "Body of a dialog displayed on unsupported operating systems"
|
||||||
},
|
},
|
||||||
|
"icu:MessageMetadata__edited": {
|
||||||
|
"messageformat": "edited",
|
||||||
|
"description": "label for an edited message"
|
||||||
|
},
|
||||||
|
"icu:EditHistoryMessagesModal__title": {
|
||||||
|
"messageformat": "Edit history",
|
||||||
|
"description": "Modal title for the edit history messages modal"
|
||||||
|
},
|
||||||
"WhatsNew__modal-title": {
|
"WhatsNew__modal-title": {
|
||||||
"message": "What's New",
|
"message": "What's New",
|
||||||
"description": "Title for the whats new modal"
|
"description": "Title for the whats new modal"
|
||||||
|
|
|
@ -182,7 +182,7 @@
|
||||||
"@electron/fuses": "1.5.0",
|
"@electron/fuses": "1.5.0",
|
||||||
"@formatjs/intl": "2.6.7",
|
"@formatjs/intl": "2.6.7",
|
||||||
"@mixer/parallel-prettier": "2.0.3",
|
"@mixer/parallel-prettier": "2.0.3",
|
||||||
"@signalapp/mock-server": "2.15.0",
|
"@signalapp/mock-server": "2.17.0",
|
||||||
"@storybook/addon-a11y": "6.5.6",
|
"@storybook/addon-a11y": "6.5.6",
|
||||||
"@storybook/addon-actions": "6.5.6",
|
"@storybook/addon-actions": "6.5.6",
|
||||||
"@storybook/addon-controls": "6.5.6",
|
"@storybook/addon-controls": "6.5.6",
|
||||||
|
|
|
@ -51,6 +51,7 @@ message Content {
|
||||||
optional bytes decryptionErrorMessage = 8;
|
optional bytes decryptionErrorMessage = 8;
|
||||||
optional StoryMessage storyMessage = 9;
|
optional StoryMessage storyMessage = 9;
|
||||||
optional PniSignatureMessage pniSignatureMessage = 10;
|
optional PniSignatureMessage pniSignatureMessage = 10;
|
||||||
|
optional EditMessage editMessage = 11;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Everything in CallingMessage must be kept in sync with RingRTC (ringrtc-node).
|
// Everything in CallingMessage must be kept in sync with RingRTC (ringrtc-node).
|
||||||
|
@ -464,6 +465,7 @@ message SyncMessage {
|
||||||
optional bool isRecipientUpdate = 6 [default = false];
|
optional bool isRecipientUpdate = 6 [default = false];
|
||||||
optional StoryMessage storyMessage = 8;
|
optional StoryMessage storyMessage = 8;
|
||||||
repeated StoryMessageRecipient storyMessageRecipients = 9;
|
repeated StoryMessageRecipient storyMessageRecipients = 9;
|
||||||
|
optional EditMessage editMessage = 10;
|
||||||
}
|
}
|
||||||
|
|
||||||
message Contacts {
|
message Contacts {
|
||||||
|
@ -720,3 +722,8 @@ message PniSignatureMessage {
|
||||||
// Signature *by* the PNI identity key *of* the ACI identity key
|
// Signature *by* the PNI identity key *of* the ACI identity key
|
||||||
optional bytes signature = 2;
|
optional bytes signature = 2;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
message EditMessage {
|
||||||
|
optional uint64 targetSentTimestamp = 1;
|
||||||
|
optional DataMessage dataMessage = 2;
|
||||||
|
}
|
||||||
|
|
|
@ -1112,6 +1112,23 @@ $message-padding-horizontal: 12px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.module-message__metadata__edited {
|
||||||
|
@include button-reset;
|
||||||
|
@include font-caption;
|
||||||
|
color: $color-gray-60;
|
||||||
|
cursor: pointer;
|
||||||
|
margin-right: 6px;
|
||||||
|
z-index: $z-index-base;
|
||||||
|
|
||||||
|
@include dark-theme {
|
||||||
|
color: $color-gray-25;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.module-message__container--outgoing .module-message__metadata__edited {
|
||||||
|
color: $color-white-alpha-80;
|
||||||
|
}
|
||||||
|
|
||||||
// With an image and no caption, this section needs to be on top of the image overlay
|
// With an image and no caption, this section needs to be on top of the image overlay
|
||||||
.module-message__metadata--with-image-no-caption {
|
.module-message__metadata--with-image-no-caption {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
|
|
|
@ -114,6 +114,8 @@ import { areAnyCallsActiveOrRinging } from './state/selectors/calling';
|
||||||
import { badgeImageFileDownloader } from './badges/badgeImageFileDownloader';
|
import { badgeImageFileDownloader } from './badges/badgeImageFileDownloader';
|
||||||
import { actionCreators } from './state/actions';
|
import { actionCreators } from './state/actions';
|
||||||
import { Deletes } from './messageModifiers/Deletes';
|
import { Deletes } from './messageModifiers/Deletes';
|
||||||
|
import type { EditAttributesType } from './messageModifiers/Edits';
|
||||||
|
import * as Edits from './messageModifiers/Edits';
|
||||||
import {
|
import {
|
||||||
MessageReceipts,
|
MessageReceipts,
|
||||||
MessageReceiptType,
|
MessageReceiptType,
|
||||||
|
@ -3069,6 +3071,35 @@ export async function startApp(): Promise<void> {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (data.message.editedMessageTimestamp) {
|
||||||
|
const { editedMessageTimestamp } = data.message;
|
||||||
|
|
||||||
|
strictAssert(editedMessageTimestamp, 'Edit missing targetSentTimestamp');
|
||||||
|
const fromConversation = window.ConversationController.lookupOrCreate({
|
||||||
|
e164: data.source,
|
||||||
|
uuid: data.sourceUuid,
|
||||||
|
reason: 'onMessageReceived:edit',
|
||||||
|
});
|
||||||
|
strictAssert(fromConversation, 'Edit missing fromConversation');
|
||||||
|
|
||||||
|
log.info('Queuing incoming edit for', {
|
||||||
|
editedMessageTimestamp,
|
||||||
|
sentAt: data.timestamp,
|
||||||
|
});
|
||||||
|
|
||||||
|
const editAttributes: EditAttributesType = {
|
||||||
|
dataMessage: data.message,
|
||||||
|
fromId: fromConversation.id,
|
||||||
|
message: message.attributes,
|
||||||
|
targetSentTimestamp: editedMessageTimestamp,
|
||||||
|
};
|
||||||
|
|
||||||
|
drop(Edits.onEdit(editAttributes));
|
||||||
|
|
||||||
|
confirm();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (handleGroupCallUpdateMessage(data.message, messageDescriptor)) {
|
if (handleGroupCallUpdateMessage(data.message, messageDescriptor)) {
|
||||||
confirm();
|
confirm();
|
||||||
return;
|
return;
|
||||||
|
@ -3415,6 +3446,29 @@ export async function startApp(): Promise<void> {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (data.message.editedMessageTimestamp) {
|
||||||
|
const { editedMessageTimestamp } = data.message;
|
||||||
|
|
||||||
|
strictAssert(editedMessageTimestamp, 'Edit missing targetSentTimestamp');
|
||||||
|
|
||||||
|
log.info('Queuing sent edit for', {
|
||||||
|
editedMessageTimestamp,
|
||||||
|
sentAt: data.timestamp,
|
||||||
|
});
|
||||||
|
|
||||||
|
const editAttributes: EditAttributesType = {
|
||||||
|
dataMessage: data.message,
|
||||||
|
fromId: window.ConversationController.getOurConversationIdOrThrow(),
|
||||||
|
message: message.attributes,
|
||||||
|
targetSentTimestamp: editedMessageTimestamp,
|
||||||
|
};
|
||||||
|
|
||||||
|
drop(Edits.onEdit(editAttributes));
|
||||||
|
|
||||||
|
confirm();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (handleGroupCallUpdateMessage(data.message, messageDescriptor)) {
|
if (handleGroupCallUpdateMessage(data.message, messageDescriptor)) {
|
||||||
event.confirm();
|
event.confirm();
|
||||||
return;
|
return;
|
||||||
|
|
112
ts/components/EditHistoryMessagesModal.tsx
Normal file
112
ts/components/EditHistoryMessagesModal.tsx
Normal file
|
@ -0,0 +1,112 @@
|
||||||
|
// Copyright 2023 Signal Messenger, LLC
|
||||||
|
// SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
|
||||||
|
import React, { useCallback, useRef } from 'react';
|
||||||
|
import { noop } from 'lodash';
|
||||||
|
|
||||||
|
import type { AttachmentType } from '../types/Attachment';
|
||||||
|
import type { LocalizerType } from '../types/Util';
|
||||||
|
import type { MessagePropsType } from '../state/selectors/message';
|
||||||
|
import type { PreferredBadgeSelectorType } from '../state/selectors/badges';
|
||||||
|
import { Message, TextDirection } from './conversation/Message';
|
||||||
|
import { Modal } from './Modal';
|
||||||
|
import { WidthBreakpoint } from './_util';
|
||||||
|
import { shouldNeverBeCalled } from '../util/shouldNeverBeCalled';
|
||||||
|
import { useTheme } from '../hooks/useTheme';
|
||||||
|
|
||||||
|
export type PropsType = {
|
||||||
|
closeEditHistoryModal: () => unknown;
|
||||||
|
editHistoryMessages: Array<MessagePropsType>;
|
||||||
|
getPreferredBadge: PreferredBadgeSelectorType;
|
||||||
|
i18n: LocalizerType;
|
||||||
|
kickOffAttachmentDownload: (options: {
|
||||||
|
attachment: AttachmentType;
|
||||||
|
messageId: string;
|
||||||
|
}) => void;
|
||||||
|
showLightbox: (options: {
|
||||||
|
attachment: AttachmentType;
|
||||||
|
messageId: string;
|
||||||
|
}) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
const MESSAGE_DEFAULT_PROPS = {
|
||||||
|
canDeleteForEveryone: false,
|
||||||
|
checkForAccount: shouldNeverBeCalled,
|
||||||
|
clearSelectedMessage: shouldNeverBeCalled,
|
||||||
|
clearTargetedMessage: shouldNeverBeCalled,
|
||||||
|
containerWidthBreakpoint: WidthBreakpoint.Medium,
|
||||||
|
doubleCheckMissingQuoteReference: shouldNeverBeCalled,
|
||||||
|
interactionMode: 'mouse' as const,
|
||||||
|
isBlocked: false,
|
||||||
|
isMessageRequestAccepted: true,
|
||||||
|
markAttachmentAsCorrupted: shouldNeverBeCalled,
|
||||||
|
messageExpanded: shouldNeverBeCalled,
|
||||||
|
onReplyToMessage: shouldNeverBeCalled,
|
||||||
|
onToggleSelect: shouldNeverBeCalled,
|
||||||
|
openGiftBadge: shouldNeverBeCalled,
|
||||||
|
openLink: shouldNeverBeCalled,
|
||||||
|
previews: [],
|
||||||
|
pushPanelForConversation: shouldNeverBeCalled,
|
||||||
|
renderAudioAttachment: () => <div />,
|
||||||
|
renderingContext: 'EditHistoryMessagesModal',
|
||||||
|
saveAttachment: shouldNeverBeCalled,
|
||||||
|
scrollToQuotedMessage: shouldNeverBeCalled,
|
||||||
|
shouldCollapseAbove: false,
|
||||||
|
shouldCollapseBelow: false,
|
||||||
|
shouldHideMetadata: true,
|
||||||
|
showContactModal: shouldNeverBeCalled,
|
||||||
|
showConversation: noop,
|
||||||
|
showEditHistoryModal: shouldNeverBeCalled,
|
||||||
|
showExpiredIncomingTapToViewToast: shouldNeverBeCalled,
|
||||||
|
showExpiredOutgoingTapToViewToast: shouldNeverBeCalled,
|
||||||
|
showLightboxForViewOnceMedia: shouldNeverBeCalled,
|
||||||
|
startConversation: shouldNeverBeCalled,
|
||||||
|
textDirection: TextDirection.Default,
|
||||||
|
viewStory: shouldNeverBeCalled,
|
||||||
|
};
|
||||||
|
|
||||||
|
export function EditHistoryMessagesModal({
|
||||||
|
closeEditHistoryModal,
|
||||||
|
getPreferredBadge,
|
||||||
|
editHistoryMessages,
|
||||||
|
i18n,
|
||||||
|
kickOffAttachmentDownload,
|
||||||
|
showLightbox,
|
||||||
|
}: PropsType): JSX.Element {
|
||||||
|
const containerElementRef = useRef<HTMLDivElement | null>(null);
|
||||||
|
const theme = useTheme();
|
||||||
|
|
||||||
|
const closeAndShowLightbox = useCallback(
|
||||||
|
(options: { attachment: AttachmentType; messageId: string }) => {
|
||||||
|
closeEditHistoryModal();
|
||||||
|
showLightbox(options);
|
||||||
|
},
|
||||||
|
[closeEditHistoryModal, showLightbox]
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
hasXButton
|
||||||
|
i18n={i18n}
|
||||||
|
modalName="EditHistoryMessagesModal"
|
||||||
|
onClose={closeEditHistoryModal}
|
||||||
|
title={i18n('icu:EditHistoryMessagesModal__title')}
|
||||||
|
>
|
||||||
|
<div ref={containerElementRef}>
|
||||||
|
{editHistoryMessages.map(messageAttributes => (
|
||||||
|
<Message
|
||||||
|
{...MESSAGE_DEFAULT_PROPS}
|
||||||
|
{...messageAttributes}
|
||||||
|
containerElementRef={containerElementRef}
|
||||||
|
getPreferredBadge={getPreferredBadge}
|
||||||
|
i18n={i18n}
|
||||||
|
key={messageAttributes.timestamp}
|
||||||
|
kickOffAttachmentDownload={kickOffAttachmentDownload}
|
||||||
|
showLightbox={closeAndShowLightbox}
|
||||||
|
theme={theme}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
|
@ -3,11 +3,12 @@
|
||||||
|
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import type {
|
import type {
|
||||||
ContactModalStateType,
|
|
||||||
UserNotFoundModalStateType,
|
|
||||||
SafetyNumberChangedBlockingDataType,
|
|
||||||
AuthorizeArtCreatorDataType,
|
AuthorizeArtCreatorDataType,
|
||||||
|
ContactModalStateType,
|
||||||
|
EditHistoryMessagesType,
|
||||||
ForwardMessagesPropsType,
|
ForwardMessagesPropsType,
|
||||||
|
SafetyNumberChangedBlockingDataType,
|
||||||
|
UserNotFoundModalStateType,
|
||||||
} from '../state/ducks/globalModals';
|
} from '../state/ducks/globalModals';
|
||||||
import type { LocalizerType, ThemeType } from '../types/Util';
|
import type { LocalizerType, ThemeType } from '../types/Util';
|
||||||
import { missingCaseError } from '../util/missingCaseError';
|
import { missingCaseError } from '../util/missingCaseError';
|
||||||
|
@ -28,6 +29,9 @@ export type PropsType = {
|
||||||
// ContactModal
|
// ContactModal
|
||||||
contactModalState: ContactModalStateType | undefined;
|
contactModalState: ContactModalStateType | undefined;
|
||||||
renderContactModal: () => JSX.Element;
|
renderContactModal: () => JSX.Element;
|
||||||
|
// EditHistoryMessagesModal
|
||||||
|
editHistoryMessages: EditHistoryMessagesType | undefined;
|
||||||
|
renderEditHistoryMessagesModal: () => JSX.Element;
|
||||||
// ErrorModal
|
// ErrorModal
|
||||||
errorModalProps: { description?: string; title?: string } | undefined;
|
errorModalProps: { description?: string; title?: string } | undefined;
|
||||||
renderErrorModal: (opts: {
|
renderErrorModal: (opts: {
|
||||||
|
@ -82,6 +86,9 @@ export function GlobalModalContainer({
|
||||||
// ContactModal
|
// ContactModal
|
||||||
contactModalState,
|
contactModalState,
|
||||||
renderContactModal,
|
renderContactModal,
|
||||||
|
// EditHistoryMessages
|
||||||
|
editHistoryMessages,
|
||||||
|
renderEditHistoryMessagesModal,
|
||||||
// ErrorModal
|
// ErrorModal
|
||||||
errorModalProps,
|
errorModalProps,
|
||||||
renderErrorModal,
|
renderErrorModal,
|
||||||
|
@ -147,6 +154,10 @@ export function GlobalModalContainer({
|
||||||
return renderContactModal();
|
return renderContactModal();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (editHistoryMessages) {
|
||||||
|
return renderEditHistoryMessagesModal();
|
||||||
|
}
|
||||||
|
|
||||||
if (forwardMessagesProps) {
|
if (forwardMessagesProps) {
|
||||||
return renderForwardMessagesModal();
|
return renderForwardMessagesModal();
|
||||||
}
|
}
|
||||||
|
|
|
@ -207,6 +207,7 @@ export type PropsData = {
|
||||||
text?: string;
|
text?: string;
|
||||||
textDirection: TextDirection;
|
textDirection: TextDirection;
|
||||||
textAttachment?: AttachmentType;
|
textAttachment?: AttachmentType;
|
||||||
|
isEditedMessage?: boolean;
|
||||||
isSticker?: boolean;
|
isSticker?: boolean;
|
||||||
isTargeted?: boolean;
|
isTargeted?: boolean;
|
||||||
isTargetedCounter?: number;
|
isTargetedCounter?: number;
|
||||||
|
@ -338,6 +339,7 @@ export type PropsActions = {
|
||||||
}) => void;
|
}) => void;
|
||||||
targetMessage?: (messageId: string, conversationId: string) => unknown;
|
targetMessage?: (messageId: string, conversationId: string) => unknown;
|
||||||
|
|
||||||
|
showEditHistoryModal?: (id: string) => unknown;
|
||||||
showExpiredIncomingTapToViewToast: () => unknown;
|
showExpiredIncomingTapToViewToast: () => unknown;
|
||||||
showExpiredOutgoingTapToViewToast: () => unknown;
|
showExpiredOutgoingTapToViewToast: () => unknown;
|
||||||
viewStory: ViewStoryActionCreatorType;
|
viewStory: ViewStoryActionCreatorType;
|
||||||
|
@ -768,9 +770,11 @@ export class Message extends React.PureComponent<Props, State> {
|
||||||
expirationTimestamp,
|
expirationTimestamp,
|
||||||
i18n,
|
i18n,
|
||||||
id,
|
id,
|
||||||
|
isEditedMessage,
|
||||||
isSticker,
|
isSticker,
|
||||||
isTapToViewExpired,
|
isTapToViewExpired,
|
||||||
pushPanelForConversation,
|
pushPanelForConversation,
|
||||||
|
showEditHistoryModal,
|
||||||
status,
|
status,
|
||||||
text,
|
text,
|
||||||
textAttachment,
|
textAttachment,
|
||||||
|
@ -788,12 +792,14 @@ export class Message extends React.PureComponent<Props, State> {
|
||||||
hasText={Boolean(text)}
|
hasText={Boolean(text)}
|
||||||
i18n={i18n}
|
i18n={i18n}
|
||||||
id={id}
|
id={id}
|
||||||
|
isEditedMessage={isEditedMessage}
|
||||||
isInline={isInline}
|
isInline={isInline}
|
||||||
isShowingImage={this.isShowingImage()}
|
isShowingImage={this.isShowingImage()}
|
||||||
isSticker={isStickerLike}
|
isSticker={isStickerLike}
|
||||||
isTapToViewExpired={isTapToViewExpired}
|
isTapToViewExpired={isTapToViewExpired}
|
||||||
onWidthMeasured={isInline ? this.updateMetadataWidth : undefined}
|
onWidthMeasured={isInline ? this.updateMetadataWidth : undefined}
|
||||||
pushPanelForConversation={pushPanelForConversation}
|
pushPanelForConversation={pushPanelForConversation}
|
||||||
|
showEditHistoryModal={showEditHistoryModal}
|
||||||
status={status}
|
status={status}
|
||||||
textPending={textAttachment?.pending}
|
textPending={textAttachment?.pending}
|
||||||
timestamp={timestamp}
|
timestamp={timestamp}
|
||||||
|
|
|
@ -22,12 +22,14 @@ type PropsType = {
|
||||||
hasText: boolean;
|
hasText: boolean;
|
||||||
i18n: LocalizerType;
|
i18n: LocalizerType;
|
||||||
id: string;
|
id: string;
|
||||||
|
isEditedMessage?: boolean;
|
||||||
isInline?: boolean;
|
isInline?: boolean;
|
||||||
isShowingImage: boolean;
|
isShowingImage: boolean;
|
||||||
isSticker?: boolean;
|
isSticker?: boolean;
|
||||||
isTapToViewExpired?: boolean;
|
isTapToViewExpired?: boolean;
|
||||||
onWidthMeasured?: (width: number) => unknown;
|
onWidthMeasured?: (width: number) => unknown;
|
||||||
pushPanelForConversation: PushPanelForConversationActionType;
|
pushPanelForConversation: PushPanelForConversationActionType;
|
||||||
|
showEditHistoryModal?: (id: string) => unknown;
|
||||||
status?: MessageStatusType;
|
status?: MessageStatusType;
|
||||||
textPending?: boolean;
|
textPending?: boolean;
|
||||||
timestamp: number;
|
timestamp: number;
|
||||||
|
@ -41,12 +43,14 @@ export function MessageMetadata({
|
||||||
hasText,
|
hasText,
|
||||||
i18n,
|
i18n,
|
||||||
id,
|
id,
|
||||||
|
isEditedMessage,
|
||||||
isInline,
|
isInline,
|
||||||
isShowingImage,
|
isShowingImage,
|
||||||
isSticker,
|
isSticker,
|
||||||
isTapToViewExpired,
|
isTapToViewExpired,
|
||||||
onWidthMeasured,
|
onWidthMeasured,
|
||||||
pushPanelForConversation,
|
pushPanelForConversation,
|
||||||
|
showEditHistoryModal,
|
||||||
status,
|
status,
|
||||||
textPending,
|
textPending,
|
||||||
timestamp,
|
timestamp,
|
||||||
|
@ -130,6 +134,15 @@ export function MessageMetadata({
|
||||||
);
|
);
|
||||||
const children = (
|
const children = (
|
||||||
<>
|
<>
|
||||||
|
{isEditedMessage && showEditHistoryModal && (
|
||||||
|
<button
|
||||||
|
className="module-message__metadata__edited"
|
||||||
|
onClick={() => showEditHistoryModal(id)}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
{i18n('icu:MessageMetadata__edited')}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
{timestampNode}
|
{timestampNode}
|
||||||
{expirationLength ? (
|
{expirationLength ? (
|
||||||
<ExpireTimer
|
<ExpireTimer
|
||||||
|
|
|
@ -17,6 +17,7 @@ import type {
|
||||||
|
|
||||||
import type { MessageModel } from '../models/messages';
|
import type { MessageModel } from '../models/messages';
|
||||||
import type { AttachmentType } from '../types/Attachment';
|
import type { AttachmentType } from '../types/Attachment';
|
||||||
|
import { getAttachmentSignature, isDownloaded } from '../types/Attachment';
|
||||||
import * as Errors from '../types/errors';
|
import * as Errors from '../types/errors';
|
||||||
import type { LoggerType } from '../types/Logging';
|
import type { LoggerType } from '../types/Logging';
|
||||||
import * as log from '../logging/log';
|
import * as log from '../logging/log';
|
||||||
|
@ -433,7 +434,7 @@ function _markAttachmentAsPermanentError(
|
||||||
attachment: AttachmentType
|
attachment: AttachmentType
|
||||||
): AttachmentType {
|
): AttachmentType {
|
||||||
return {
|
return {
|
||||||
...omit(attachment, ['key', 'digest', 'id']),
|
...omit(attachment, ['key', 'id']),
|
||||||
error: true,
|
error: true,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
@ -454,6 +455,7 @@ async function _addAttachmentToMessage(
|
||||||
}
|
}
|
||||||
|
|
||||||
const logPrefix = `${message.idForLogging()} (type: ${type}, index: ${index})`;
|
const logPrefix = `${message.idForLogging()} (type: ${type}, index: ${index})`;
|
||||||
|
const attachmentSignature = getAttachmentSignature(attachment);
|
||||||
|
|
||||||
if (type === 'long-message') {
|
if (type === 'long-message') {
|
||||||
// Attachment wasn't downloaded yet.
|
// Attachment wasn't downloaded yet.
|
||||||
|
@ -482,13 +484,60 @@ async function _addAttachmentToMessage(
|
||||||
|
|
||||||
if (type === 'attachment') {
|
if (type === 'attachment') {
|
||||||
const attachments = message.get('attachments');
|
const attachments = message.get('attachments');
|
||||||
|
|
||||||
|
let handledInEditHistory = false;
|
||||||
|
|
||||||
|
const editHistory = message.get('editHistory');
|
||||||
|
if (editHistory) {
|
||||||
|
const newEditHistory = editHistory.map(edit => {
|
||||||
|
if (!edit.attachments) {
|
||||||
|
return edit;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
...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;
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
if (newEditHistory !== editHistory) {
|
||||||
|
message.set({ editHistory: newEditHistory });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (!attachments || attachments.length <= index) {
|
if (!attachments || attachments.length <= index) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
`_addAttachmentToMessage: attachments didn't exist or ${index} was too large`
|
`_addAttachmentToMessage: attachments didn't exist or index(${index}) was too large`
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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);
|
_checkOldAttachment(attachments, index.toString(), logPrefix);
|
||||||
|
|
||||||
|
// Replace attachment
|
||||||
const newAttachments = [...attachments];
|
const newAttachments = [...attachments];
|
||||||
newAttachments[index] = attachment;
|
newAttachments[index] = attachment;
|
||||||
|
|
||||||
|
@ -499,6 +548,48 @@ async function _addAttachmentToMessage(
|
||||||
|
|
||||||
if (type === 'preview') {
|
if (type === 'preview') {
|
||||||
const preview = message.get('preview');
|
const preview = message.get('preview');
|
||||||
|
|
||||||
|
let handledInEditHistory = false;
|
||||||
|
|
||||||
|
const editHistory = message.get('editHistory');
|
||||||
|
if (preview && editHistory) {
|
||||||
|
const newEditHistory = editHistory.map(edit => {
|
||||||
|
if (!edit.preview || edit.preview.length <= index) {
|
||||||
|
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,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
if (newEditHistory !== editHistory) {
|
||||||
|
message.set({ editHistory: newEditHistory });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (!preview || preview.length <= index) {
|
if (!preview || preview.length <= index) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
`_addAttachmentToMessage: preview didn't exist or ${index} was too large`
|
`_addAttachmentToMessage: preview didn't exist or ${index} was too large`
|
||||||
|
@ -509,8 +600,16 @@ async function _addAttachmentToMessage(
|
||||||
throw new Error(`_addAttachmentToMessage: preview ${index} was falsey`);
|
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);
|
_checkOldAttachment(item, 'image', logPrefix);
|
||||||
|
|
||||||
|
// Replace attachment
|
||||||
const newPreview = [...preview];
|
const newPreview = [...preview];
|
||||||
newPreview[index] = {
|
newPreview[index] = {
|
||||||
...preview[index],
|
...preview[index],
|
||||||
|
|
104
ts/messageModifiers/Edits.ts
Normal file
104
ts/messageModifiers/Edits.ts
Normal file
|
@ -0,0 +1,104 @@
|
||||||
|
// Copyright 2023 Signal Messenger, LLC
|
||||||
|
// SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
|
||||||
|
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';
|
||||||
|
import { filter, size } from '../util/iterables';
|
||||||
|
import { getContactId } from '../messages/helpers';
|
||||||
|
import { handleEditMessage } from '../util/handleEditMessage';
|
||||||
|
|
||||||
|
export type EditAttributesType = {
|
||||||
|
dataMessage: ProcessedDataMessage;
|
||||||
|
fromId: string;
|
||||||
|
message: MessageAttributesType;
|
||||||
|
targetSentTimestamp: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
const edits = new Set<EditAttributesType>();
|
||||||
|
|
||||||
|
export function forMessage(message: MessageModel): Array<EditAttributesType> {
|
||||||
|
const matchingEdits = filter(edits, item => {
|
||||||
|
return (
|
||||||
|
item.targetSentTimestamp === message.get('sent_at') &&
|
||||||
|
item.fromId === getContactId(message.attributes)
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
if (size(matchingEdits) > 0) {
|
||||||
|
log.info('Edits.forMessage: Found early edit for message');
|
||||||
|
filter(matchingEdits, item => edits.delete(item));
|
||||||
|
return Array.from(matchingEdits);
|
||||||
|
}
|
||||||
|
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function onEdit(edit: EditAttributesType): Promise<void> {
|
||||||
|
edits.add(edit);
|
||||||
|
|
||||||
|
try {
|
||||||
|
// The conversation the deleted message was in; we have to find it in the database
|
||||||
|
// to to figure that out.
|
||||||
|
const targetConversation =
|
||||||
|
await window.ConversationController.getConversationForTargetMessage(
|
||||||
|
edit.fromId,
|
||||||
|
edit.targetSentTimestamp
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!targetConversation) {
|
||||||
|
log.info(
|
||||||
|
'No target conversation for edit',
|
||||||
|
edit.fromId,
|
||||||
|
edit.targetSentTimestamp
|
||||||
|
);
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Do not await, since this can deadlock the queue
|
||||||
|
drop(
|
||||||
|
targetConversation.queueJob('Edits.onEdit', async () => {
|
||||||
|
log.info('Handling edit for', {
|
||||||
|
targetSentTimestamp: edit.targetSentTimestamp,
|
||||||
|
sentAt: edit.dataMessage.timestamp,
|
||||||
|
});
|
||||||
|
|
||||||
|
const messages = await window.Signal.Data.getMessagesBySentAt(
|
||||||
|
edit.targetSentTimestamp
|
||||||
|
);
|
||||||
|
|
||||||
|
// Verify authorship
|
||||||
|
const targetMessage = messages.find(
|
||||||
|
m =>
|
||||||
|
edit.message.conversationId === m.conversationId &&
|
||||||
|
edit.fromId === getContactId(m)
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!targetMessage) {
|
||||||
|
log.info(
|
||||||
|
'No message for edit',
|
||||||
|
edit.fromId,
|
||||||
|
edit.targetSentTimestamp
|
||||||
|
);
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const message = window.MessageController.register(
|
||||||
|
targetMessage.id,
|
||||||
|
targetMessage
|
||||||
|
);
|
||||||
|
|
||||||
|
await handleEditMessage(message.attributes, edit);
|
||||||
|
|
||||||
|
edits.delete(edit);
|
||||||
|
})
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
log.error('Edits.onEdit error:', Errors.toLogFormat(error));
|
||||||
|
}
|
||||||
|
}
|
|
@ -285,7 +285,8 @@ export class MessageReceipts extends Collection<MessageReceiptModel> {
|
||||||
const type = receipt.get('type');
|
const type = receipt.get('type');
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const messages = await window.Signal.Data.getMessagesBySentAt(
|
const messages =
|
||||||
|
await window.Signal.Data.getMessagesIncludingEditedBySentAt(
|
||||||
messageSentAt
|
messageSentAt
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
|
@ -82,7 +82,8 @@ export class ReadSyncs extends Collection {
|
||||||
|
|
||||||
async onSync(sync: ReadSyncModel): Promise<void> {
|
async onSync(sync: ReadSyncModel): Promise<void> {
|
||||||
try {
|
try {
|
||||||
const messages = await window.Signal.Data.getMessagesBySentAt(
|
const messages =
|
||||||
|
await window.Signal.Data.getMessagesIncludingEditedBySentAt(
|
||||||
sync.get('timestamp')
|
sync.get('timestamp')
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
|
@ -98,7 +98,12 @@ export class ViewSyncs extends Collection {
|
||||||
|
|
||||||
const attachments = message.get('attachments');
|
const attachments = message.get('attachments');
|
||||||
if (!attachments?.every(isDownloaded)) {
|
if (!attachments?.every(isDownloaded)) {
|
||||||
void queueAttachmentDownloads(message.attributes);
|
const updatedFields = await queueAttachmentDownloads(
|
||||||
|
message.attributes
|
||||||
|
);
|
||||||
|
if (updatedFields) {
|
||||||
|
message.set(updatedFields);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
10
ts/model-types.d.ts
vendored
10
ts/model-types.d.ts
vendored
|
@ -120,6 +120,14 @@ export type MessageReactionType = {
|
||||||
isSentByConversationId?: Record<string, boolean>;
|
isSentByConversationId?: Record<string, boolean>;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type EditHistoryType = {
|
||||||
|
attachments?: Array<AttachmentType>;
|
||||||
|
body?: string;
|
||||||
|
bodyRanges?: BodyRangesType;
|
||||||
|
preview?: Array<LinkPreviewType>;
|
||||||
|
timestamp: number;
|
||||||
|
};
|
||||||
|
|
||||||
export type MessageAttributesType = {
|
export type MessageAttributesType = {
|
||||||
bodyAttachment?: AttachmentType;
|
bodyAttachment?: AttachmentType;
|
||||||
bodyRanges?: BodyRangesType;
|
bodyRanges?: BodyRangesType;
|
||||||
|
@ -141,6 +149,8 @@ export type MessageAttributesType = {
|
||||||
isErased?: boolean;
|
isErased?: boolean;
|
||||||
isTapToViewInvalid?: boolean;
|
isTapToViewInvalid?: boolean;
|
||||||
isViewOnce?: boolean;
|
isViewOnce?: boolean;
|
||||||
|
editHistory?: Array<EditHistoryType>;
|
||||||
|
editMessageTimestamp?: number;
|
||||||
key_changed?: string;
|
key_changed?: string;
|
||||||
local?: boolean;
|
local?: boolean;
|
||||||
logger?: unknown;
|
logger?: unknown;
|
||||||
|
|
|
@ -31,7 +31,7 @@ import { normalizeUuid } from '../util/normalizeUuid';
|
||||||
import { clearTimeoutIfNecessary } from '../util/clearTimeoutIfNecessary';
|
import { clearTimeoutIfNecessary } from '../util/clearTimeoutIfNecessary';
|
||||||
import type { AttachmentType, ThumbnailType } from '../types/Attachment';
|
import type { AttachmentType, ThumbnailType } from '../types/Attachment';
|
||||||
import { toDayMillis } from '../util/timestamp';
|
import { toDayMillis } from '../util/timestamp';
|
||||||
import { isGIF, isVoiceMessage } from '../types/Attachment';
|
import { isVoiceMessage } from '../types/Attachment';
|
||||||
import type { CallHistoryDetailsType } from '../types/Calling';
|
import type { CallHistoryDetailsType } from '../types/Calling';
|
||||||
import { CallMode } from '../types/Calling';
|
import { CallMode } from '../types/Calling';
|
||||||
import * as Conversation from '../types/Conversation';
|
import * as Conversation from '../types/Conversation';
|
||||||
|
@ -73,7 +73,7 @@ import { sniffImageMimeType } from '../util/sniffImageMimeType';
|
||||||
import { isValidE164 } from '../util/isValidE164';
|
import { isValidE164 } from '../util/isValidE164';
|
||||||
import { canConversationBeUnarchived } from '../util/canConversationBeUnarchived';
|
import { canConversationBeUnarchived } from '../util/canConversationBeUnarchived';
|
||||||
import type { MIMEType } from '../types/MIME';
|
import type { MIMEType } from '../types/MIME';
|
||||||
import { IMAGE_JPEG, IMAGE_GIF, IMAGE_WEBP } from '../types/MIME';
|
import { IMAGE_JPEG, IMAGE_WEBP } from '../types/MIME';
|
||||||
import { UUID, UUIDKind } from '../types/UUID';
|
import { UUID, UUIDKind } from '../types/UUID';
|
||||||
import type { UUIDStringType } from '../types/UUID';
|
import type { UUIDStringType } from '../types/UUID';
|
||||||
import {
|
import {
|
||||||
|
@ -108,15 +108,7 @@ import { ReadStatus } from '../messages/MessageReadStatus';
|
||||||
import { SendStatus } from '../messages/MessageSendState';
|
import { SendStatus } from '../messages/MessageSendState';
|
||||||
import type { LinkPreviewType } from '../types/message/LinkPreviews';
|
import type { LinkPreviewType } from '../types/message/LinkPreviews';
|
||||||
import { MINUTE, SECOND, DurationInSeconds } from '../util/durations';
|
import { MINUTE, SECOND, DurationInSeconds } from '../util/durations';
|
||||||
import {
|
import { concat, filter, map, repeat, zipObject } from '../util/iterables';
|
||||||
concat,
|
|
||||||
filter,
|
|
||||||
map,
|
|
||||||
take,
|
|
||||||
repeat,
|
|
||||||
zipObject,
|
|
||||||
collect,
|
|
||||||
} from '../util/iterables';
|
|
||||||
import * as universalExpireTimer from '../util/universalExpireTimer';
|
import * as universalExpireTimer from '../util/universalExpireTimer';
|
||||||
import type { GroupNameCollisionsWithIdsByTitle } from '../util/groupMemberNameCollisions';
|
import type { GroupNameCollisionsWithIdsByTitle } from '../util/groupMemberNameCollisions';
|
||||||
import {
|
import {
|
||||||
|
@ -130,10 +122,8 @@ import { SignalService as Proto } from '../protobuf';
|
||||||
import {
|
import {
|
||||||
getMessagePropStatus,
|
getMessagePropStatus,
|
||||||
hasErrors,
|
hasErrors,
|
||||||
isGiftBadge,
|
|
||||||
isIncoming,
|
isIncoming,
|
||||||
isStory,
|
isStory,
|
||||||
isTapToView,
|
|
||||||
} from '../state/selectors/message';
|
} from '../state/selectors/message';
|
||||||
import {
|
import {
|
||||||
conversationJobQueue,
|
conversationJobQueue,
|
||||||
|
@ -162,6 +152,7 @@ import { removePendingMember } from '../util/removePendingMember';
|
||||||
import { isMemberPending } from '../util/isMemberPending';
|
import { isMemberPending } from '../util/isMemberPending';
|
||||||
import { imageToBlurHash } from '../util/imageToBlurHash';
|
import { imageToBlurHash } from '../util/imageToBlurHash';
|
||||||
import { ReceiptType } from '../types/Receipt';
|
import { ReceiptType } from '../types/Receipt';
|
||||||
|
import { getQuoteAttachment } from '../util/makeQuote';
|
||||||
|
|
||||||
const EMPTY_ARRAY: Readonly<[]> = [];
|
const EMPTY_ARRAY: Readonly<[]> = [];
|
||||||
const EMPTY_GROUP_COLLISIONS: GroupNameCollisionsWithIdsByTitle = {};
|
const EMPTY_GROUP_COLLISIONS: GroupNameCollisionsWithIdsByTitle = {};
|
||||||
|
@ -175,7 +166,6 @@ const {
|
||||||
deleteAttachmentData,
|
deleteAttachmentData,
|
||||||
doesAttachmentExist,
|
doesAttachmentExist,
|
||||||
getAbsoluteAttachmentPath,
|
getAbsoluteAttachmentPath,
|
||||||
loadAttachmentData,
|
|
||||||
readStickerData,
|
readStickerData,
|
||||||
upgradeMessageSchema,
|
upgradeMessageSchema,
|
||||||
writeNewAttachmentData,
|
writeNewAttachmentData,
|
||||||
|
@ -3860,109 +3850,7 @@ export class ConversationModel extends window.Backbone
|
||||||
thumbnail: ThumbnailType | null;
|
thumbnail: ThumbnailType | null;
|
||||||
}>
|
}>
|
||||||
> {
|
> {
|
||||||
if (attachments && attachments.length) {
|
return getQuoteAttachment(attachments, preview, sticker);
|
||||||
const attachmentsToUse = Array.from(take(attachments, 1));
|
|
||||||
const isGIFQuote = isGIF(attachmentsToUse);
|
|
||||||
|
|
||||||
return Promise.all(
|
|
||||||
map(attachmentsToUse, async attachment => {
|
|
||||||
const { path, fileName, thumbnail, contentType } = attachment;
|
|
||||||
|
|
||||||
if (!path) {
|
|
||||||
return {
|
|
||||||
contentType: isGIFQuote ? IMAGE_GIF : contentType,
|
|
||||||
// Our protos library complains about this field being undefined, so we
|
|
||||||
// force it to null
|
|
||||||
fileName: fileName || null,
|
|
||||||
thumbnail: null,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
contentType: isGIFQuote ? IMAGE_GIF : contentType,
|
|
||||||
// Our protos library complains about this field being undefined, so we force
|
|
||||||
// it to null
|
|
||||||
fileName: fileName || null,
|
|
||||||
thumbnail: thumbnail
|
|
||||||
? {
|
|
||||||
...(await loadAttachmentData(thumbnail)),
|
|
||||||
objectUrl: thumbnail.path
|
|
||||||
? getAbsoluteAttachmentPath(thumbnail.path)
|
|
||||||
: undefined,
|
|
||||||
}
|
|
||||||
: null,
|
|
||||||
};
|
|
||||||
})
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (preview && preview.length) {
|
|
||||||
const previewImages = collect(preview, prev => prev.image);
|
|
||||||
const previewImagesToUse = take(previewImages, 1);
|
|
||||||
|
|
||||||
return Promise.all(
|
|
||||||
map(previewImagesToUse, async image => {
|
|
||||||
const { contentType } = image;
|
|
||||||
|
|
||||||
return {
|
|
||||||
contentType,
|
|
||||||
// Our protos library complains about this field being undefined, so we
|
|
||||||
// force it to null
|
|
||||||
fileName: null,
|
|
||||||
thumbnail: image
|
|
||||||
? {
|
|
||||||
...(await loadAttachmentData(image)),
|
|
||||||
objectUrl: image.path
|
|
||||||
? getAbsoluteAttachmentPath(image.path)
|
|
||||||
: undefined,
|
|
||||||
}
|
|
||||||
: null,
|
|
||||||
};
|
|
||||||
})
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (sticker && sticker.data && sticker.data.path) {
|
|
||||||
const { path, contentType } = sticker.data;
|
|
||||||
|
|
||||||
return [
|
|
||||||
{
|
|
||||||
contentType,
|
|
||||||
// Our protos library complains about this field being undefined, so we
|
|
||||||
// force it to null
|
|
||||||
fileName: null,
|
|
||||||
thumbnail: {
|
|
||||||
...(await loadAttachmentData(sticker.data)),
|
|
||||||
objectUrl: path ? getAbsoluteAttachmentPath(path) : undefined,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
|
|
||||||
async makeQuote(quotedMessage: MessageModel): Promise<QuotedMessageType> {
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
|
||||||
const contact = getContact(quotedMessage.attributes)!;
|
|
||||||
const attachments = quotedMessage.get('attachments');
|
|
||||||
const preview = quotedMessage.get('preview');
|
|
||||||
const sticker = quotedMessage.get('sticker');
|
|
||||||
|
|
||||||
return {
|
|
||||||
authorUuid: contact.get('uuid'),
|
|
||||||
attachments: isTapToView(quotedMessage.attributes)
|
|
||||||
? [{ contentType: IMAGE_JPEG, fileName: null }]
|
|
||||||
: await this.getQuoteAttachment(attachments, preview, sticker),
|
|
||||||
payment: quotedMessage.get('payment'),
|
|
||||||
bodyRanges: quotedMessage.get('bodyRanges'),
|
|
||||||
id: quotedMessage.get('sent_at'),
|
|
||||||
isViewOnce: isTapToView(quotedMessage.attributes),
|
|
||||||
isGiftBadge: isGiftBadge(quotedMessage.attributes),
|
|
||||||
messageId: quotedMessage.get('id'),
|
|
||||||
referencedMessageNotFound: false,
|
|
||||||
text: quotedMessage.getQuoteBodyText(),
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async sendStickerMessage(packId: string, stickerId: number): Promise<void> {
|
async sendStickerMessage(packId: string, stickerId: number): Promise<void> {
|
||||||
|
|
|
@ -80,7 +80,6 @@ import { migrateLegacySendAttributes } from '../messages/migrateLegacySendAttrib
|
||||||
import { getOwn } from '../util/getOwn';
|
import { getOwn } from '../util/getOwn';
|
||||||
import { markRead, markViewed } from '../services/MessageUpdater';
|
import { markRead, markViewed } from '../services/MessageUpdater';
|
||||||
import { scheduleOptimizeFTS } from '../services/ftsOptimizer';
|
import { scheduleOptimizeFTS } from '../services/ftsOptimizer';
|
||||||
import { isMessageUnread } from '../util/isMessageUnread';
|
|
||||||
import {
|
import {
|
||||||
isDirectConversation,
|
isDirectConversation,
|
||||||
isGroup,
|
isGroup,
|
||||||
|
@ -181,78 +180,10 @@ import {
|
||||||
} from '../util/attachmentDownloadQueue';
|
} from '../util/attachmentDownloadQueue';
|
||||||
import { getTitleNoDefault, getNumber } from '../util/getTitle';
|
import { getTitleNoDefault, getNumber } from '../util/getTitle';
|
||||||
import dataInterface from '../sql/Client';
|
import dataInterface from '../sql/Client';
|
||||||
|
import * as Edits from '../messageModifiers/Edits';
|
||||||
function isSameUuid(
|
import { handleEditMessage } from '../util/handleEditMessage';
|
||||||
a: UUID | string | null | undefined,
|
import { getQuoteBodyText } from '../util/getQuoteBodyText';
|
||||||
b: UUID | string | null | undefined
|
import { shouldReplyNotifyUser } from '../util/shouldReplyNotifyUser';
|
||||||
): boolean {
|
|
||||||
return a != null && b != null && String(a) === String(b);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function shouldReplyNotifyUser(
|
|
||||||
message: MessageModel,
|
|
||||||
conversation: ConversationModel
|
|
||||||
): Promise<boolean> {
|
|
||||||
// Don't notify if the message has already been read
|
|
||||||
if (!isMessageUnread(message.attributes)) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
const storyId = message.get('storyId');
|
|
||||||
|
|
||||||
// If this is not a reply to a story, always notify.
|
|
||||||
if (storyId == null) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Always notify if this is not a group
|
|
||||||
if (!isGroup(conversation.attributes)) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
const matchedStory = window.reduxStore
|
|
||||||
.getState()
|
|
||||||
.stories.stories.find(story => {
|
|
||||||
return story.messageId === storyId;
|
|
||||||
});
|
|
||||||
|
|
||||||
// If we can't find the story, don't notify
|
|
||||||
if (matchedStory == null) {
|
|
||||||
log.warn("Couldn't find story for reply");
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
const currentUserId = window.textsecure.storage.user.getUuid();
|
|
||||||
const storySourceId = matchedStory.sourceUuid;
|
|
||||||
|
|
||||||
const currentUserIdSource = isSameUuid(storySourceId, currentUserId);
|
|
||||||
|
|
||||||
// If the story is from the current user, always notify
|
|
||||||
if (currentUserIdSource) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
// If the story is from a different user, only notify if the user has
|
|
||||||
// replied or reacted to the story
|
|
||||||
|
|
||||||
const replies = await dataInterface.getOlderMessagesByConversation({
|
|
||||||
conversationId: conversation.id,
|
|
||||||
limit: 9000,
|
|
||||||
storyId,
|
|
||||||
includeStoryReplies: true,
|
|
||||||
});
|
|
||||||
|
|
||||||
const prevCurrentUserReply = replies.find(replyMessage => {
|
|
||||||
return replyMessage.type === 'outgoing';
|
|
||||||
});
|
|
||||||
|
|
||||||
if (prevCurrentUserReply != null) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Otherwise don't notify
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* eslint-disable more/no-then */
|
/* eslint-disable more/no-then */
|
||||||
|
|
||||||
|
@ -1184,14 +1115,15 @@ export class MessageModel extends window.Backbone.Model<MessageAttributesType> {
|
||||||
}
|
}
|
||||||
|
|
||||||
this.set({
|
this.set({
|
||||||
isErased: true,
|
attachments: [],
|
||||||
body: '',
|
body: '',
|
||||||
bodyRanges: undefined,
|
bodyRanges: undefined,
|
||||||
attachments: [],
|
|
||||||
quote: undefined,
|
|
||||||
contact: [],
|
contact: [],
|
||||||
sticker: undefined,
|
editHistory: undefined,
|
||||||
|
isErased: true,
|
||||||
preview: [],
|
preview: [],
|
||||||
|
quote: undefined,
|
||||||
|
sticker: undefined,
|
||||||
...additionalProperties,
|
...additionalProperties,
|
||||||
});
|
});
|
||||||
this.getConversation()?.debouncedUpdateLastMessage?.();
|
this.getConversation()?.debouncedUpdateLastMessage?.();
|
||||||
|
@ -2045,7 +1977,8 @@ export class MessageModel extends window.Backbone.Model<MessageAttributesType> {
|
||||||
queryMessage = matchingMessage;
|
queryMessage = matchingMessage;
|
||||||
} else {
|
} else {
|
||||||
log.info('copyFromQuotedMessage: db lookup needed', id);
|
log.info('copyFromQuotedMessage: db lookup needed', id);
|
||||||
const messages = await window.Signal.Data.getMessagesBySentAt(id);
|
const messages =
|
||||||
|
await window.Signal.Data.getMessagesIncludingEditedBySentAt(id);
|
||||||
const found = messages.find(item =>
|
const found = messages.find(item =>
|
||||||
isQuoteAMatch(item, conversationId, result)
|
isQuoteAMatch(item, conversationId, result)
|
||||||
);
|
);
|
||||||
|
@ -2065,18 +1998,6 @@ export class MessageModel extends window.Backbone.Model<MessageAttributesType> {
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
getQuoteBodyText(): string | undefined {
|
|
||||||
const storyReactionEmoji = this.get('storyReaction')?.emoji;
|
|
||||||
const body = this.get('body');
|
|
||||||
const embeddedContact = this.get('contact');
|
|
||||||
const embeddedContactName =
|
|
||||||
embeddedContact && embeddedContact.length > 0
|
|
||||||
? EmbeddedContact.getName(embeddedContact[0])
|
|
||||||
: '';
|
|
||||||
|
|
||||||
return body || embeddedContactName || storyReactionEmoji;
|
|
||||||
}
|
|
||||||
|
|
||||||
async copyQuoteContentFromOriginal(
|
async copyQuoteContentFromOriginal(
|
||||||
originalMessage: MessageModel,
|
originalMessage: MessageModel,
|
||||||
quote: QuotedMessageType
|
quote: QuotedMessageType
|
||||||
|
@ -2125,7 +2046,7 @@ export class MessageModel extends window.Backbone.Model<MessageAttributesType> {
|
||||||
quote.isViewOnce = false;
|
quote.isViewOnce = false;
|
||||||
|
|
||||||
// eslint-disable-next-line no-param-reassign
|
// eslint-disable-next-line no-param-reassign
|
||||||
quote.text = originalMessage.getQuoteBodyText();
|
quote.text = getQuoteBodyText(originalMessage.attributes, quote.id);
|
||||||
if (firstAttachment) {
|
if (firstAttachment) {
|
||||||
firstAttachment.thumbnail = null;
|
firstAttachment.thumbnail = null;
|
||||||
}
|
}
|
||||||
|
@ -3338,6 +3259,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);
|
||||||
|
await Promise.all(
|
||||||
|
edits.map(editAttributes =>
|
||||||
|
handleEditMessage(message.attributes, editAttributes)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
if (changed && !isFirstRun) {
|
if (changed && !isFirstRun) {
|
||||||
log.info(
|
log.info(
|
||||||
`modifyTargetMessage/${this.idForLogging()}: Changes in second run; saving.`
|
`modifyTargetMessage/${this.idForLogging()}: Changes in second run; saving.`
|
||||||
|
|
|
@ -5,6 +5,7 @@ import type { MessageAttributesType } from '../model-types.d';
|
||||||
import { ReadStatus, maxReadStatus } from '../messages/MessageReadStatus';
|
import { ReadStatus, maxReadStatus } from '../messages/MessageReadStatus';
|
||||||
import { notificationService } from './notifications';
|
import { notificationService } from './notifications';
|
||||||
import { SeenStatus } from '../MessageSeenStatus';
|
import { SeenStatus } from '../MessageSeenStatus';
|
||||||
|
import { queueUpdateMessage } from '../util/messageBatcher';
|
||||||
|
|
||||||
function markReadOrViewed(
|
function markReadOrViewed(
|
||||||
messageAttrs: Readonly<MessageAttributesType>,
|
messageAttrs: Readonly<MessageAttributesType>,
|
||||||
|
@ -34,7 +35,7 @@ function markReadOrViewed(
|
||||||
notificationService.removeBy({ messageId });
|
notificationService.removeBy({ messageId });
|
||||||
|
|
||||||
if (!skipSave) {
|
if (!skipSave) {
|
||||||
window.Signal.Util.queueUpdateMessage(nextMessageAttributes);
|
queueUpdateMessage(nextMessageAttributes);
|
||||||
}
|
}
|
||||||
|
|
||||||
return nextMessageAttributes;
|
return nextMessageAttributes;
|
||||||
|
|
|
@ -128,12 +128,14 @@ class NotificationService extends EventEmitter {
|
||||||
public notify({
|
public notify({
|
||||||
icon,
|
icon,
|
||||||
message,
|
message,
|
||||||
|
messageId,
|
||||||
onNotificationClick,
|
onNotificationClick,
|
||||||
silent,
|
silent,
|
||||||
title,
|
title,
|
||||||
}: Readonly<{
|
}: Readonly<{
|
||||||
icon?: string;
|
icon?: string;
|
||||||
message: string;
|
message: string;
|
||||||
|
messageId?: string;
|
||||||
onNotificationClick: () => void;
|
onNotificationClick: () => void;
|
||||||
silent: boolean;
|
silent: boolean;
|
||||||
title: string;
|
title: string;
|
||||||
|
@ -149,6 +151,7 @@ class NotificationService extends EventEmitter {
|
||||||
icon,
|
icon,
|
||||||
silent:
|
silent:
|
||||||
silent || audioNotificationSupport !== AudioNotificationSupport.Native,
|
silent || audioNotificationSupport !== AudioNotificationSupport.Native,
|
||||||
|
tag: messageId,
|
||||||
});
|
});
|
||||||
notification.onclick = onNotificationClick;
|
notification.onclick = onNotificationClick;
|
||||||
|
|
||||||
|
|
|
@ -387,6 +387,13 @@ export type FTSOptimizationStateType = Readonly<{
|
||||||
done?: boolean;
|
done?: boolean;
|
||||||
}>;
|
}>;
|
||||||
|
|
||||||
|
export type EditedMessageType = Readonly<{
|
||||||
|
fromId: string;
|
||||||
|
messageId: string;
|
||||||
|
sentAt: number;
|
||||||
|
readStatus: MessageType['readStatus'];
|
||||||
|
}>;
|
||||||
|
|
||||||
export type DataInterface = {
|
export type DataInterface = {
|
||||||
close: () => Promise<void>;
|
close: () => Promise<void>;
|
||||||
removeDB: () => Promise<void>;
|
removeDB: () => Promise<void>;
|
||||||
|
@ -514,6 +521,10 @@ export type DataInterface = {
|
||||||
readAt?: number;
|
readAt?: number;
|
||||||
storyId?: string;
|
storyId?: string;
|
||||||
}) => Promise<GetUnreadByConversationAndMarkReadResultType>;
|
}) => Promise<GetUnreadByConversationAndMarkReadResultType>;
|
||||||
|
getUnreadEditedMessagesAndMarkRead: (options: {
|
||||||
|
fromId: string;
|
||||||
|
newestUnreadAt: number;
|
||||||
|
}) => Promise<GetUnreadByConversationAndMarkReadResultType>;
|
||||||
getUnreadReactionsAndMarkRead: (options: {
|
getUnreadReactionsAndMarkRead: (options: {
|
||||||
conversationId: string;
|
conversationId: string;
|
||||||
newestUnreadAt: number;
|
newestUnreadAt: number;
|
||||||
|
@ -543,9 +554,15 @@ export type DataInterface = {
|
||||||
messageIds: ReadonlyArray<string>
|
messageIds: ReadonlyArray<string>
|
||||||
) => Promise<Array<MessageType>>;
|
) => Promise<Array<MessageType>>;
|
||||||
_getAllMessages: () => Promise<Array<MessageType>>;
|
_getAllMessages: () => Promise<Array<MessageType>>;
|
||||||
|
_getAllEditedMessages: () => Promise<
|
||||||
|
Array<{ messageId: string; sentAt: number }>
|
||||||
|
>;
|
||||||
_removeAllMessages: () => Promise<void>;
|
_removeAllMessages: () => Promise<void>;
|
||||||
getAllMessageIds: () => Promise<Array<string>>;
|
getAllMessageIds: () => Promise<Array<string>>;
|
||||||
getMessagesBySentAt: (sentAt: number) => Promise<Array<MessageType>>;
|
getMessagesBySentAt: (sentAt: number) => Promise<Array<MessageType>>;
|
||||||
|
getMessagesIncludingEditedBySentAt: (
|
||||||
|
sentAt: number
|
||||||
|
) => Promise<Array<MessageType>>;
|
||||||
getExpiredMessages: () => Promise<Array<MessageType>>;
|
getExpiredMessages: () => Promise<Array<MessageType>>;
|
||||||
getMessagesUnexpectedlyMissingExpirationStartTimestamp: () => Promise<
|
getMessagesUnexpectedlyMissingExpirationStartTimestamp: () => Promise<
|
||||||
Array<MessageType>
|
Array<MessageType>
|
||||||
|
@ -592,6 +609,11 @@ export type DataInterface = {
|
||||||
getNearbyMessageFromDeletedSet: (
|
getNearbyMessageFromDeletedSet: (
|
||||||
options: GetNearbyMessageFromDeletedSetOptionsType
|
options: GetNearbyMessageFromDeletedSetOptionsType
|
||||||
) => Promise<string | null>;
|
) => Promise<string | null>;
|
||||||
|
saveEditedMessage: (
|
||||||
|
mainMessage: MessageType,
|
||||||
|
ourUuid: UUIDStringType,
|
||||||
|
opts: EditedMessageType
|
||||||
|
) => Promise<void>;
|
||||||
getUnprocessedCount: () => Promise<number>;
|
getUnprocessedCount: () => Promise<number>;
|
||||||
getUnprocessedByIdsAndIncrementAttempts: (
|
getUnprocessedByIdsAndIncrementAttempts: (
|
||||||
ids: ReadonlyArray<string>
|
ids: ReadonlyArray<string>
|
||||||
|
|
138
ts/sql/Server.ts
138
ts/sql/Server.ts
|
@ -87,6 +87,7 @@ import type {
|
||||||
ConversationType,
|
ConversationType,
|
||||||
DeleteSentProtoRecipientOptionsType,
|
DeleteSentProtoRecipientOptionsType,
|
||||||
DeleteSentProtoRecipientResultType,
|
DeleteSentProtoRecipientResultType,
|
||||||
|
EditedMessageType,
|
||||||
EmojiType,
|
EmojiType,
|
||||||
FTSOptimizationStateType,
|
FTSOptimizationStateType,
|
||||||
GetAllStoriesResultType,
|
GetAllStoriesResultType,
|
||||||
|
@ -252,9 +253,12 @@ const dataInterface: ServerInterface = {
|
||||||
getMessageById,
|
getMessageById,
|
||||||
getMessagesById,
|
getMessagesById,
|
||||||
_getAllMessages,
|
_getAllMessages,
|
||||||
|
_getAllEditedMessages,
|
||||||
_removeAllMessages,
|
_removeAllMessages,
|
||||||
getAllMessageIds,
|
getAllMessageIds,
|
||||||
getMessagesBySentAt,
|
getMessagesBySentAt,
|
||||||
|
getMessagesIncludingEditedBySentAt,
|
||||||
|
getUnreadEditedMessagesAndMarkRead,
|
||||||
getExpiredMessages,
|
getExpiredMessages,
|
||||||
getMessagesUnexpectedlyMissingExpirationStartTimestamp,
|
getMessagesUnexpectedlyMissingExpirationStartTimestamp,
|
||||||
getSoonestMessageExpiry,
|
getSoonestMessageExpiry,
|
||||||
|
@ -273,6 +277,7 @@ const dataInterface: ServerInterface = {
|
||||||
migrateConversationMessages,
|
migrateConversationMessages,
|
||||||
getMessagesBetween,
|
getMessagesBetween,
|
||||||
getNearbyMessageFromDeletedSet,
|
getNearbyMessageFromDeletedSet,
|
||||||
|
saveEditedMessage,
|
||||||
|
|
||||||
getUnprocessedCount,
|
getUnprocessedCount,
|
||||||
getUnprocessedByIdsAndIncrementAttempts,
|
getUnprocessedByIdsAndIncrementAttempts,
|
||||||
|
@ -5679,3 +5684,136 @@ async function removeAllProfileKeyCredentials(): Promise<void> {
|
||||||
`
|
`
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function saveEditedMessage(
|
||||||
|
mainMessage: MessageType,
|
||||||
|
ourUuid: UUIDStringType,
|
||||||
|
{ fromId, messageId, readStatus, sentAt }: EditedMessageType
|
||||||
|
): Promise<void> {
|
||||||
|
const db = getInstance();
|
||||||
|
|
||||||
|
db.transaction(() => {
|
||||||
|
assertSync(
|
||||||
|
saveMessageSync(mainMessage, {
|
||||||
|
ourUuid,
|
||||||
|
alreadyInTransaction: true,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
const [query, params] = sql`
|
||||||
|
INSERT INTO edited_messages (
|
||||||
|
fromId,
|
||||||
|
messageId,
|
||||||
|
sentAt,
|
||||||
|
readStatus
|
||||||
|
) VALUES (
|
||||||
|
${fromId},
|
||||||
|
${messageId},
|
||||||
|
${sentAt},
|
||||||
|
${readStatus}
|
||||||
|
);
|
||||||
|
`;
|
||||||
|
|
||||||
|
db.prepare(query).run(params);
|
||||||
|
})();
|
||||||
|
}
|
||||||
|
|
||||||
|
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 }>
|
||||||
|
> {
|
||||||
|
const db = getInstance();
|
||||||
|
|
||||||
|
return db
|
||||||
|
.prepare<Query>(
|
||||||
|
`
|
||||||
|
SELECT * FROM edited_messages;
|
||||||
|
`
|
||||||
|
)
|
||||||
|
.all({});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getUnreadEditedMessagesAndMarkRead({
|
||||||
|
fromId,
|
||||||
|
newestUnreadAt,
|
||||||
|
}: {
|
||||||
|
fromId: string;
|
||||||
|
newestUnreadAt: number;
|
||||||
|
}): Promise<GetUnreadByConversationAndMarkReadResultType> {
|
||||||
|
const db = getInstance();
|
||||||
|
|
||||||
|
return db.transaction(() => {
|
||||||
|
const [selectQuery, selectParams] = sql`
|
||||||
|
SELECT
|
||||||
|
messages.id,
|
||||||
|
messages.json,
|
||||||
|
edited_messages.sentAt,
|
||||||
|
edited_messages.readStatus
|
||||||
|
FROM edited_messages
|
||||||
|
JOIN messages
|
||||||
|
ON messages.id = edited_messages.messageId
|
||||||
|
WHERE
|
||||||
|
edited_messages.readStatus = ${ReadStatus.Unread} AND
|
||||||
|
edited_messages.fromId = ${fromId} AND
|
||||||
|
received_at <= ${newestUnreadAt}
|
||||||
|
ORDER BY messages.received_at DESC, messages.sent_at DESC;
|
||||||
|
`;
|
||||||
|
|
||||||
|
const rows = db.prepare(selectQuery).all(selectParams);
|
||||||
|
|
||||||
|
if (rows.length) {
|
||||||
|
const newestSentAt = rows[0].sentAt;
|
||||||
|
|
||||||
|
const [updateStatusQuery, updateStatusParams] = sql`
|
||||||
|
UPDATE edited_messages
|
||||||
|
SET
|
||||||
|
readStatus = ${ReadStatus.Read}
|
||||||
|
WHERE
|
||||||
|
readStatus = ${ReadStatus.Unread} AND
|
||||||
|
fromId = ${fromId} AND
|
||||||
|
sentAt <= ${newestSentAt};
|
||||||
|
`;
|
||||||
|
|
||||||
|
db.prepare(updateStatusQuery).run(updateStatusParams);
|
||||||
|
}
|
||||||
|
|
||||||
|
return rows.map(row => {
|
||||||
|
const json = jsonToObject<MessageType>(row.json);
|
||||||
|
return {
|
||||||
|
originalReadStatus: row.readStatus,
|
||||||
|
readStatus: ReadStatus.Read,
|
||||||
|
seenStatus: SeenStatus.Seen,
|
||||||
|
...pick(json, [
|
||||||
|
'expirationStartTimestamp',
|
||||||
|
'id',
|
||||||
|
'sent_at',
|
||||||
|
'source',
|
||||||
|
'sourceUuid',
|
||||||
|
'type',
|
||||||
|
]),
|
||||||
|
// Use the edited message timestamp
|
||||||
|
sent_at: row.sentAt,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
})();
|
||||||
|
}
|
||||||
|
|
34
ts/sql/migrations/80-edited-messages.ts
Normal file
34
ts/sql/migrations/80-edited-messages.ts
Normal file
|
@ -0,0 +1,34 @@
|
||||||
|
// Copyright 2023 Signal Messenger, LLC
|
||||||
|
// SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
|
||||||
|
import type { Database } from '@signalapp/better-sqlite3';
|
||||||
|
|
||||||
|
import type { LoggerType } from '../../types/Logging';
|
||||||
|
|
||||||
|
export default function updateToSchemaVersion80(
|
||||||
|
currentVersion: number,
|
||||||
|
db: Database,
|
||||||
|
logger: LoggerType
|
||||||
|
): void {
|
||||||
|
if (currentVersion >= 80) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
db.transaction(() => {
|
||||||
|
db.exec(`
|
||||||
|
CREATE TABLE edited_messages(
|
||||||
|
fromId STRING,
|
||||||
|
messageId STRING REFERENCES messages(id)
|
||||||
|
ON DELETE CASCADE,
|
||||||
|
sentAt INTEGER,
|
||||||
|
readStatus INTEGER
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX edited_messages_sent_at ON edited_messages (sentAt);
|
||||||
|
`);
|
||||||
|
|
||||||
|
db.pragma('user_version = 80');
|
||||||
|
})();
|
||||||
|
|
||||||
|
logger.info('updateToSchemaVersion80: success!');
|
||||||
|
}
|
|
@ -55,6 +55,7 @@ import updateToSchemaVersion76 from './76-optimize-convo-open-2';
|
||||||
import updateToSchemaVersion77 from './77-signal-tokenizer';
|
import updateToSchemaVersion77 from './77-signal-tokenizer';
|
||||||
import updateToSchemaVersion78 from './78-merge-receipt-jobs';
|
import updateToSchemaVersion78 from './78-merge-receipt-jobs';
|
||||||
import updateToSchemaVersion79 from './79-paging-lightbox';
|
import updateToSchemaVersion79 from './79-paging-lightbox';
|
||||||
|
import updateToSchemaVersion80 from './80-edited-messages';
|
||||||
|
|
||||||
function updateToSchemaVersion1(
|
function updateToSchemaVersion1(
|
||||||
currentVersion: number,
|
currentVersion: number,
|
||||||
|
@ -1979,6 +1980,8 @@ export const SCHEMA_VERSIONS = [
|
||||||
updateToSchemaVersion77,
|
updateToSchemaVersion77,
|
||||||
updateToSchemaVersion78,
|
updateToSchemaVersion78,
|
||||||
updateToSchemaVersion79,
|
updateToSchemaVersion79,
|
||||||
|
|
||||||
|
updateToSchemaVersion80,
|
||||||
];
|
];
|
||||||
|
|
||||||
export function updateSchema(db: Database, logger: LoggerType): void {
|
export function updateSchema(db: Database, logger: LoggerType): void {
|
||||||
|
|
|
@ -88,6 +88,7 @@ import type {
|
||||||
import { longRunningTaskWrapper } from '../../util/longRunningTaskWrapper';
|
import { longRunningTaskWrapper } from '../../util/longRunningTaskWrapper';
|
||||||
import { drop } from '../../util/drop';
|
import { drop } from '../../util/drop';
|
||||||
import { strictAssert } from '../../util/assert';
|
import { strictAssert } from '../../util/assert';
|
||||||
|
import { makeQuote } from '../../util/makeQuote';
|
||||||
|
|
||||||
// State
|
// State
|
||||||
// eslint-disable-next-line local-rules/type-alias-readonlydeep
|
// eslint-disable-next-line local-rules/type-alias-readonlydeep
|
||||||
|
@ -630,7 +631,7 @@ export function setQuoteByMessageId(
|
||||||
}
|
}
|
||||||
|
|
||||||
if (message) {
|
if (message) {
|
||||||
const quote = await conversation.makeQuote(message);
|
const quote = await makeQuote(message.attributes);
|
||||||
|
|
||||||
// In case the conversation changed while we were about to set the quote
|
// In case the conversation changed while we were about to set the quote
|
||||||
if (getState().conversations.selectedConversationId !== conversationId) {
|
if (getState().conversations.selectedConversationId !== conversationId) {
|
||||||
|
|
|
@ -1991,7 +1991,15 @@ function kickOffAttachmentDownload(
|
||||||
`kickOffAttachmentDownload: Message ${options.messageId} missing!`
|
`kickOffAttachmentDownload: Message ${options.messageId} missing!`
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
await message.queueAttachmentDownloads();
|
const didUpdateValues = await message.queueAttachmentDownloads();
|
||||||
|
|
||||||
|
if (didUpdateValues) {
|
||||||
|
drop(
|
||||||
|
window.Signal.Data.saveMessage(message.attributes, {
|
||||||
|
ourUuid: window.textsecure.storage.user.getCheckedUuid().toString(),
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
dispatch({
|
dispatch({
|
||||||
type: 'NOOP',
|
type: 'NOOP',
|
||||||
|
|
|
@ -4,15 +4,24 @@
|
||||||
import type { ThunkAction } from 'redux-thunk';
|
import type { ThunkAction } from 'redux-thunk';
|
||||||
import type { ReadonlyDeep } from 'type-fest';
|
import type { ReadonlyDeep } from 'type-fest';
|
||||||
import type { ExplodePromiseResultType } from '../../util/explodePromise';
|
import type { ExplodePromiseResultType } from '../../util/explodePromise';
|
||||||
import type { GroupV2PendingMemberType } from '../../model-types.d';
|
import type {
|
||||||
import type { PropsForMessage } from '../selectors/message';
|
GroupV2PendingMemberType,
|
||||||
|
MessageAttributesType,
|
||||||
|
} from '../../model-types.d';
|
||||||
|
import type {
|
||||||
|
MessageChangedActionType,
|
||||||
|
MessageDeletedActionType,
|
||||||
|
MessageExpiredActionType,
|
||||||
|
} from './conversations';
|
||||||
|
import type { MessagePropsType } from '../selectors/message';
|
||||||
import type { RecipientsByConversation } from './stories';
|
import type { RecipientsByConversation } from './stories';
|
||||||
import type { SafetyNumberChangeSource } from '../../components/SafetyNumberChangeDialog';
|
import type { SafetyNumberChangeSource } from '../../components/SafetyNumberChangeDialog';
|
||||||
import type { StateType as RootStateType } from '../reducer';
|
import type { StateType as RootStateType } from '../reducer';
|
||||||
import type { UUIDStringType } from '../../types/UUID';
|
import type { UUIDStringType } from '../../types/UUID';
|
||||||
|
import * as Errors from '../../types/errors';
|
||||||
import * as SingleServePromise from '../../services/singleServePromise';
|
import * as SingleServePromise from '../../services/singleServePromise';
|
||||||
import * as Stickers from '../../types/Stickers';
|
import * as Stickers from '../../types/Stickers';
|
||||||
import * as Errors from '../../types/errors';
|
import * as log from '../../logging/log';
|
||||||
import { getMessageById } from '../../messages/getMessageById';
|
import { getMessageById } from '../../messages/getMessageById';
|
||||||
import { getMessagePropsSelector } from '../selectors/message';
|
import { getMessagePropsSelector } from '../selectors/message';
|
||||||
import type { BoundActionCreatorsMapObject } from '../../hooks/useBoundActions';
|
import type { BoundActionCreatorsMapObject } from '../../hooks/useBoundActions';
|
||||||
|
@ -22,19 +31,24 @@ import { isGroupV1 } from '../../util/whatTypeOfConversation';
|
||||||
import { authorizeArtCreator } from '../../textsecure/authorizeArtCreator';
|
import { authorizeArtCreator } from '../../textsecure/authorizeArtCreator';
|
||||||
import type { AuthorizeArtCreatorOptionsType } from '../../textsecure/authorizeArtCreator';
|
import type { AuthorizeArtCreatorOptionsType } from '../../textsecure/authorizeArtCreator';
|
||||||
import { getGroupMigrationMembers } from '../../groups';
|
import { getGroupMigrationMembers } from '../../groups';
|
||||||
import * as log from '../../logging/log';
|
|
||||||
import { ToastType } from '../../types/Toast';
|
import { ToastType } from '../../types/Toast';
|
||||||
|
import {
|
||||||
|
MESSAGE_CHANGED,
|
||||||
|
MESSAGE_DELETED,
|
||||||
|
MESSAGE_EXPIRED,
|
||||||
|
} from './conversations';
|
||||||
import { SHOW_TOAST } from './toast';
|
import { SHOW_TOAST } from './toast';
|
||||||
import type { ShowToastActionType } from './toast';
|
import type { ShowToastActionType } from './toast';
|
||||||
|
|
||||||
// State
|
// State
|
||||||
|
|
||||||
|
export type EditHistoryMessagesType = ReadonlyDeep<
|
||||||
|
Array<MessageAttributesType>
|
||||||
|
>;
|
||||||
export type ConfirmDeleteForMeModalProps = ReadonlyDeep<{
|
export type ConfirmDeleteForMeModalProps = ReadonlyDeep<{
|
||||||
count: number;
|
count: number;
|
||||||
}>;
|
}>;
|
||||||
export type ForwardMessagePropsType = ReadonlyDeep<
|
export type ForwardMessagePropsType = ReadonlyDeep<MessagePropsType>;
|
||||||
Omit<PropsForMessage, 'renderingContext' | 'menu' | 'contextMenu'>
|
|
||||||
>;
|
|
||||||
export type ForwardMessagesPropsType = ReadonlyDeep<{
|
export type ForwardMessagesPropsType = ReadonlyDeep<{
|
||||||
messages: Array<ForwardMessagePropsType>;
|
messages: Array<ForwardMessagePropsType>;
|
||||||
onForward?: () => void;
|
onForward?: () => void;
|
||||||
|
@ -57,6 +71,7 @@ type MigrateToGV2PropsType = ReadonlyDeep<{
|
||||||
export type GlobalModalsStateType = ReadonlyDeep<{
|
export type GlobalModalsStateType = ReadonlyDeep<{
|
||||||
addUserToAnotherGroupModalContactId?: string;
|
addUserToAnotherGroupModalContactId?: string;
|
||||||
contactModalState?: ContactModalStateType;
|
contactModalState?: ContactModalStateType;
|
||||||
|
editHistoryMessages?: EditHistoryMessagesType;
|
||||||
errorModalProps?: {
|
errorModalProps?: {
|
||||||
description?: string;
|
description?: string;
|
||||||
title?: string;
|
title?: string;
|
||||||
|
@ -115,6 +130,8 @@ const CONFIRM_AUTH_ART_CREATOR_PENDING =
|
||||||
'globalModals/CONFIRM_AUTH_ART_CREATOR_PENDING';
|
'globalModals/CONFIRM_AUTH_ART_CREATOR_PENDING';
|
||||||
const CONFIRM_AUTH_ART_CREATOR_FULFILLED =
|
const CONFIRM_AUTH_ART_CREATOR_FULFILLED =
|
||||||
'globalModals/CONFIRM_AUTH_ART_CREATOR_FULFILLED';
|
'globalModals/CONFIRM_AUTH_ART_CREATOR_FULFILLED';
|
||||||
|
const SHOW_EDIT_HISTORY_MODAL = 'globalModals/SHOW_EDIT_HISTORY_MODAL';
|
||||||
|
const CLOSE_EDIT_HISTORY_MODAL = 'globalModals/CLOSE_EDIT_HISTORY_MODAL';
|
||||||
|
|
||||||
export type ContactModalStateType = ReadonlyDeep<{
|
export type ContactModalStateType = ReadonlyDeep<{
|
||||||
contactId: string;
|
contactId: string;
|
||||||
|
@ -264,34 +281,50 @@ type ConfirmAuthArtCreatorFulfilledActionType = ReadonlyDeep<{
|
||||||
type: typeof CONFIRM_AUTH_ART_CREATOR_FULFILLED;
|
type: typeof CONFIRM_AUTH_ART_CREATOR_FULFILLED;
|
||||||
}>;
|
}>;
|
||||||
|
|
||||||
|
type ShowEditHistoryModalActionType = ReadonlyDeep<{
|
||||||
|
type: typeof SHOW_EDIT_HISTORY_MODAL;
|
||||||
|
payload: {
|
||||||
|
messages: EditHistoryMessagesType;
|
||||||
|
};
|
||||||
|
}>;
|
||||||
|
|
||||||
|
type CloseEditHistoryModalActionType = ReadonlyDeep<{
|
||||||
|
type: typeof CLOSE_EDIT_HISTORY_MODAL;
|
||||||
|
}>;
|
||||||
|
|
||||||
export type GlobalModalsActionType = ReadonlyDeep<
|
export type GlobalModalsActionType = ReadonlyDeep<
|
||||||
| StartMigrationToGV2ActionType
|
|
||||||
| CloseGV2MigrationDialogActionType
|
|
||||||
| HideContactModalActionType
|
|
||||||
| ShowContactModalActionType
|
|
||||||
| HideWhatsNewModalActionType
|
|
||||||
| ShowWhatsNewModalActionType
|
|
||||||
| HideUserNotFoundModalActionType
|
|
||||||
| ShowUserNotFoundModalActionType
|
|
||||||
| HideStoriesSettingsActionType
|
|
||||||
| ShowStoriesSettingsActionType
|
|
||||||
| HideSendAnywayDialogActiontype
|
|
||||||
| ShowSendAnywayDialogActionType
|
|
||||||
| CloseStickerPackPreviewActionType
|
|
||||||
| ShowStickerPackPreviewActionType
|
|
||||||
| CloseErrorModalActionType
|
|
||||||
| ShowErrorModalActionType
|
|
||||||
| CloseShortcutGuideModalActionType
|
|
||||||
| ShowShortcutGuideModalActionType
|
|
||||||
| CancelAuthArtCreatorActionType
|
| CancelAuthArtCreatorActionType
|
||||||
| ConfirmAuthArtCreatorPendingActionType
|
| CloseEditHistoryModalActionType
|
||||||
|
| CloseErrorModalActionType
|
||||||
|
| CloseGV2MigrationDialogActionType
|
||||||
|
| CloseShortcutGuideModalActionType
|
||||||
|
| CloseStickerPackPreviewActionType
|
||||||
| ConfirmAuthArtCreatorFulfilledActionType
|
| ConfirmAuthArtCreatorFulfilledActionType
|
||||||
|
| ConfirmAuthArtCreatorPendingActionType
|
||||||
|
| HideContactModalActionType
|
||||||
|
| HideSendAnywayDialogActiontype
|
||||||
|
| HideStoriesSettingsActionType
|
||||||
|
| HideUserNotFoundModalActionType
|
||||||
|
| HideWhatsNewModalActionType
|
||||||
|
| MessageChangedActionType
|
||||||
|
| MessageDeletedActionType
|
||||||
|
| MessageExpiredActionType
|
||||||
| ShowAuthArtCreatorActionType
|
| ShowAuthArtCreatorActionType
|
||||||
|
| ShowContactModalActionType
|
||||||
|
| ShowEditHistoryModalActionType
|
||||||
|
| ShowErrorModalActionType
|
||||||
|
| ShowSendAnywayDialogActionType
|
||||||
|
| ShowShortcutGuideModalActionType
|
||||||
|
| ShowStickerPackPreviewActionType
|
||||||
|
| ShowStoriesSettingsActionType
|
||||||
|
| ShowUserNotFoundModalActionType
|
||||||
|
| ShowWhatsNewModalActionType
|
||||||
|
| StartMigrationToGV2ActionType
|
||||||
|
| ToggleAddUserToAnotherGroupModalActionType
|
||||||
| ToggleForwardMessagesModalActionType
|
| ToggleForwardMessagesModalActionType
|
||||||
| ToggleProfileEditorActionType
|
| ToggleProfileEditorActionType
|
||||||
| ToggleProfileEditorErrorActionType
|
| ToggleProfileEditorErrorActionType
|
||||||
| ToggleSafetyNumberModalActionType
|
| ToggleSafetyNumberModalActionType
|
||||||
| ToggleAddUserToAnotherGroupModalActionType
|
|
||||||
| ToggleSignalConnectionsModalActionType
|
| ToggleSignalConnectionsModalActionType
|
||||||
| ToggleConfirmationModalActionType
|
| ToggleConfirmationModalActionType
|
||||||
>;
|
>;
|
||||||
|
@ -299,34 +332,36 @@ export type GlobalModalsActionType = ReadonlyDeep<
|
||||||
// Action Creators
|
// Action Creators
|
||||||
|
|
||||||
export const actions = {
|
export const actions = {
|
||||||
hideContactModal,
|
cancelAuthorizeArtCreator,
|
||||||
showContactModal,
|
closeEditHistoryModal,
|
||||||
hideWhatsNewModal,
|
closeErrorModal,
|
||||||
showWhatsNewModal,
|
closeGV2MigrationDialog,
|
||||||
hideUserNotFoundModal,
|
closeShortcutGuideModal,
|
||||||
showUserNotFoundModal,
|
closeStickerPackPreview,
|
||||||
hideStoriesSettings,
|
confirmAuthorizeArtCreator,
|
||||||
showStoriesSettings,
|
|
||||||
hideBlockingSafetyNumberChangeDialog,
|
hideBlockingSafetyNumberChangeDialog,
|
||||||
|
hideContactModal,
|
||||||
|
hideStoriesSettings,
|
||||||
|
hideUserNotFoundModal,
|
||||||
|
hideWhatsNewModal,
|
||||||
|
showAuthorizeArtCreator,
|
||||||
showBlockingSafetyNumberChangeDialog,
|
showBlockingSafetyNumberChangeDialog,
|
||||||
|
showContactModal,
|
||||||
|
showEditHistoryModal,
|
||||||
|
showErrorModal,
|
||||||
|
showGV2MigrationDialog,
|
||||||
|
showShortcutGuideModal,
|
||||||
|
showStickerPackPreview,
|
||||||
|
showStoriesSettings,
|
||||||
|
showUserNotFoundModal,
|
||||||
|
showWhatsNewModal,
|
||||||
|
toggleAddUserToAnotherGroupModal,
|
||||||
|
toggleConfirmationModal,
|
||||||
toggleForwardMessagesModal,
|
toggleForwardMessagesModal,
|
||||||
toggleProfileEditor,
|
toggleProfileEditor,
|
||||||
toggleProfileEditorHasError,
|
toggleProfileEditorHasError,
|
||||||
toggleSafetyNumberModal,
|
toggleSafetyNumberModal,
|
||||||
toggleAddUserToAnotherGroupModal,
|
|
||||||
toggleSignalConnectionsModal,
|
toggleSignalConnectionsModal,
|
||||||
toggleConfirmationModal,
|
|
||||||
showGV2MigrationDialog,
|
|
||||||
closeGV2MigrationDialog,
|
|
||||||
showStickerPackPreview,
|
|
||||||
closeStickerPackPreview,
|
|
||||||
closeErrorModal,
|
|
||||||
showErrorModal,
|
|
||||||
closeShortcutGuideModal,
|
|
||||||
showShortcutGuideModal,
|
|
||||||
showAuthorizeArtCreator,
|
|
||||||
cancelAuthorizeArtCreator,
|
|
||||||
confirmAuthorizeArtCreator,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export const useGlobalModalActions = (): BoundActionCreatorsMapObject<
|
export const useGlobalModalActions = (): BoundActionCreatorsMapObject<
|
||||||
|
@ -632,6 +667,56 @@ function cancelAuthorizeArtCreator(): ThunkAction<
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function copyOverMessageAttributesIntoEditHistory(
|
||||||
|
messageAttributes: ReadonlyDeep<MessageAttributesType>
|
||||||
|
): EditHistoryMessagesType | undefined {
|
||||||
|
if (!messageAttributes.editHistory) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
return messageAttributes.editHistory.map(editedMessageAttributes => ({
|
||||||
|
...messageAttributes,
|
||||||
|
...editedMessageAttributes,
|
||||||
|
// For timestamp uniqueness of messages
|
||||||
|
sent_at: editedMessageAttributes.timestamp,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
function showEditHistoryModal(
|
||||||
|
messageId: string
|
||||||
|
): ThunkAction<void, RootStateType, unknown, ShowEditHistoryModalActionType> {
|
||||||
|
return async dispatch => {
|
||||||
|
const message = await getMessageById(messageId);
|
||||||
|
|
||||||
|
if (!message) {
|
||||||
|
log.warn('showEditHistoryModal: no message found');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const messageAttributes = message.attributes;
|
||||||
|
const nextEditHistoryMessages =
|
||||||
|
copyOverMessageAttributesIntoEditHistory(messageAttributes);
|
||||||
|
|
||||||
|
if (!nextEditHistoryMessages) {
|
||||||
|
log.warn('showEditHistoryModal: no edit history for message');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
dispatch({
|
||||||
|
type: SHOW_EDIT_HISTORY_MODAL,
|
||||||
|
payload: {
|
||||||
|
messages: nextEditHistoryMessages,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeEditHistoryModal(): CloseEditHistoryModalActionType {
|
||||||
|
return {
|
||||||
|
type: CLOSE_EDIT_HISTORY_MODAL,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export function showAuthorizeArtCreator(
|
export function showAuthorizeArtCreator(
|
||||||
data: AuthorizeArtCreatorDataType
|
data: AuthorizeArtCreatorDataType
|
||||||
): ShowAuthArtCreatorActionType {
|
): ShowAuthArtCreatorActionType {
|
||||||
|
@ -896,5 +981,71 @@ export function reducer(
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (action.type === SHOW_EDIT_HISTORY_MODAL) {
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
editHistoryMessages: action.payload.messages,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (action.type === CLOSE_EDIT_HISTORY_MODAL) {
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
editHistoryMessages: undefined,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
action.type === MESSAGE_CHANGED ||
|
||||||
|
action.type === MESSAGE_DELETED ||
|
||||||
|
action.type === MESSAGE_EXPIRED
|
||||||
|
) {
|
||||||
|
if (!state.editHistoryMessages) {
|
||||||
|
return state;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (action.type === MESSAGE_DELETED || action.type === MESSAGE_EXPIRED) {
|
||||||
|
const hasMessageId = state.editHistoryMessages.some(
|
||||||
|
edit => edit.id === action.payload.id
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!hasMessageId) {
|
||||||
|
return state;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
editHistoryMessages: undefined,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (action.type === MESSAGE_CHANGED) {
|
||||||
|
if (!action.payload.data.editHistory) {
|
||||||
|
return state;
|
||||||
|
}
|
||||||
|
|
||||||
|
const hasMessageId = state.editHistoryMessages.some(
|
||||||
|
edit => edit.id === action.payload.id
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!hasMessageId) {
|
||||||
|
return state;
|
||||||
|
}
|
||||||
|
|
||||||
|
const nextEditHistoryMessages = copyOverMessageAttributesIntoEditHistory(
|
||||||
|
action.payload.data
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!nextEditHistoryMessages) {
|
||||||
|
return state;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
editHistoryMessages: nextEditHistoryMessages,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return state;
|
return state;
|
||||||
}
|
}
|
||||||
|
|
|
@ -136,6 +136,10 @@ type FormattedContact = Partial<ConversationType> &
|
||||||
| 'unblurredAvatarPath'
|
| 'unblurredAvatarPath'
|
||||||
>;
|
>;
|
||||||
export type PropsForMessage = Omit<TimelineMessagePropsData, 'interactionMode'>;
|
export type PropsForMessage = Omit<TimelineMessagePropsData, 'interactionMode'>;
|
||||||
|
export type MessagePropsType = Omit<
|
||||||
|
PropsForMessage,
|
||||||
|
'renderingContext' | 'menu' | 'contextMenu'
|
||||||
|
>;
|
||||||
type PropsForUnsupportedMessage = {
|
type PropsForUnsupportedMessage = {
|
||||||
canProcessNow: boolean;
|
canProcessNow: boolean;
|
||||||
contact: FormattedContact;
|
contact: FormattedContact;
|
||||||
|
@ -718,6 +722,7 @@ export const getPropsForMessage = (
|
||||||
giftBadge: message.giftBadge,
|
giftBadge: message.giftBadge,
|
||||||
id: message.id,
|
id: message.id,
|
||||||
isBlocked: conversation.isBlocked || false,
|
isBlocked: conversation.isBlocked || false,
|
||||||
|
isEditedMessage: Boolean(message.editHistory),
|
||||||
isMessageRequestAccepted: conversation?.acceptedMessageRequest ?? true,
|
isMessageRequestAccepted: conversation?.acceptedMessageRequest ?? true,
|
||||||
isTargeted,
|
isTargeted,
|
||||||
isTargetedCounter: isTargeted ? targetedMessageCounter : undefined,
|
isTargetedCounter: isTargeted ? targetedMessageCounter : undefined,
|
||||||
|
|
57
ts/state/smart/EditHistoryMessagesModal.tsx
Normal file
57
ts/state/smart/EditHistoryMessagesModal.tsx
Normal file
|
@ -0,0 +1,57 @@
|
||||||
|
// Copyright 2023 Signal Messenger, LLC
|
||||||
|
// SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
|
||||||
|
import React, { useMemo } from 'react';
|
||||||
|
import { useSelector } from 'react-redux';
|
||||||
|
import type { GlobalModalsStateType } from '../ducks/globalModals';
|
||||||
|
import type { MessageAttributesType } from '../../model-types.d';
|
||||||
|
import type { StateType } from '../reducer';
|
||||||
|
import { EditHistoryMessagesModal } from '../../components/EditHistoryMessagesModal';
|
||||||
|
import { getIntl } from '../selectors/user';
|
||||||
|
import { getMessagePropsSelector } from '../selectors/message';
|
||||||
|
import { getPreferredBadgeSelector } from '../selectors/badges';
|
||||||
|
import { useConversationsActions } from '../ducks/conversations';
|
||||||
|
import { useGlobalModalActions } from '../ducks/globalModals';
|
||||||
|
import { useLightboxActions } from '../ducks/lightbox';
|
||||||
|
import { strictAssert } from '../../util/assert';
|
||||||
|
|
||||||
|
export function SmartEditHistoryMessagesModal(): JSX.Element {
|
||||||
|
const i18n = useSelector(getIntl);
|
||||||
|
|
||||||
|
const { closeEditHistoryModal } = useGlobalModalActions();
|
||||||
|
|
||||||
|
const { kickOffAttachmentDownload } = useConversationsActions();
|
||||||
|
const { showLightbox } = useLightboxActions();
|
||||||
|
|
||||||
|
const getPreferredBadge = useSelector(getPreferredBadgeSelector);
|
||||||
|
|
||||||
|
const { editHistoryMessages: messagesAttributes } = useSelector<
|
||||||
|
StateType,
|
||||||
|
GlobalModalsStateType
|
||||||
|
>(state => state.globalModals);
|
||||||
|
|
||||||
|
const messagePropsSelector = useSelector(getMessagePropsSelector);
|
||||||
|
|
||||||
|
strictAssert(messagesAttributes, 'messages not provided');
|
||||||
|
|
||||||
|
const editHistoryMessages = useMemo(() => {
|
||||||
|
return messagesAttributes.map(messageAttributes => ({
|
||||||
|
...messagePropsSelector(messageAttributes as MessageAttributesType),
|
||||||
|
// Make sure the messages don't get an "edited" badge
|
||||||
|
editHistory: undefined,
|
||||||
|
// Do not show the same reactions in the message history UI
|
||||||
|
reactions: undefined,
|
||||||
|
}));
|
||||||
|
}, [messagesAttributes, messagePropsSelector]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<EditHistoryMessagesModal
|
||||||
|
closeEditHistoryModal={closeEditHistoryModal}
|
||||||
|
editHistoryMessages={editHistoryMessages}
|
||||||
|
getPreferredBadge={getPreferredBadge}
|
||||||
|
i18n={i18n}
|
||||||
|
kickOffAttachmentDownload={kickOffAttachmentDownload}
|
||||||
|
showLightbox={showLightbox}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
|
@ -10,6 +10,7 @@ import { ErrorModal } from '../../components/ErrorModal';
|
||||||
import { GlobalModalContainer } from '../../components/GlobalModalContainer';
|
import { GlobalModalContainer } from '../../components/GlobalModalContainer';
|
||||||
import { SmartAddUserToAnotherGroupModal } from './AddUserToAnotherGroupModal';
|
import { SmartAddUserToAnotherGroupModal } from './AddUserToAnotherGroupModal';
|
||||||
import { SmartContactModal } from './ContactModal';
|
import { SmartContactModal } from './ContactModal';
|
||||||
|
import { SmartEditHistoryMessagesModal } from './EditHistoryMessagesModal';
|
||||||
import { SmartForwardMessagesModal } from './ForwardMessagesModal';
|
import { SmartForwardMessagesModal } from './ForwardMessagesModal';
|
||||||
import { SmartProfileEditorModal } from './ProfileEditorModal';
|
import { SmartProfileEditorModal } from './ProfileEditorModal';
|
||||||
import { SmartSafetyNumberModal } from './SafetyNumberModal';
|
import { SmartSafetyNumberModal } from './SafetyNumberModal';
|
||||||
|
@ -21,6 +22,10 @@ import { getConversationsStoppingSend } from '../selectors/conversations';
|
||||||
import { getIntl, getTheme } from '../selectors/user';
|
import { getIntl, getTheme } from '../selectors/user';
|
||||||
import { useGlobalModalActions } from '../ducks/globalModals';
|
import { useGlobalModalActions } from '../ducks/globalModals';
|
||||||
|
|
||||||
|
function renderEditHistoryMessagesModal(): JSX.Element {
|
||||||
|
return <SmartEditHistoryMessagesModal />;
|
||||||
|
}
|
||||||
|
|
||||||
function renderProfileEditor(): JSX.Element {
|
function renderProfileEditor(): JSX.Element {
|
||||||
return <SmartProfileEditorModal />;
|
return <SmartProfileEditorModal />;
|
||||||
}
|
}
|
||||||
|
@ -55,6 +60,7 @@ export function SmartGlobalModalContainer(): JSX.Element {
|
||||||
const {
|
const {
|
||||||
addUserToAnotherGroupModalContactId,
|
addUserToAnotherGroupModalContactId,
|
||||||
contactModalState,
|
contactModalState,
|
||||||
|
editHistoryMessages,
|
||||||
errorModalProps,
|
errorModalProps,
|
||||||
forwardMessagesProps,
|
forwardMessagesProps,
|
||||||
isProfileEditorVisible,
|
isProfileEditorVisible,
|
||||||
|
@ -120,6 +126,7 @@ export function SmartGlobalModalContainer(): JSX.Element {
|
||||||
<GlobalModalContainer
|
<GlobalModalContainer
|
||||||
addUserToAnotherGroupModalContactId={addUserToAnotherGroupModalContactId}
|
addUserToAnotherGroupModalContactId={addUserToAnotherGroupModalContactId}
|
||||||
contactModalState={contactModalState}
|
contactModalState={contactModalState}
|
||||||
|
editHistoryMessages={editHistoryMessages}
|
||||||
errorModalProps={errorModalProps}
|
errorModalProps={errorModalProps}
|
||||||
forwardMessagesProps={forwardMessagesProps}
|
forwardMessagesProps={forwardMessagesProps}
|
||||||
hasSafetyNumberChangeModal={hasSafetyNumberChangeModal}
|
hasSafetyNumberChangeModal={hasSafetyNumberChangeModal}
|
||||||
|
@ -133,6 +140,7 @@ export function SmartGlobalModalContainer(): JSX.Element {
|
||||||
isWhatsNewVisible={isWhatsNewVisible}
|
isWhatsNewVisible={isWhatsNewVisible}
|
||||||
renderAddUserToAnotherGroup={renderAddUserToAnotherGroup}
|
renderAddUserToAnotherGroup={renderAddUserToAnotherGroup}
|
||||||
renderContactModal={renderContactModal}
|
renderContactModal={renderContactModal}
|
||||||
|
renderEditHistoryMessagesModal={renderEditHistoryMessagesModal}
|
||||||
renderErrorModal={renderErrorModal}
|
renderErrorModal={renderErrorModal}
|
||||||
renderForwardMessagesModal={renderForwardMessagesModal}
|
renderForwardMessagesModal={renderForwardMessagesModal}
|
||||||
renderProfileEditor={renderProfileEditor}
|
renderProfileEditor={renderProfileEditor}
|
||||||
|
|
|
@ -130,6 +130,7 @@ export function SmartTimelineItem(props: ExternalProps): JSX.Element {
|
||||||
|
|
||||||
const {
|
const {
|
||||||
showContactModal,
|
showContactModal,
|
||||||
|
showEditHistoryModal,
|
||||||
toggleForwardMessagesModal,
|
toggleForwardMessagesModal,
|
||||||
toggleSafetyNumberModal,
|
toggleSafetyNumberModal,
|
||||||
} = useGlobalModalActions();
|
} = useGlobalModalActions();
|
||||||
|
@ -161,6 +162,7 @@ export function SmartTimelineItem(props: ExternalProps): JSX.Element {
|
||||||
shouldCollapseBelow={shouldCollapseBelow}
|
shouldCollapseBelow={shouldCollapseBelow}
|
||||||
shouldHideMetadata={shouldHideMetadata}
|
shouldHideMetadata={shouldHideMetadata}
|
||||||
shouldRenderDateHeader={shouldRenderDateHeader}
|
shouldRenderDateHeader={shouldRenderDateHeader}
|
||||||
|
showEditHistoryModal={showEditHistoryModal}
|
||||||
i18n={i18n}
|
i18n={i18n}
|
||||||
interactionMode={interactionMode}
|
interactionMode={interactionMode}
|
||||||
theme={theme}
|
theme={theme}
|
||||||
|
|
126
ts/test-mock/messaging/edit_test.ts
Normal file
126
ts/test-mock/messaging/edit_test.ts
Normal file
|
@ -0,0 +1,126 @@
|
||||||
|
// Copyright 2023 Signal Messenger, LLC
|
||||||
|
// SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
|
||||||
|
import type { Proto } from '@signalapp/mock-server';
|
||||||
|
import { assert } from 'chai';
|
||||||
|
import createDebug from 'debug';
|
||||||
|
import Long from 'long';
|
||||||
|
import { strictAssert } from '../../util/assert';
|
||||||
|
|
||||||
|
import * as durations from '../../util/durations';
|
||||||
|
import type { App } from '../playwright';
|
||||||
|
import { Bootstrap } from '../bootstrap';
|
||||||
|
|
||||||
|
export const debug = createDebug('mock:test:edit');
|
||||||
|
|
||||||
|
function wrap({
|
||||||
|
dataMessage,
|
||||||
|
editMessage,
|
||||||
|
}: {
|
||||||
|
dataMessage?: Proto.IDataMessage;
|
||||||
|
editMessage?: Proto.IEditMessage;
|
||||||
|
}): Proto.IContent {
|
||||||
|
return {
|
||||||
|
dataMessage,
|
||||||
|
editMessage,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function createMessage(): Proto.IDataMessage {
|
||||||
|
return {
|
||||||
|
body: 'hey yhere',
|
||||||
|
groupV2: undefined,
|
||||||
|
timestamp: Long.fromNumber(Date.now()),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function createEditedMessage(
|
||||||
|
targetMessage: Proto.IDataMessage
|
||||||
|
): Proto.IEditMessage {
|
||||||
|
strictAssert(targetMessage.timestamp, 'timestamp missing');
|
||||||
|
|
||||||
|
return {
|
||||||
|
targetSentTimestamp: targetMessage.timestamp,
|
||||||
|
dataMessage: {
|
||||||
|
body: 'hey there',
|
||||||
|
groupV2: undefined,
|
||||||
|
timestamp: Long.fromNumber(Date.now()),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('editing', function needsName() {
|
||||||
|
this.timeout(durations.MINUTE);
|
||||||
|
|
||||||
|
let bootstrap: Bootstrap;
|
||||||
|
let app: App;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
bootstrap = new Bootstrap();
|
||||||
|
await bootstrap.init();
|
||||||
|
app = await bootstrap.link();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(async function after() {
|
||||||
|
if (!bootstrap) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.currentTest?.state !== 'passed') {
|
||||||
|
await bootstrap.saveLogs(app);
|
||||||
|
}
|
||||||
|
|
||||||
|
await app.close();
|
||||||
|
await bootstrap.teardown();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should edit a message', async () => {
|
||||||
|
const { phone, desktop } = bootstrap;
|
||||||
|
|
||||||
|
const window = await app.getWindow();
|
||||||
|
|
||||||
|
const originalMessage = createMessage();
|
||||||
|
|
||||||
|
debug('sending message');
|
||||||
|
{
|
||||||
|
const sendOptions = {
|
||||||
|
timestamp: Number(originalMessage.timestamp),
|
||||||
|
};
|
||||||
|
await phone.sendRaw(
|
||||||
|
desktop,
|
||||||
|
wrap({ dataMessage: originalMessage }),
|
||||||
|
sendOptions
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
debug('opening conversation');
|
||||||
|
const leftPane = window.locator('.left-pane-wrapper');
|
||||||
|
await leftPane
|
||||||
|
.locator('.module-conversation-list__item--contact-or-conversation')
|
||||||
|
.first()
|
||||||
|
.click();
|
||||||
|
await window.locator('.module-conversation-hero').waitFor();
|
||||||
|
|
||||||
|
debug('checking for message');
|
||||||
|
await window.locator('.module-message__text >> "hey yhere"').waitFor();
|
||||||
|
|
||||||
|
debug('sending edited message');
|
||||||
|
{
|
||||||
|
const editedMessage = createEditedMessage(originalMessage);
|
||||||
|
const sendOptions = {
|
||||||
|
timestamp: Number(editedMessage.dataMessage?.timestamp),
|
||||||
|
};
|
||||||
|
await phone.sendRaw(
|
||||||
|
desktop,
|
||||||
|
wrap({ editMessage: editedMessage }),
|
||||||
|
sendOptions
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
debug('checking for edited message');
|
||||||
|
await window.locator('.module-message__text >> "hey there"').waitFor();
|
||||||
|
|
||||||
|
const messages = window.locator('.module-message__text');
|
||||||
|
assert.strictEqual(await messages.count(), 1, 'message count');
|
||||||
|
});
|
||||||
|
});
|
|
@ -122,6 +122,7 @@ import type { SendTypesType } from '../util/handleMessageSend';
|
||||||
import { getStoriesBlocked } from '../util/stories';
|
import { getStoriesBlocked } from '../util/stories';
|
||||||
import { isNotNil } from '../util/isNotNil';
|
import { isNotNil } from '../util/isNotNil';
|
||||||
import { chunk } from '../util/iterables';
|
import { chunk } from '../util/iterables';
|
||||||
|
import { isOlderThan } from '../util/timestamp';
|
||||||
|
|
||||||
const GROUPV1_ID_LENGTH = 16;
|
const GROUPV1_ID_LENGTH = 16;
|
||||||
const GROUPV2_ID_LENGTH = 32;
|
const GROUPV2_ID_LENGTH = 32;
|
||||||
|
@ -2242,6 +2243,84 @@ export default class MessageReceiver
|
||||||
return this.dispatchAndWait(logId, ev);
|
return this.dispatchAndWait(logId, ev);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async handleEditMessage(
|
||||||
|
envelope: UnsealedEnvelope,
|
||||||
|
msg: Proto.IEditMessage
|
||||||
|
): Promise<void> {
|
||||||
|
const logId = `MessageReceiver.handleEditMessage(${getEnvelopeId(
|
||||||
|
envelope
|
||||||
|
)})`;
|
||||||
|
log.info(logId);
|
||||||
|
|
||||||
|
if (!msg.targetSentTimestamp) {
|
||||||
|
log.info(`${logId}: cannot edit message. No targetSentTimestamp`);
|
||||||
|
this.removeFromCache(envelope);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!msg.dataMessage) {
|
||||||
|
log.info(`${logId}: cannot edit message. No dataMessage`);
|
||||||
|
this.removeFromCache(envelope);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Timing check
|
||||||
|
if (isOlderThan(envelope.serverTimestamp, durations.DAY)) {
|
||||||
|
log.info(
|
||||||
|
'MessageReceiver.handleEditMessage: cannot edit message older than 24h',
|
||||||
|
logId
|
||||||
|
);
|
||||||
|
this.removeFromCache(envelope);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const message = this.processDecrypted(envelope, msg.dataMessage);
|
||||||
|
const groupId = this.getProcessedGroupId(message);
|
||||||
|
const isBlocked = groupId ? this.isGroupBlocked(groupId) : false;
|
||||||
|
const { source, sourceUuid } = envelope;
|
||||||
|
const ourE164 = this.storage.user.getNumber();
|
||||||
|
const ourUuid = this.storage.user.getCheckedUuid().toString();
|
||||||
|
const isMe =
|
||||||
|
(source && ourE164 && source === ourE164) ||
|
||||||
|
(sourceUuid && ourUuid && sourceUuid === ourUuid);
|
||||||
|
const isLeavingGroup = Boolean(
|
||||||
|
!message.groupV2 &&
|
||||||
|
message.group &&
|
||||||
|
message.group.type === Proto.GroupContext.Type.QUIT
|
||||||
|
);
|
||||||
|
|
||||||
|
if (groupId && isBlocked && !(isMe && isLeavingGroup)) {
|
||||||
|
log.warn(
|
||||||
|
`Message ${getEnvelopeId(envelope)} ignored; destined for blocked group`
|
||||||
|
);
|
||||||
|
this.removeFromCache(envelope);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ev = new MessageEvent(
|
||||||
|
{
|
||||||
|
source: envelope.source,
|
||||||
|
sourceUuid: envelope.sourceUuid,
|
||||||
|
sourceDevice: envelope.sourceDevice,
|
||||||
|
destinationUuid: envelope.destinationUuid.toString(),
|
||||||
|
timestamp: envelope.timestamp,
|
||||||
|
serverGuid: envelope.serverGuid,
|
||||||
|
serverTimestamp: envelope.serverTimestamp,
|
||||||
|
unidentifiedDeliveryReceived: Boolean(
|
||||||
|
envelope.unidentifiedDeliveryReceived
|
||||||
|
),
|
||||||
|
message: {
|
||||||
|
...message,
|
||||||
|
editedMessageTimestamp: msg.targetSentTimestamp.toNumber(),
|
||||||
|
},
|
||||||
|
receivedAtCounter: envelope.receivedAtCounter,
|
||||||
|
receivedAtDate: envelope.receivedAtDate,
|
||||||
|
},
|
||||||
|
this.removeFromCache.bind(this, envelope)
|
||||||
|
);
|
||||||
|
return this.dispatchAndWait(logId, ev);
|
||||||
|
}
|
||||||
|
|
||||||
private async handleDataMessage(
|
private async handleDataMessage(
|
||||||
envelope: UnsealedEnvelope,
|
envelope: UnsealedEnvelope,
|
||||||
msg: Proto.IDataMessage
|
msg: Proto.IDataMessage
|
||||||
|
@ -2358,6 +2437,7 @@ export default class MessageReceiver
|
||||||
},
|
},
|
||||||
this.removeFromCache.bind(this, envelope)
|
this.removeFromCache.bind(this, envelope)
|
||||||
);
|
);
|
||||||
|
|
||||||
return this.dispatchAndWait(logId, ev);
|
return this.dispatchAndWait(logId, ev);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -2463,6 +2543,11 @@ export default class MessageReceiver
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (content.editMessage) {
|
||||||
|
await this.handleEditMessage(envelope, content.editMessage);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
this.removeFromCache(envelope);
|
this.removeFromCache(envelope);
|
||||||
|
|
||||||
if (Bytes.isEmpty(content.senderKeyDistributionMessage)) {
|
if (Bytes.isEmpty(content.senderKeyDistributionMessage)) {
|
||||||
|
@ -2859,6 +2944,10 @@ export default class MessageReceiver
|
||||||
if (syncMessage.sent) {
|
if (syncMessage.sent) {
|
||||||
const sentMessage = syncMessage.sent;
|
const sentMessage = syncMessage.sent;
|
||||||
|
|
||||||
|
if (sentMessage.editMessage) {
|
||||||
|
return this.handleSentEditMessage(envelope, sentMessage);
|
||||||
|
}
|
||||||
|
|
||||||
if (sentMessage.storyMessageRecipients && sentMessage.isRecipientUpdate) {
|
if (sentMessage.storyMessageRecipients && sentMessage.isRecipientUpdate) {
|
||||||
if (getStoriesBlocked()) {
|
if (getStoriesBlocked()) {
|
||||||
log.info(
|
log.info(
|
||||||
|
@ -2886,12 +2975,11 @@ export default class MessageReceiver
|
||||||
}
|
}
|
||||||
|
|
||||||
if (sentMessage.storyMessage) {
|
if (sentMessage.storyMessage) {
|
||||||
void this.handleStoryMessage(
|
return this.handleStoryMessage(
|
||||||
envelope,
|
envelope,
|
||||||
sentMessage.storyMessage,
|
sentMessage.storyMessage,
|
||||||
sentMessage
|
sentMessage
|
||||||
);
|
);
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!sentMessage || !sentMessage.message) {
|
if (!sentMessage || !sentMessage.message) {
|
||||||
|
@ -2916,6 +3004,7 @@ export default class MessageReceiver
|
||||||
'from',
|
'from',
|
||||||
getEnvelopeId(envelope)
|
getEnvelopeId(envelope)
|
||||||
);
|
);
|
||||||
|
|
||||||
return this.handleSentMessage(envelope, sentMessage);
|
return this.handleSentMessage(envelope, sentMessage);
|
||||||
}
|
}
|
||||||
if (syncMessage.contacts) {
|
if (syncMessage.contacts) {
|
||||||
|
@ -2984,6 +3073,68 @@ export default class MessageReceiver
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async handleSentEditMessage(
|
||||||
|
envelope: UnsealedEnvelope,
|
||||||
|
sentMessage: ProcessedSent
|
||||||
|
): Promise<void> {
|
||||||
|
const logId = `MessageReceiver.handleSentEditMessage(${getEnvelopeId(
|
||||||
|
envelope
|
||||||
|
)})`;
|
||||||
|
log.info(logId);
|
||||||
|
|
||||||
|
const { editMessage } = sentMessage;
|
||||||
|
|
||||||
|
if (!editMessage) {
|
||||||
|
log.warn(`${logId}: cannot edit message. No editMessage in proto`);
|
||||||
|
this.removeFromCache(envelope);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!editMessage.targetSentTimestamp) {
|
||||||
|
log.warn(`${logId}: cannot edit message. No targetSentTimestamp`);
|
||||||
|
this.removeFromCache(envelope);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!editMessage.dataMessage) {
|
||||||
|
log.warn(`${logId}: cannot edit message. No dataMessage`);
|
||||||
|
this.removeFromCache(envelope);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const {
|
||||||
|
destination,
|
||||||
|
destinationUuid,
|
||||||
|
expirationStartTimestamp,
|
||||||
|
unidentifiedStatus,
|
||||||
|
isRecipientUpdate,
|
||||||
|
} = sentMessage;
|
||||||
|
|
||||||
|
const message = this.processDecrypted(envelope, editMessage.dataMessage);
|
||||||
|
|
||||||
|
const ev = new SentEvent(
|
||||||
|
{
|
||||||
|
destination: dropNull(destination),
|
||||||
|
destinationUuid:
|
||||||
|
dropNull(destinationUuid) || envelope.destinationUuid.toString(),
|
||||||
|
timestamp: envelope.timestamp,
|
||||||
|
serverTimestamp: envelope.serverTimestamp,
|
||||||
|
device: envelope.sourceDevice,
|
||||||
|
unidentifiedStatus,
|
||||||
|
message: {
|
||||||
|
...message,
|
||||||
|
editedMessageTimestamp: editMessage.targetSentTimestamp.toNumber(),
|
||||||
|
},
|
||||||
|
isRecipientUpdate: Boolean(isRecipientUpdate),
|
||||||
|
receivedAtCounter: envelope.receivedAtCounter,
|
||||||
|
receivedAtDate: envelope.receivedAtDate,
|
||||||
|
expirationStartTimestamp: expirationStartTimestamp?.toNumber(),
|
||||||
|
},
|
||||||
|
this.removeFromCache.bind(this, envelope)
|
||||||
|
);
|
||||||
|
return this.dispatchAndWait(getEnvelopeId(envelope), ev);
|
||||||
|
}
|
||||||
|
|
||||||
private async handleConfiguration(
|
private async handleConfiguration(
|
||||||
envelope: ProcessedEnvelope,
|
envelope: ProcessedEnvelope,
|
||||||
configuration: Proto.SyncMessage.IConfiguration
|
configuration: Proto.SyncMessage.IConfiguration
|
||||||
|
|
3
ts/textsecure/Types.d.ts
vendored
3
ts/textsecure/Types.d.ts
vendored
|
@ -150,7 +150,7 @@ export type ProcessedQuote = {
|
||||||
authorUuid?: string;
|
authorUuid?: string;
|
||||||
text?: string;
|
text?: string;
|
||||||
attachments: ReadonlyArray<ProcessedQuoteAttachment>;
|
attachments: ReadonlyArray<ProcessedQuoteAttachment>;
|
||||||
bodyRanges: ReadonlyArray<Proto.DataMessage.IBodyRange>;
|
bodyRanges: ReadonlyArray<ProcessedBodyRange>;
|
||||||
type: Proto.DataMessage.Quote.Type;
|
type: Proto.DataMessage.Quote.Type;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -219,6 +219,7 @@ export type ProcessedDataMessage = {
|
||||||
preview?: ReadonlyArray<ProcessedPreview>;
|
preview?: ReadonlyArray<ProcessedPreview>;
|
||||||
sticker?: ProcessedSticker;
|
sticker?: ProcessedSticker;
|
||||||
requiredProtocolVersion?: number;
|
requiredProtocolVersion?: number;
|
||||||
|
editedMessageTimestamp?: number;
|
||||||
isStory?: boolean;
|
isStory?: boolean;
|
||||||
isViewOnce: boolean;
|
isViewOnce: boolean;
|
||||||
reaction?: ProcessedReaction;
|
reaction?: ProcessedReaction;
|
||||||
|
|
|
@ -50,7 +50,7 @@ export async function downloadAttachment(
|
||||||
const data = getFirstBytes(paddedData, size);
|
const data = getFirstBytes(paddedData, size);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...omit(attachment, 'digest', 'key'),
|
...omit(attachment, 'key'),
|
||||||
|
|
||||||
size,
|
size,
|
||||||
contentType: contentType
|
contentType: contentType
|
||||||
|
|
|
@ -27,6 +27,7 @@ import { ThemeType } from './Util';
|
||||||
import * as GoogleChrome from '../util/GoogleChrome';
|
import * as GoogleChrome from '../util/GoogleChrome';
|
||||||
import { ReadStatus } from '../messages/MessageReadStatus';
|
import { ReadStatus } from '../messages/MessageReadStatus';
|
||||||
import type { MessageStatusType } from '../components/conversation/Message';
|
import type { MessageStatusType } from '../components/conversation/Message';
|
||||||
|
import { softAssert } from '../util/assert';
|
||||||
|
|
||||||
const MAX_WIDTH = 300;
|
const MAX_WIDTH = 300;
|
||||||
const MAX_HEIGHT = MAX_WIDTH * 1.5;
|
const MAX_HEIGHT = MAX_WIDTH * 1.5;
|
||||||
|
@ -40,7 +41,9 @@ export type AttachmentType = {
|
||||||
blurHash?: string;
|
blurHash?: string;
|
||||||
caption?: string;
|
caption?: string;
|
||||||
contentType: MIME.MIMEType;
|
contentType: MIME.MIMEType;
|
||||||
|
digest?: string;
|
||||||
fileName?: string;
|
fileName?: string;
|
||||||
|
uploadTimestamp?: number;
|
||||||
/** Not included in protobuf, needs to be pulled from flags */
|
/** Not included in protobuf, needs to be pulled from flags */
|
||||||
isVoiceMessage?: boolean;
|
isVoiceMessage?: boolean;
|
||||||
/** For messages not already on disk, this will be a data url */
|
/** For messages not already on disk, this will be a data url */
|
||||||
|
@ -78,7 +81,6 @@ export type AttachmentType = {
|
||||||
schemaVersion?: number;
|
schemaVersion?: number;
|
||||||
|
|
||||||
/** Removed once we download the attachment */
|
/** Removed once we download the attachment */
|
||||||
digest?: string;
|
|
||||||
key?: string;
|
key?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -187,6 +189,7 @@ export async function migrateDataToFileSystem(
|
||||||
const { data } = attachment;
|
const { data } = attachment;
|
||||||
const attachmentHasData = !isUndefined(data);
|
const attachmentHasData = !isUndefined(data);
|
||||||
const shouldSkipSchemaUpgrade = !attachmentHasData;
|
const shouldSkipSchemaUpgrade = !attachmentHasData;
|
||||||
|
|
||||||
if (shouldSkipSchemaUpgrade) {
|
if (shouldSkipSchemaUpgrade) {
|
||||||
return attachment;
|
return attachment;
|
||||||
}
|
}
|
||||||
|
@ -1001,3 +1004,8 @@ export const canBeDownloaded = (
|
||||||
): boolean => {
|
): boolean => {
|
||||||
return Boolean(attachment.key && attachment.digest);
|
return Boolean(attachment.key && attachment.digest);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export function getAttachmentSignature(attachment: AttachmentType): string {
|
||||||
|
softAssert(attachment.digest, 'attachment missing digest');
|
||||||
|
return attachment.digest || String(attachment.blurHash);
|
||||||
|
}
|
||||||
|
|
|
@ -824,7 +824,8 @@ export const deleteAllExternalFiles = ({
|
||||||
}
|
}
|
||||||
|
|
||||||
return async (message: MessageAttributesType) => {
|
return async (message: MessageAttributesType) => {
|
||||||
const { attachments, quote, contact, preview, sticker } = message;
|
const { attachments, editHistory, quote, contact, preview, sticker } =
|
||||||
|
message;
|
||||||
|
|
||||||
if (attachments && attachments.length) {
|
if (attachments && attachments.length) {
|
||||||
await Promise.all(attachments.map(deleteAttachmentData));
|
await Promise.all(attachments.map(deleteAttachmentData));
|
||||||
|
@ -858,15 +859,7 @@ export const deleteAllExternalFiles = ({
|
||||||
}
|
}
|
||||||
|
|
||||||
if (preview && preview.length) {
|
if (preview && preview.length) {
|
||||||
await Promise.all(
|
await deletePreviews(preview, deleteOnDisk);
|
||||||
preview.map(async item => {
|
|
||||||
const { image } = item;
|
|
||||||
|
|
||||||
if (image && image.path) {
|
|
||||||
await deleteOnDisk(image.path);
|
|
||||||
}
|
|
||||||
})
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (sticker && sticker.data && sticker.data.path) {
|
if (sticker && sticker.data && sticker.data.path) {
|
||||||
|
@ -876,9 +869,42 @@ export const deleteAllExternalFiles = ({
|
||||||
await deleteOnDisk(sticker.data.thumbnail.path);
|
await deleteOnDisk(sticker.data.thumbnail.path);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (editHistory && editHistory.length) {
|
||||||
|
await editHistory.map(edit => {
|
||||||
|
if (!edit.attachments || !edit.attachments.length) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
return Promise.all(edit.attachments.map(deleteAttachmentData));
|
||||||
|
});
|
||||||
|
await editHistory.map(edit => deletePreviews(edit.preview, deleteOnDisk));
|
||||||
|
}
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
async function deletePreviews(
|
||||||
|
preview: MessageAttributesType['preview'],
|
||||||
|
deleteOnDisk: (path: string) => Promise<void>
|
||||||
|
): Promise<Array<void>> {
|
||||||
|
if (!preview) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return Promise.all(
|
||||||
|
preview.map(async item => {
|
||||||
|
const { image } = item;
|
||||||
|
|
||||||
|
if (image && image.path) {
|
||||||
|
await deleteOnDisk(image.path);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (image?.thumbnail?.path) {
|
||||||
|
await deleteOnDisk(image.thumbnail.path);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// createAttachmentDataWriter :: (RelativePath -> IO Unit)
|
// createAttachmentDataWriter :: (RelativePath -> IO Unit)
|
||||||
// Message ->
|
// Message ->
|
||||||
// IO (Promise Message)
|
// IO (Promise Message)
|
||||||
|
|
|
@ -1,7 +1,6 @@
|
||||||
// Copyright 2021 Signal Messenger, LLC
|
// Copyright 2021 Signal Messenger, LLC
|
||||||
// SPDX-License-Identifier: AGPL-3.0-only
|
// SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
|
||||||
import { omit } from 'lodash';
|
|
||||||
import { blobToArrayBuffer } from 'blob-util';
|
import { blobToArrayBuffer } from 'blob-util';
|
||||||
|
|
||||||
import { scaleImageToLevel } from './scaleImageToLevel';
|
import { scaleImageToLevel } from './scaleImageToLevel';
|
||||||
|
@ -59,8 +58,7 @@ export async function autoOrientJPEG(
|
||||||
// by potentially doubling stored image data.
|
// by potentially doubling stored image data.
|
||||||
// See: https://github.com/signalapp/Signal-Desktop/issues/1589
|
// See: https://github.com/signalapp/Signal-Desktop/issues/1589
|
||||||
const xcodedAttachment = {
|
const xcodedAttachment = {
|
||||||
// `digest` is no longer valid for auto-oriented image data, so we discard it:
|
...attachment,
|
||||||
...omit(attachment, 'digest'),
|
|
||||||
data: new Uint8Array(xcodedDataArrayBuffer),
|
data: new Uint8Array(xcodedDataArrayBuffer),
|
||||||
size: xcodedDataArrayBuffer.byteLength,
|
size: xcodedDataArrayBuffer.byteLength,
|
||||||
};
|
};
|
||||||
|
|
28
ts/util/getQuoteBodyText.ts
Normal file
28
ts/util/getQuoteBodyText.ts
Normal file
|
@ -0,0 +1,28 @@
|
||||||
|
// Copyright 2023 Signal Messenger, LLC
|
||||||
|
// SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
|
||||||
|
import type { MessageAttributesType } from '../model-types.d';
|
||||||
|
import * as EmbeddedContact from '../types/EmbeddedContact';
|
||||||
|
|
||||||
|
export function getQuoteBodyText(
|
||||||
|
messageAttributes: MessageAttributesType,
|
||||||
|
id: number
|
||||||
|
): string | undefined {
|
||||||
|
const storyReactionEmoji = messageAttributes.storyReaction?.emoji;
|
||||||
|
|
||||||
|
const { editHistory } = messageAttributes;
|
||||||
|
const editedMessage =
|
||||||
|
editHistory && editHistory.find(edit => edit.timestamp === id);
|
||||||
|
|
||||||
|
if (editedMessage && editedMessage.body) {
|
||||||
|
return editedMessage.body;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { body, contact: embeddedContact } = messageAttributes;
|
||||||
|
const embeddedContactName =
|
||||||
|
embeddedContact && embeddedContact.length > 0
|
||||||
|
? EmbeddedContact.getName(embeddedContact[0])
|
||||||
|
: '';
|
||||||
|
|
||||||
|
return body || embeddedContactName || storyReactionEmoji;
|
||||||
|
}
|
191
ts/util/handleEditMessage.ts
Normal file
191
ts/util/handleEditMessage.ts
Normal file
|
@ -0,0 +1,191 @@
|
||||||
|
// Copyright 2023 Signal Messenger, LLC
|
||||||
|
// SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
|
||||||
|
import type { AttachmentType } from '../types/Attachment';
|
||||||
|
import type { EditAttributesType } from '../messageModifiers/Edits';
|
||||||
|
import type { EditHistoryType, MessageAttributesType } 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 { getMessageIdForLogging } from './idForLogging';
|
||||||
|
import { isOutgoing } from '../messages/helpers';
|
||||||
|
import { queueAttachmentDownloads } from './queueAttachmentDownloads';
|
||||||
|
import { shouldReplyNotifyUser } from './shouldReplyNotifyUser';
|
||||||
|
|
||||||
|
export async function handleEditMessage(
|
||||||
|
mainMessage: MessageAttributesType,
|
||||||
|
editAttributes: EditAttributesType
|
||||||
|
): Promise<void> {
|
||||||
|
const idLog = `handleEditMessage(${getMessageIdForLogging(mainMessage)})`;
|
||||||
|
|
||||||
|
// Verify that we can safely apply an edit to this type of message
|
||||||
|
if (mainMessage.deletedForEveryone) {
|
||||||
|
log.warn(`${idLog}: Cannot edit a DOE message`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (mainMessage.isViewOnce) {
|
||||||
|
log.warn(`${idLog}: Cannot edit an isViewOnce message`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (mainMessage.contact && mainMessage.contact.length > 0) {
|
||||||
|
log.warn(`${idLog}: Cannot edit a contact share`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const hasVoiceMessage = mainMessage.attachments?.some(isVoiceMessage);
|
||||||
|
if (hasVoiceMessage) {
|
||||||
|
log.warn(`${idLog}: Cannot edit a voice message`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const mainMessageModel = window.MessageController.register(
|
||||||
|
mainMessage.id,
|
||||||
|
mainMessage
|
||||||
|
);
|
||||||
|
|
||||||
|
// 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 || [
|
||||||
|
{
|
||||||
|
attachments: mainMessage.attachments,
|
||||||
|
body: mainMessage.body,
|
||||||
|
bodyRanges: mainMessage.bodyRanges,
|
||||||
|
preview: mainMessage.preview,
|
||||||
|
timestamp: mainMessage.timestamp,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
// Race condition prevention check here. If we already have the timestamp
|
||||||
|
// recorded as an edit we can safely drop handling this edit.
|
||||||
|
const editedMessageExists = editHistory.some(
|
||||||
|
edit => edit.timestamp === editAttributes.message.timestamp
|
||||||
|
);
|
||||||
|
if (editedMessageExists) {
|
||||||
|
log.warn(`${idLog}: edited message is duplicate. Dropping.`);
|
||||||
|
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
|
||||||
|
);
|
||||||
|
|
||||||
|
// 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();
|
||||||
|
|
||||||
|
mainMessage.attachments?.forEach(attachment => {
|
||||||
|
if (!isDownloaded(attachment)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const signature = getAttachmentSignature(attachment);
|
||||||
|
attachmentSignatures.set(signature, attachment);
|
||||||
|
});
|
||||||
|
mainMessage.preview?.forEach(preview => {
|
||||||
|
if (!preview.image || !isDownloaded(preview.image)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const signature = getAttachmentSignature(preview.image);
|
||||||
|
previewSignatures.set(signature, preview);
|
||||||
|
});
|
||||||
|
|
||||||
|
const nextEditedMessageAttachments =
|
||||||
|
upgradedEditedMessageData.attachments?.map(attachment => {
|
||||||
|
const signature = getAttachmentSignature(attachment);
|
||||||
|
const existingAttachment = attachmentSignatures.get(signature);
|
||||||
|
|
||||||
|
return existingAttachment || attachment;
|
||||||
|
});
|
||||||
|
|
||||||
|
const nextEditedMessagePreview = upgradedEditedMessageData.preview?.map(
|
||||||
|
preview => {
|
||||||
|
if (!preview.image) {
|
||||||
|
return preview;
|
||||||
|
}
|
||||||
|
|
||||||
|
const signature = getAttachmentSignature(preview.image);
|
||||||
|
const existingPreview = previewSignatures.get(signature);
|
||||||
|
return existingPreview || preview;
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
const editedMessage: EditHistoryType = {
|
||||||
|
attachments: nextEditedMessageAttachments,
|
||||||
|
body: upgradedEditedMessageData.body,
|
||||||
|
bodyRanges: upgradedEditedMessageData.bodyRanges,
|
||||||
|
preview: nextEditedMessagePreview,
|
||||||
|
timestamp: upgradedEditedMessageData.timestamp,
|
||||||
|
};
|
||||||
|
|
||||||
|
// 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);
|
||||||
|
|
||||||
|
// Update all the editable attributes on the main message also updating the
|
||||||
|
// edit history.
|
||||||
|
mainMessageModel.set({
|
||||||
|
attachments: editedMessage.attachments,
|
||||||
|
body: editedMessage.body,
|
||||||
|
bodyRanges: editedMessage.bodyRanges,
|
||||||
|
editHistory,
|
||||||
|
editMessageTimestamp: upgradedEditedMessageData.timestamp,
|
||||||
|
preview: editedMessage.preview,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Queue up any downloads in case they're different, update the fields if so.
|
||||||
|
const updatedFields = await queueAttachmentDownloads(
|
||||||
|
mainMessageModel.attributes
|
||||||
|
);
|
||||||
|
if (updatedFields) {
|
||||||
|
mainMessageModel.set(updatedFields);
|
||||||
|
}
|
||||||
|
|
||||||
|
// For incoming edits, we mark the message as unread so that we're able to
|
||||||
|
// send a read receipt for the message. In case we had already sent one for
|
||||||
|
// the original message.
|
||||||
|
const readStatus = isOutgoing(mainMessageModel.attributes)
|
||||||
|
? ReadStatus.Read
|
||||||
|
: ReadStatus.Unread;
|
||||||
|
|
||||||
|
// Save both the main message and the edited message for fast lookups
|
||||||
|
drop(
|
||||||
|
dataInterface.saveEditedMessage(
|
||||||
|
mainMessageModel.attributes,
|
||||||
|
window.textsecure.storage.user.getCheckedUuid().toString(),
|
||||||
|
{
|
||||||
|
fromId: editAttributes.fromId,
|
||||||
|
messageId: mainMessage.id,
|
||||||
|
readStatus,
|
||||||
|
sentAt: upgradedEditedMessageData.timestamp,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
drop(mainMessageModel.getConversation()?.updateLastMessage());
|
||||||
|
|
||||||
|
// Update notifications
|
||||||
|
const conversation = mainMessageModel.getConversation();
|
||||||
|
if (!conversation) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (await shouldReplyNotifyUser(mainMessageModel, conversation)) {
|
||||||
|
await conversation.notify(mainMessageModel);
|
||||||
|
}
|
||||||
|
}
|
|
@ -21,31 +21,12 @@ export function hasAttachmentDownloads(
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
const hasNormalAttachments = normalAttachments.some(attachment => {
|
const hasNormalAttachments = hasNormalAttachmentDownloads(normalAttachments);
|
||||||
if (!attachment) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
// We've already downloaded this!
|
|
||||||
if (attachment.path) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
return true;
|
|
||||||
});
|
|
||||||
if (hasNormalAttachments) {
|
if (hasNormalAttachments) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
const previews = message.preview || [];
|
const hasPreviews = hasPreviewDownloads(message.preview);
|
||||||
const hasPreviews = previews.some(item => {
|
|
||||||
if (!item.image) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
// We've already downloaded this!
|
|
||||||
if (item.image.path) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
return true;
|
|
||||||
});
|
|
||||||
if (hasPreviews) {
|
if (hasPreviews) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
@ -85,5 +66,48 @@ export function hasAttachmentDownloads(
|
||||||
return !sticker.data || (sticker.data && !sticker.data.path);
|
return !sticker.data || (sticker.data && !sticker.data.path);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const { editHistory } = message;
|
||||||
|
if (editHistory) {
|
||||||
|
const hasAttachmentsWithinEditHistory = editHistory.some(
|
||||||
|
edit =>
|
||||||
|
hasNormalAttachmentDownloads(edit.attachments) ||
|
||||||
|
hasPreviewDownloads(edit.preview)
|
||||||
|
);
|
||||||
|
|
||||||
|
if (hasAttachmentsWithinEditHistory) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function hasPreviewDownloads(
|
||||||
|
previews: MessageAttributesType['preview']
|
||||||
|
): boolean {
|
||||||
|
return (previews || []).some(item => {
|
||||||
|
if (!item.image) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
// We've already downloaded this!
|
||||||
|
if (item.image.path) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasNormalAttachmentDownloads(
|
||||||
|
attachments: MessageAttributesType['attachments']
|
||||||
|
): boolean {
|
||||||
|
return (attachments || []).some(attachment => {
|
||||||
|
if (!attachment) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
// We've already downloaded this!
|
||||||
|
if (attachment.path) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
|
@ -2011,6 +2011,13 @@
|
||||||
"reasonCategory": "usageTrusted",
|
"reasonCategory": "usageTrusted",
|
||||||
"updated": "2021-07-30T16:57:33.618Z"
|
"updated": "2021-07-30T16:57:33.618Z"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"rule": "React-useRef",
|
||||||
|
"path": "ts/components/EditHistoryMessagesModal.tsx",
|
||||||
|
"line": " const containerElementRef = useRef<HTMLDivElement | null>(null);",
|
||||||
|
"reasonCategory": "usageTrusted",
|
||||||
|
"updated": "2023-03-25T01:59:04.590Z"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"rule": "React-useRef",
|
"rule": "React-useRef",
|
||||||
"path": "ts/components/ForwardMessagesModal.tsx",
|
"path": "ts/components/ForwardMessagesModal.tsx",
|
||||||
|
@ -2399,6 +2406,13 @@
|
||||||
"reasonCategory": "usageTrusted",
|
"reasonCategory": "usageTrusted",
|
||||||
"updated": "2022-11-03T14:21:47.456Z"
|
"updated": "2022-11-03T14:21:47.456Z"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"rule": "React-useRef",
|
||||||
|
"path": "ts/components/conversation/WaveformScrubber.tsx",
|
||||||
|
"line": " const waveformRef = useRef<HTMLDivElement | null>(null);",
|
||||||
|
"reasonCategory": "usageTrusted",
|
||||||
|
"updated": "2023-02-26T23:20:28.848Z"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"rule": "React-useRef",
|
"rule": "React-useRef",
|
||||||
"path": "ts/components/conversation/conversation-details/AddGroupMembersModal/ChooseGroupMembersModal.tsx",
|
"path": "ts/components/conversation/conversation-details/AddGroupMembersModal/ChooseGroupMembersModal.tsx",
|
||||||
|
@ -2435,13 +2449,6 @@
|
||||||
"updated": "2019-11-01T22:46:33.013Z",
|
"updated": "2019-11-01T22:46:33.013Z",
|
||||||
"reasonDetail": "Used for setting focus only"
|
"reasonDetail": "Used for setting focus only"
|
||||||
},
|
},
|
||||||
{
|
|
||||||
"rule": "React-useRef",
|
|
||||||
"path": "ts/components/conversation/WaveformScrubber.tsx",
|
|
||||||
"line": " const waveformRef = useRef<HTMLDivElement | null>(null);",
|
|
||||||
"reasonCategory": "usageTrusted",
|
|
||||||
"updated": "2023-02-26T23:20:28.848Z"
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
"rule": "React-useRef",
|
"rule": "React-useRef",
|
||||||
"path": "ts/components/emoji/EmojiButton.tsx",
|
"path": "ts/components/emoji/EmojiButton.tsx",
|
||||||
|
|
149
ts/util/makeQuote.ts
Normal file
149
ts/util/makeQuote.ts
Normal file
|
@ -0,0 +1,149 @@
|
||||||
|
// Copyright 2023 Signal Messenger, LLC
|
||||||
|
// SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
|
||||||
|
import type { AttachmentType, ThumbnailType } from '../types/Attachment';
|
||||||
|
import type {
|
||||||
|
MessageAttributesType,
|
||||||
|
QuotedMessageType,
|
||||||
|
} from '../model-types.d';
|
||||||
|
import type { MIMEType } from '../types/MIME';
|
||||||
|
import type { LinkPreviewType } from '../types/message/LinkPreviews';
|
||||||
|
import type { StickerType } from '../types/Stickers';
|
||||||
|
import { IMAGE_JPEG, IMAGE_GIF } from '../types/MIME';
|
||||||
|
import { getContact } from '../messages/helpers';
|
||||||
|
import { getQuoteBodyText } from './getQuoteBodyText';
|
||||||
|
import { isGIF } from '../types/Attachment';
|
||||||
|
import { isGiftBadge, isTapToView } from '../state/selectors/message';
|
||||||
|
import { map, take, collect } from './iterables';
|
||||||
|
import { strictAssert } from './assert';
|
||||||
|
|
||||||
|
export async function makeQuote(
|
||||||
|
quotedMessage: MessageAttributesType
|
||||||
|
): Promise<QuotedMessageType> {
|
||||||
|
const contact = getContact(quotedMessage);
|
||||||
|
|
||||||
|
strictAssert(contact, 'makeQuote: no contact');
|
||||||
|
|
||||||
|
const {
|
||||||
|
attachments,
|
||||||
|
bodyRanges,
|
||||||
|
editMessageTimestamp,
|
||||||
|
id: messageId,
|
||||||
|
payment,
|
||||||
|
preview,
|
||||||
|
sticker,
|
||||||
|
} = quotedMessage;
|
||||||
|
|
||||||
|
const quoteId = editMessageTimestamp || quotedMessage.sent_at;
|
||||||
|
|
||||||
|
return {
|
||||||
|
authorUuid: contact.get('uuid'),
|
||||||
|
attachments: isTapToView(quotedMessage)
|
||||||
|
? [{ contentType: IMAGE_JPEG, fileName: null }]
|
||||||
|
: await getQuoteAttachment(attachments, preview, sticker),
|
||||||
|
payment,
|
||||||
|
bodyRanges,
|
||||||
|
id: quoteId,
|
||||||
|
isViewOnce: isTapToView(quotedMessage),
|
||||||
|
isGiftBadge: isGiftBadge(quotedMessage),
|
||||||
|
messageId,
|
||||||
|
referencedMessageNotFound: false,
|
||||||
|
text: getQuoteBodyText(quotedMessage, quoteId),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getQuoteAttachment(
|
||||||
|
attachments?: Array<AttachmentType>,
|
||||||
|
preview?: Array<LinkPreviewType>,
|
||||||
|
sticker?: StickerType
|
||||||
|
): Promise<
|
||||||
|
Array<{
|
||||||
|
contentType: MIMEType;
|
||||||
|
fileName: string | null;
|
||||||
|
thumbnail: ThumbnailType | null;
|
||||||
|
}>
|
||||||
|
> {
|
||||||
|
const { getAbsoluteAttachmentPath, loadAttachmentData } =
|
||||||
|
window.Signal.Migrations;
|
||||||
|
|
||||||
|
if (attachments && attachments.length) {
|
||||||
|
const attachmentsToUse = Array.from(take(attachments, 1));
|
||||||
|
const isGIFQuote = isGIF(attachmentsToUse);
|
||||||
|
|
||||||
|
return Promise.all(
|
||||||
|
map(attachmentsToUse, async attachment => {
|
||||||
|
const { path, fileName, thumbnail, contentType } = attachment;
|
||||||
|
|
||||||
|
if (!path) {
|
||||||
|
return {
|
||||||
|
contentType: isGIFQuote ? IMAGE_GIF : contentType,
|
||||||
|
// Our protos library complains about this field being undefined, so we
|
||||||
|
// force it to null
|
||||||
|
fileName: fileName || null,
|
||||||
|
thumbnail: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
contentType: isGIFQuote ? IMAGE_GIF : contentType,
|
||||||
|
// Our protos library complains about this field being undefined, so we force
|
||||||
|
// it to null
|
||||||
|
fileName: fileName || null,
|
||||||
|
thumbnail: thumbnail
|
||||||
|
? {
|
||||||
|
...(await loadAttachmentData(thumbnail)),
|
||||||
|
objectUrl: thumbnail.path
|
||||||
|
? getAbsoluteAttachmentPath(thumbnail.path)
|
||||||
|
: undefined,
|
||||||
|
}
|
||||||
|
: null,
|
||||||
|
};
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (preview && preview.length) {
|
||||||
|
const previewImages = collect(preview, prev => prev.image);
|
||||||
|
const previewImagesToUse = take(previewImages, 1);
|
||||||
|
|
||||||
|
return Promise.all(
|
||||||
|
map(previewImagesToUse, async image => {
|
||||||
|
const { contentType } = image;
|
||||||
|
|
||||||
|
return {
|
||||||
|
contentType,
|
||||||
|
// Our protos library complains about this field being undefined, so we
|
||||||
|
// force it to null
|
||||||
|
fileName: null,
|
||||||
|
thumbnail: image
|
||||||
|
? {
|
||||||
|
...(await loadAttachmentData(image)),
|
||||||
|
objectUrl: image.path
|
||||||
|
? getAbsoluteAttachmentPath(image.path)
|
||||||
|
: undefined,
|
||||||
|
}
|
||||||
|
: null,
|
||||||
|
};
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (sticker && sticker.data && sticker.data.path) {
|
||||||
|
const { path, contentType } = sticker.data;
|
||||||
|
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
contentType,
|
||||||
|
// Our protos library complains about this field being undefined, so we
|
||||||
|
// force it to null
|
||||||
|
fileName: null,
|
||||||
|
thumbnail: {
|
||||||
|
...(await loadAttachmentData(sticker.data)),
|
||||||
|
objectUrl: path ? getAbsoluteAttachmentPath(path) : undefined,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
return [];
|
||||||
|
}
|
|
@ -34,13 +34,18 @@ export async function markConversationRead(
|
||||||
): Promise<boolean> {
|
): Promise<boolean> {
|
||||||
const { id: conversationId } = conversationAttrs;
|
const { id: conversationId } = conversationAttrs;
|
||||||
|
|
||||||
const [unreadMessages, unreadReactions] = await Promise.all([
|
const [unreadMessages, unreadEditedMessages, unreadReactions] =
|
||||||
|
await Promise.all([
|
||||||
window.Signal.Data.getUnreadByConversationAndMarkRead({
|
window.Signal.Data.getUnreadByConversationAndMarkRead({
|
||||||
conversationId,
|
conversationId,
|
||||||
newestUnreadAt,
|
newestUnreadAt,
|
||||||
readAt: options.readAt,
|
readAt: options.readAt,
|
||||||
includeStoryReplies: !isGroup(conversationAttrs),
|
includeStoryReplies: !isGroup(conversationAttrs),
|
||||||
}),
|
}),
|
||||||
|
window.Signal.Data.getUnreadEditedMessagesAndMarkRead({
|
||||||
|
fromId: conversationId,
|
||||||
|
newestUnreadAt,
|
||||||
|
}),
|
||||||
window.Signal.Data.getUnreadReactionsAndMarkRead({
|
window.Signal.Data.getUnreadReactionsAndMarkRead({
|
||||||
conversationId,
|
conversationId,
|
||||||
newestUnreadAt,
|
newestUnreadAt,
|
||||||
|
@ -55,7 +60,11 @@ export async function markConversationRead(
|
||||||
unreadReactions: unreadReactions.length,
|
unreadReactions: unreadReactions.length,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!unreadMessages.length && !unreadReactions.length) {
|
if (
|
||||||
|
!unreadMessages.length &&
|
||||||
|
!unreadEditedMessages.length &&
|
||||||
|
!unreadReactions.length
|
||||||
|
) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -83,7 +92,9 @@ export async function markConversationRead(
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
const allReadMessagesSync = unreadMessages.map(messageSyncData => {
|
const allUnreadMessages = [...unreadMessages, ...unreadEditedMessages];
|
||||||
|
|
||||||
|
const allReadMessagesSync = allUnreadMessages.map(messageSyncData => {
|
||||||
const message = window.MessageController.getById(messageSyncData.id);
|
const message = window.MessageController.getById(messageSyncData.id);
|
||||||
// we update the in-memory MessageModel with the fresh database call data
|
// we update the in-memory MessageModel with the fresh database call data
|
||||||
if (message) {
|
if (message) {
|
||||||
|
|
|
@ -16,16 +16,23 @@ import dataInterface from '../sql/Client';
|
||||||
import type { AttachmentType } from '../types/Attachment';
|
import type { AttachmentType } from '../types/Attachment';
|
||||||
import type { EmbeddedContactType } from '../types/EmbeddedContact';
|
import type { EmbeddedContactType } from '../types/EmbeddedContact';
|
||||||
import type {
|
import type {
|
||||||
|
EditHistoryType,
|
||||||
MessageAttributesType,
|
MessageAttributesType,
|
||||||
QuotedMessageType,
|
QuotedMessageType,
|
||||||
} from '../model-types.d';
|
} from '../model-types.d';
|
||||||
import * as Errors from '../types/errors';
|
import * as Errors from '../types/errors';
|
||||||
|
import {
|
||||||
|
getAttachmentSignature,
|
||||||
|
isDownloading,
|
||||||
|
isDownloaded,
|
||||||
|
} from '../types/Attachment';
|
||||||
import type { StickerType } from '../types/Stickers';
|
import type { StickerType } from '../types/Stickers';
|
||||||
import type { LinkPreviewType } from '../types/message/LinkPreviews';
|
import type { LinkPreviewType } from '../types/message/LinkPreviews';
|
||||||
|
|
||||||
type ReturnType = {
|
type ReturnType = {
|
||||||
bodyAttachment?: AttachmentType;
|
bodyAttachment?: AttachmentType;
|
||||||
attachments: Array<AttachmentType>;
|
attachments: Array<AttachmentType>;
|
||||||
|
editHistory?: Array<EditHistoryType>;
|
||||||
preview: Array<LinkPreviewType>;
|
preview: Array<LinkPreviewType>;
|
||||||
contact: Array<EmbeddedContactType>;
|
contact: Array<EmbeddedContactType>;
|
||||||
quote?: QuotedMessageType;
|
quote?: QuotedMessageType;
|
||||||
|
@ -45,8 +52,10 @@ export async function queueAttachmentDownloads(
|
||||||
let count = 0;
|
let count = 0;
|
||||||
let bodyAttachment;
|
let bodyAttachment;
|
||||||
|
|
||||||
|
const idLog = `queueAttachmentDownloads(${idForLogging}})`;
|
||||||
|
|
||||||
log.info(
|
log.info(
|
||||||
`Queueing ${attachmentsToQueue.length} attachment downloads for message ${idForLogging}`
|
`${idLog}: Queueing ${attachmentsToQueue.length} attachment downloads`
|
||||||
);
|
);
|
||||||
|
|
||||||
const [longMessageAttachments, normalAttachments] = partition(
|
const [longMessageAttachments, normalAttachments] = partition(
|
||||||
|
@ -55,13 +64,11 @@ export async function queueAttachmentDownloads(
|
||||||
);
|
);
|
||||||
|
|
||||||
if (longMessageAttachments.length > 1) {
|
if (longMessageAttachments.length > 1) {
|
||||||
log.error(
|
log.error(`${idLog}: Received more than one long message attachment`);
|
||||||
`Received more than one long message attachment in message ${idForLogging}`
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
log.info(
|
log.info(
|
||||||
`Queueing ${longMessageAttachments.length} long message attachment downloads for message ${idForLogging}`
|
`${idLog}: Queueing ${longMessageAttachments.length} long message attachment downloads`
|
||||||
);
|
);
|
||||||
|
|
||||||
if (longMessageAttachments.length > 0) {
|
if (longMessageAttachments.length > 0) {
|
||||||
|
@ -82,63 +89,31 @@ export async function queueAttachmentDownloads(
|
||||||
}
|
}
|
||||||
|
|
||||||
log.info(
|
log.info(
|
||||||
`Queueing ${normalAttachments.length} normal attachment downloads for message ${idForLogging}`
|
`${idLog}: Queueing ${normalAttachments.length} normal attachment downloads`
|
||||||
);
|
);
|
||||||
const attachments = await Promise.all(
|
const { attachments, count: attachmentsCount } = await queueNormalAttachments(
|
||||||
normalAttachments.map((attachment, index) => {
|
idLog,
|
||||||
if (!attachment) {
|
|
||||||
return attachment;
|
|
||||||
}
|
|
||||||
// We've already downloaded this!
|
|
||||||
if (attachment.path || attachment.textAttachment) {
|
|
||||||
log.info(
|
|
||||||
`Normal attachment already downloaded for message ${idForLogging}`
|
|
||||||
);
|
|
||||||
return attachment;
|
|
||||||
}
|
|
||||||
|
|
||||||
count += 1;
|
|
||||||
|
|
||||||
return AttachmentDownloads.addJob(attachment, {
|
|
||||||
messageId,
|
messageId,
|
||||||
type: 'attachment',
|
normalAttachments,
|
||||||
index,
|
message.editHistory?.flatMap(x => x.attachments ?? [])
|
||||||
});
|
|
||||||
})
|
|
||||||
);
|
);
|
||||||
|
count += attachmentsCount;
|
||||||
|
|
||||||
const previewsToQueue = message.preview || [];
|
const previewsToQueue = message.preview || [];
|
||||||
log.info(
|
log.info(
|
||||||
`Queueing ${previewsToQueue.length} preview attachment downloads for message ${idForLogging}`
|
`${idLog}: Queueing ${previewsToQueue.length} preview attachment downloads`
|
||||||
);
|
);
|
||||||
const preview = await Promise.all(
|
const { preview, count: previewCount } = await queuePreviews(
|
||||||
previewsToQueue.map(async (item, index) => {
|
idLog,
|
||||||
if (!item.image) {
|
|
||||||
return item;
|
|
||||||
}
|
|
||||||
// We've already downloaded this!
|
|
||||||
if (item.image.path) {
|
|
||||||
log.info(
|
|
||||||
`Preview attachment already downloaded for message ${idForLogging}`
|
|
||||||
);
|
|
||||||
return item;
|
|
||||||
}
|
|
||||||
|
|
||||||
count += 1;
|
|
||||||
return {
|
|
||||||
...item,
|
|
||||||
image: await AttachmentDownloads.addJob(item.image, {
|
|
||||||
messageId,
|
messageId,
|
||||||
type: 'preview',
|
previewsToQueue,
|
||||||
index,
|
message.editHistory?.flatMap(x => x.preview ?? [])
|
||||||
}),
|
|
||||||
};
|
|
||||||
})
|
|
||||||
);
|
);
|
||||||
|
count += previewCount;
|
||||||
|
|
||||||
const contactsToQueue = message.contact || [];
|
const contactsToQueue = message.contact || [];
|
||||||
log.info(
|
log.info(
|
||||||
`Queueing ${contactsToQueue.length} contact attachment downloads for message ${idForLogging}`
|
`${idLog}: Queueing ${contactsToQueue.length} contact attachment downloads`
|
||||||
);
|
);
|
||||||
const contact = await Promise.all(
|
const contact = await Promise.all(
|
||||||
contactsToQueue.map(async (item, index) => {
|
contactsToQueue.map(async (item, index) => {
|
||||||
|
@ -147,9 +122,7 @@ export async function queueAttachmentDownloads(
|
||||||
}
|
}
|
||||||
// We've already downloaded this!
|
// We've already downloaded this!
|
||||||
if (item.avatar.avatar.path) {
|
if (item.avatar.avatar.path) {
|
||||||
log.info(
|
log.info(`${idLog}: Contact attachment already downloaded`);
|
||||||
`Contact attachment already downloaded for message ${idForLogging}`
|
|
||||||
);
|
|
||||||
return item;
|
return item;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -172,7 +145,7 @@ export async function queueAttachmentDownloads(
|
||||||
const quoteAttachmentsToQueue =
|
const quoteAttachmentsToQueue =
|
||||||
quote && quote.attachments ? quote.attachments : [];
|
quote && quote.attachments ? quote.attachments : [];
|
||||||
log.info(
|
log.info(
|
||||||
`Queueing ${quoteAttachmentsToQueue.length} quote attachment downloads for message ${idForLogging}`
|
`${idLog}: Queueing ${quoteAttachmentsToQueue.length} quote attachment downloads`
|
||||||
);
|
);
|
||||||
if (quote && quoteAttachmentsToQueue.length > 0) {
|
if (quote && quoteAttachmentsToQueue.length > 0) {
|
||||||
quote = {
|
quote = {
|
||||||
|
@ -184,9 +157,7 @@ export async function queueAttachmentDownloads(
|
||||||
}
|
}
|
||||||
// We've already downloaded this!
|
// We've already downloaded this!
|
||||||
if (item.thumbnail.path) {
|
if (item.thumbnail.path) {
|
||||||
log.info(
|
log.info(`${idLog}: Quote attachment already downloaded`);
|
||||||
`Quote attachment already downloaded for message ${idForLogging}`
|
|
||||||
);
|
|
||||||
return item;
|
return item;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -206,11 +177,9 @@ export async function queueAttachmentDownloads(
|
||||||
|
|
||||||
let { sticker } = message;
|
let { sticker } = message;
|
||||||
if (sticker && sticker.data && sticker.data.path) {
|
if (sticker && sticker.data && sticker.data.path) {
|
||||||
log.info(
|
log.info(`${idLog}: Sticker attachment already downloaded`);
|
||||||
`Sticker attachment already downloaded for message ${idForLogging}`
|
|
||||||
);
|
|
||||||
} else if (sticker) {
|
} else if (sticker) {
|
||||||
log.info(`Queueing sticker download for message ${idForLogging}`);
|
log.info(`${idLog}: Queueing sticker download`);
|
||||||
count += 1;
|
count += 1;
|
||||||
const { packId, stickerId, packKey } = sticker;
|
const { packId, stickerId, packKey } = sticker;
|
||||||
|
|
||||||
|
@ -222,7 +191,7 @@ export async function queueAttachmentDownloads(
|
||||||
data = await copyStickerToAttachments(packId, stickerId);
|
data = await copyStickerToAttachments(packId, stickerId);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
log.error(
|
log.error(
|
||||||
`Problem copying sticker (${packId}, ${stickerId}) to attachments:`,
|
`${idLog}: Problem copying sticker (${packId}, ${stickerId}) to attachments:`,
|
||||||
Errors.toLogFormat(error)
|
Errors.toLogFormat(error)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@ -252,20 +221,197 @@ export async function queueAttachmentDownloads(
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let { editHistory } = message;
|
||||||
|
if (editHistory) {
|
||||||
|
log.info(`${idLog}: Looping through ${editHistory.length} edits`);
|
||||||
|
editHistory = await Promise.all(
|
||||||
|
editHistory.map(async edit => {
|
||||||
|
const editAttachmentsToQueue = edit.attachments || [];
|
||||||
log.info(
|
log.info(
|
||||||
`Queued ${count} total attachment downloads for message ${idForLogging}`
|
`${idLog}: Queueing ${editAttachmentsToQueue.length} normal attachment downloads (edited:${edit.timestamp})`
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const { attachments: editAttachments, count: editAttachmentsCount } =
|
||||||
|
await queueNormalAttachments(
|
||||||
|
idLog,
|
||||||
|
messageId,
|
||||||
|
edit.attachments,
|
||||||
|
attachments
|
||||||
|
);
|
||||||
|
count += editAttachmentsCount;
|
||||||
|
|
||||||
|
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;
|
||||||
|
|
||||||
|
return {
|
||||||
|
...edit,
|
||||||
|
attachments: editAttachments,
|
||||||
|
preview: editPreview,
|
||||||
|
};
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
log.info(`${idLog}: Queued ${count} total attachment downloads`);
|
||||||
|
|
||||||
if (count <= 0) {
|
if (count <= 0) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
bodyAttachment,
|
|
||||||
attachments,
|
attachments,
|
||||||
preview,
|
bodyAttachment,
|
||||||
contact,
|
contact,
|
||||||
|
editHistory,
|
||||||
|
preview,
|
||||||
quote,
|
quote,
|
||||||
sticker,
|
sticker,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function queueNormalAttachments(
|
||||||
|
idLog: string,
|
||||||
|
messageId: string,
|
||||||
|
attachments: MessageAttributesType['attachments'] = [],
|
||||||
|
otherAttachments: MessageAttributesType['attachments']
|
||||||
|
): Promise<{
|
||||||
|
attachments: Array<AttachmentType>;
|
||||||
|
count: number;
|
||||||
|
}> {
|
||||||
|
// Look through "otherAttachments" which can either be attachments in the
|
||||||
|
// edit history or the message's attachments and see if any of the attachments
|
||||||
|
// are the same. If they are let's replace it so that we don't download more
|
||||||
|
// than once.
|
||||||
|
// We don't also register the signatures for "attachments" because they would
|
||||||
|
// then not be added to the AttachmentDownloads job.
|
||||||
|
const attachmentSignatures: Map<string, AttachmentType> = new Map();
|
||||||
|
otherAttachments?.forEach(attachment => {
|
||||||
|
const signature = getAttachmentSignature(attachment);
|
||||||
|
attachmentSignatures.set(signature, attachment);
|
||||||
|
});
|
||||||
|
|
||||||
|
let count = 0;
|
||||||
|
const nextAttachments = await Promise.all(
|
||||||
|
attachments.map((attachment, index) => {
|
||||||
|
if (!attachment) {
|
||||||
|
return attachment;
|
||||||
|
}
|
||||||
|
// We've already downloaded this!
|
||||||
|
if (isDownloaded(attachment)) {
|
||||||
|
log.info(`${idLog}: Normal attachment already downloaded`);
|
||||||
|
return attachment;
|
||||||
|
}
|
||||||
|
|
||||||
|
const signature = getAttachmentSignature(attachment);
|
||||||
|
const existingAttachment = signature
|
||||||
|
? attachmentSignatures.get(signature)
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
// We've already downloaded this elsewhere!
|
||||||
|
if (
|
||||||
|
existingAttachment &&
|
||||||
|
(isDownloading(existingAttachment) || isDownloaded(existingAttachment))
|
||||||
|
) {
|
||||||
|
log.info(
|
||||||
|
`${idLog}: Normal attachment already downloaded in other attachments. Replacing`
|
||||||
|
);
|
||||||
|
// Incrementing count so that we update the message's fields downstream
|
||||||
|
count += 1;
|
||||||
|
return existingAttachment;
|
||||||
|
}
|
||||||
|
|
||||||
|
count += 1;
|
||||||
|
|
||||||
|
return AttachmentDownloads.addJob(attachment, {
|
||||||
|
messageId,
|
||||||
|
type: 'attachment',
|
||||||
|
index,
|
||||||
|
});
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
attachments: nextAttachments,
|
||||||
|
count,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function getLinkPreviewSignature(preview: LinkPreviewType): string | undefined {
|
||||||
|
const { image, url } = preview;
|
||||||
|
|
||||||
|
if (!image) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
return `<${url}>${getAttachmentSignature(image)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function queuePreviews(
|
||||||
|
idLog: string,
|
||||||
|
messageId: string,
|
||||||
|
previews: MessageAttributesType['preview'] = [],
|
||||||
|
otherPreviews: MessageAttributesType['preview']
|
||||||
|
): Promise<{ preview: Array<LinkPreviewType>; count: number }> {
|
||||||
|
// Similar to queueNormalAttachments' logic for detecting same attachments
|
||||||
|
// except here we also pick by link preview URL.
|
||||||
|
const previewSignatures: Map<string, LinkPreviewType> = new Map();
|
||||||
|
otherPreviews?.forEach(preview => {
|
||||||
|
const signature = getLinkPreviewSignature(preview);
|
||||||
|
if (!signature) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
previewSignatures.set(signature, preview);
|
||||||
|
});
|
||||||
|
|
||||||
|
let count = 0;
|
||||||
|
|
||||||
|
const preview = await Promise.all(
|
||||||
|
previews.map(async (item, index) => {
|
||||||
|
if (!item.image) {
|
||||||
|
return item;
|
||||||
|
}
|
||||||
|
// We've already downloaded this!
|
||||||
|
if (isDownloaded(item.image)) {
|
||||||
|
log.info(`${idLog}: Preview attachment already downloaded`);
|
||||||
|
return item;
|
||||||
|
}
|
||||||
|
const signature = getLinkPreviewSignature(item);
|
||||||
|
const existingPreview = signature
|
||||||
|
? previewSignatures.get(signature)
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
// We've already downloaded this elsewhere!
|
||||||
|
if (
|
||||||
|
existingPreview &&
|
||||||
|
(isDownloading(existingPreview.image) ||
|
||||||
|
isDownloaded(existingPreview.image))
|
||||||
|
) {
|
||||||
|
log.info(`${idLog}: Preview already downloaded elsewhere. Replacing`);
|
||||||
|
// Incrementing count so that we update the message's fields downstream
|
||||||
|
count += 1;
|
||||||
|
return existingPreview;
|
||||||
|
}
|
||||||
|
|
||||||
|
count += 1;
|
||||||
|
return {
|
||||||
|
...item,
|
||||||
|
image: await AttachmentDownloads.addJob(item.image, {
|
||||||
|
messageId,
|
||||||
|
type: 'preview',
|
||||||
|
index,
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
preview,
|
||||||
|
count,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
82
ts/util/shouldReplyNotifyUser.ts
Normal file
82
ts/util/shouldReplyNotifyUser.ts
Normal file
|
@ -0,0 +1,82 @@
|
||||||
|
// Copyright 2023 Signal Messenger, LLC
|
||||||
|
// SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
|
||||||
|
import type { ConversationModel } from '../models/conversations';
|
||||||
|
import type { UUID } from '../types/UUID';
|
||||||
|
import type { MessageModel } from '../models/messages';
|
||||||
|
import * as log from '../logging/log';
|
||||||
|
import dataInterface from '../sql/Client';
|
||||||
|
import { isGroup } from './whatTypeOfConversation';
|
||||||
|
import { isMessageUnread } from './isMessageUnread';
|
||||||
|
|
||||||
|
function isSameUuid(
|
||||||
|
a: UUID | string | null | undefined,
|
||||||
|
b: UUID | string | null | undefined
|
||||||
|
): boolean {
|
||||||
|
return a != null && b != null && String(a) === String(b);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function shouldReplyNotifyUser(
|
||||||
|
message: MessageModel,
|
||||||
|
conversation: ConversationModel
|
||||||
|
): Promise<boolean> {
|
||||||
|
// Don't notify if the message has already been read
|
||||||
|
if (!isMessageUnread(message.attributes)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const storyId = message.get('storyId');
|
||||||
|
|
||||||
|
// If this is not a reply to a story, always notify.
|
||||||
|
if (storyId == null) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Always notify if this is not a group
|
||||||
|
if (!isGroup(conversation.attributes)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const matchedStory = window.reduxStore
|
||||||
|
.getState()
|
||||||
|
.stories.stories.find(story => {
|
||||||
|
return story.messageId === storyId;
|
||||||
|
});
|
||||||
|
|
||||||
|
// If we can't find the story, don't notify
|
||||||
|
if (matchedStory == null) {
|
||||||
|
log.warn("Couldn't find story for reply");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const currentUserId = window.textsecure.storage.user.getUuid();
|
||||||
|
const storySourceId = matchedStory.sourceUuid;
|
||||||
|
|
||||||
|
const currentUserIdSource = isSameUuid(storySourceId, currentUserId);
|
||||||
|
|
||||||
|
// If the story is from the current user, always notify
|
||||||
|
if (currentUserIdSource) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// If the story is from a different user, only notify if the user has
|
||||||
|
// replied or reacted to the story
|
||||||
|
|
||||||
|
const replies = await dataInterface.getOlderMessagesByConversation({
|
||||||
|
conversationId: conversation.id,
|
||||||
|
limit: 9000,
|
||||||
|
storyId,
|
||||||
|
includeStoryReplies: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
const prevCurrentUserReply = replies.find(replyMessage => {
|
||||||
|
return replyMessage.type === 'outgoing';
|
||||||
|
});
|
||||||
|
|
||||||
|
if (prevCurrentUserReply != null) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Otherwise don't notify
|
||||||
|
return false;
|
||||||
|
}
|
|
@ -2252,10 +2252,10 @@
|
||||||
node-gyp-build "^4.2.3"
|
node-gyp-build "^4.2.3"
|
||||||
uuid "^8.3.0"
|
uuid "^8.3.0"
|
||||||
|
|
||||||
"@signalapp/mock-server@2.15.0":
|
"@signalapp/mock-server@2.17.0":
|
||||||
version "2.15.0"
|
version "2.17.0"
|
||||||
resolved "https://registry.yarnpkg.com/@signalapp/mock-server/-/mock-server-2.15.0.tgz#de86ddc4c3f7cbe1e91941832c4b317946e90364"
|
resolved "https://registry.yarnpkg.com/@signalapp/mock-server/-/mock-server-2.17.0.tgz#070eb1e0ea33f5947450ac54fe86ade78e589447"
|
||||||
integrity sha512-bxu4hpnEAAvDT7Yg2LZQNIL/9ciNrGG0hPJlj+dT2iwsHo2AAP8Ej4sLfAiy0O2kYbf2bKcvfTE9C+XwkdAW+w==
|
integrity sha512-qhvhRvvWAlpR2lCGKLJWUSf5fpx//ZljwxqoQy1FZVnRYy6kEPsEUiO3oNE5Y+7HqFlMgD0Yt2OJbamvbQKngg==
|
||||||
dependencies:
|
dependencies:
|
||||||
"@signalapp/libsignal-client" "^0.22.0"
|
"@signalapp/libsignal-client" "^0.22.0"
|
||||||
debug "^4.3.2"
|
debug "^4.3.2"
|
||||||
|
|
Loading…
Reference in a new issue