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}.",
|
||||
"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": {
|
||||
"message": "What's New",
|
||||
"description": "Title for the whats new modal"
|
||||
|
|
|
@ -182,7 +182,7 @@
|
|||
"@electron/fuses": "1.5.0",
|
||||
"@formatjs/intl": "2.6.7",
|
||||
"@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-actions": "6.5.6",
|
||||
"@storybook/addon-controls": "6.5.6",
|
||||
|
|
|
@ -51,6 +51,7 @@ message Content {
|
|||
optional bytes decryptionErrorMessage = 8;
|
||||
optional StoryMessage storyMessage = 9;
|
||||
optional PniSignatureMessage pniSignatureMessage = 10;
|
||||
optional EditMessage editMessage = 11;
|
||||
}
|
||||
|
||||
// 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 StoryMessage storyMessage = 8;
|
||||
repeated StoryMessageRecipient storyMessageRecipients = 9;
|
||||
optional EditMessage editMessage = 10;
|
||||
}
|
||||
|
||||
message Contacts {
|
||||
|
@ -720,3 +722,8 @@ message PniSignatureMessage {
|
|||
// Signature *by* the PNI identity key *of* the ACI identity key
|
||||
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
|
||||
.module-message__metadata--with-image-no-caption {
|
||||
position: absolute;
|
||||
|
|
|
@ -114,6 +114,8 @@ import { areAnyCallsActiveOrRinging } from './state/selectors/calling';
|
|||
import { badgeImageFileDownloader } from './badges/badgeImageFileDownloader';
|
||||
import { actionCreators } from './state/actions';
|
||||
import { Deletes } from './messageModifiers/Deletes';
|
||||
import type { EditAttributesType } from './messageModifiers/Edits';
|
||||
import * as Edits from './messageModifiers/Edits';
|
||||
import {
|
||||
MessageReceipts,
|
||||
MessageReceiptType,
|
||||
|
@ -3069,6 +3071,35 @@ export async function startApp(): Promise<void> {
|
|||
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)) {
|
||||
confirm();
|
||||
return;
|
||||
|
@ -3415,6 +3446,29 @@ export async function startApp(): Promise<void> {
|
|||
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)) {
|
||||
event.confirm();
|
||||
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 type {
|
||||
ContactModalStateType,
|
||||
UserNotFoundModalStateType,
|
||||
SafetyNumberChangedBlockingDataType,
|
||||
AuthorizeArtCreatorDataType,
|
||||
ContactModalStateType,
|
||||
EditHistoryMessagesType,
|
||||
ForwardMessagesPropsType,
|
||||
SafetyNumberChangedBlockingDataType,
|
||||
UserNotFoundModalStateType,
|
||||
} from '../state/ducks/globalModals';
|
||||
import type { LocalizerType, ThemeType } from '../types/Util';
|
||||
import { missingCaseError } from '../util/missingCaseError';
|
||||
|
@ -28,6 +29,9 @@ export type PropsType = {
|
|||
// ContactModal
|
||||
contactModalState: ContactModalStateType | undefined;
|
||||
renderContactModal: () => JSX.Element;
|
||||
// EditHistoryMessagesModal
|
||||
editHistoryMessages: EditHistoryMessagesType | undefined;
|
||||
renderEditHistoryMessagesModal: () => JSX.Element;
|
||||
// ErrorModal
|
||||
errorModalProps: { description?: string; title?: string } | undefined;
|
||||
renderErrorModal: (opts: {
|
||||
|
@ -82,6 +86,9 @@ export function GlobalModalContainer({
|
|||
// ContactModal
|
||||
contactModalState,
|
||||
renderContactModal,
|
||||
// EditHistoryMessages
|
||||
editHistoryMessages,
|
||||
renderEditHistoryMessagesModal,
|
||||
// ErrorModal
|
||||
errorModalProps,
|
||||
renderErrorModal,
|
||||
|
@ -147,6 +154,10 @@ export function GlobalModalContainer({
|
|||
return renderContactModal();
|
||||
}
|
||||
|
||||
if (editHistoryMessages) {
|
||||
return renderEditHistoryMessagesModal();
|
||||
}
|
||||
|
||||
if (forwardMessagesProps) {
|
||||
return renderForwardMessagesModal();
|
||||
}
|
||||
|
|
|
@ -207,6 +207,7 @@ export type PropsData = {
|
|||
text?: string;
|
||||
textDirection: TextDirection;
|
||||
textAttachment?: AttachmentType;
|
||||
isEditedMessage?: boolean;
|
||||
isSticker?: boolean;
|
||||
isTargeted?: boolean;
|
||||
isTargetedCounter?: number;
|
||||
|
@ -338,6 +339,7 @@ export type PropsActions = {
|
|||
}) => void;
|
||||
targetMessage?: (messageId: string, conversationId: string) => unknown;
|
||||
|
||||
showEditHistoryModal?: (id: string) => unknown;
|
||||
showExpiredIncomingTapToViewToast: () => unknown;
|
||||
showExpiredOutgoingTapToViewToast: () => unknown;
|
||||
viewStory: ViewStoryActionCreatorType;
|
||||
|
@ -768,9 +770,11 @@ export class Message extends React.PureComponent<Props, State> {
|
|||
expirationTimestamp,
|
||||
i18n,
|
||||
id,
|
||||
isEditedMessage,
|
||||
isSticker,
|
||||
isTapToViewExpired,
|
||||
pushPanelForConversation,
|
||||
showEditHistoryModal,
|
||||
status,
|
||||
text,
|
||||
textAttachment,
|
||||
|
@ -788,12 +792,14 @@ export class Message extends React.PureComponent<Props, State> {
|
|||
hasText={Boolean(text)}
|
||||
i18n={i18n}
|
||||
id={id}
|
||||
isEditedMessage={isEditedMessage}
|
||||
isInline={isInline}
|
||||
isShowingImage={this.isShowingImage()}
|
||||
isSticker={isStickerLike}
|
||||
isTapToViewExpired={isTapToViewExpired}
|
||||
onWidthMeasured={isInline ? this.updateMetadataWidth : undefined}
|
||||
pushPanelForConversation={pushPanelForConversation}
|
||||
showEditHistoryModal={showEditHistoryModal}
|
||||
status={status}
|
||||
textPending={textAttachment?.pending}
|
||||
timestamp={timestamp}
|
||||
|
|
|
@ -22,12 +22,14 @@ type PropsType = {
|
|||
hasText: boolean;
|
||||
i18n: LocalizerType;
|
||||
id: string;
|
||||
isEditedMessage?: boolean;
|
||||
isInline?: boolean;
|
||||
isShowingImage: boolean;
|
||||
isSticker?: boolean;
|
||||
isTapToViewExpired?: boolean;
|
||||
onWidthMeasured?: (width: number) => unknown;
|
||||
pushPanelForConversation: PushPanelForConversationActionType;
|
||||
showEditHistoryModal?: (id: string) => unknown;
|
||||
status?: MessageStatusType;
|
||||
textPending?: boolean;
|
||||
timestamp: number;
|
||||
|
@ -41,12 +43,14 @@ export function MessageMetadata({
|
|||
hasText,
|
||||
i18n,
|
||||
id,
|
||||
isEditedMessage,
|
||||
isInline,
|
||||
isShowingImage,
|
||||
isSticker,
|
||||
isTapToViewExpired,
|
||||
onWidthMeasured,
|
||||
pushPanelForConversation,
|
||||
showEditHistoryModal,
|
||||
status,
|
||||
textPending,
|
||||
timestamp,
|
||||
|
@ -130,6 +134,15 @@ export function MessageMetadata({
|
|||
);
|
||||
const children = (
|
||||
<>
|
||||
{isEditedMessage && showEditHistoryModal && (
|
||||
<button
|
||||
className="module-message__metadata__edited"
|
||||
onClick={() => showEditHistoryModal(id)}
|
||||
type="button"
|
||||
>
|
||||
{i18n('icu:MessageMetadata__edited')}
|
||||
</button>
|
||||
)}
|
||||
{timestampNode}
|
||||
{expirationLength ? (
|
||||
<ExpireTimer
|
||||
|
|
|
@ -17,6 +17,7 @@ import type {
|
|||
|
||||
import type { MessageModel } from '../models/messages';
|
||||
import type { AttachmentType } from '../types/Attachment';
|
||||
import { getAttachmentSignature, isDownloaded } from '../types/Attachment';
|
||||
import * as Errors from '../types/errors';
|
||||
import type { LoggerType } from '../types/Logging';
|
||||
import * as log from '../logging/log';
|
||||
|
@ -433,7 +434,7 @@ function _markAttachmentAsPermanentError(
|
|||
attachment: AttachmentType
|
||||
): AttachmentType {
|
||||
return {
|
||||
...omit(attachment, ['key', 'digest', 'id']),
|
||||
...omit(attachment, ['key', 'id']),
|
||||
error: true,
|
||||
};
|
||||
}
|
||||
|
@ -454,6 +455,7 @@ async function _addAttachmentToMessage(
|
|||
}
|
||||
|
||||
const logPrefix = `${message.idForLogging()} (type: ${type}, index: ${index})`;
|
||||
const attachmentSignature = getAttachmentSignature(attachment);
|
||||
|
||||
if (type === 'long-message') {
|
||||
// Attachment wasn't downloaded yet.
|
||||
|
@ -482,13 +484,60 @@ async function _addAttachmentToMessage(
|
|||
|
||||
if (type === 'attachment') {
|
||||
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) {
|
||||
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);
|
||||
|
||||
// Replace attachment
|
||||
const newAttachments = [...attachments];
|
||||
newAttachments[index] = attachment;
|
||||
|
||||
|
@ -499,6 +548,48 @@ async function _addAttachmentToMessage(
|
|||
|
||||
if (type === '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) {
|
||||
throw new Error(
|
||||
`_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`);
|
||||
}
|
||||
|
||||
// Verify attachment is still valid
|
||||
const isSameAttachment =
|
||||
item.image && getAttachmentSignature(item.image) === attachmentSignature;
|
||||
if (handledInEditHistory && !isSameAttachment) {
|
||||
return;
|
||||
}
|
||||
strictAssert(isSameAttachment, `${logPrefix} mismatched attachment`);
|
||||
_checkOldAttachment(item, 'image', logPrefix);
|
||||
|
||||
// Replace attachment
|
||||
const newPreview = [...preview];
|
||||
newPreview[index] = {
|
||||
...preview[index],
|
||||
|
|
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,9 +285,10 @@ export class MessageReceipts extends Collection<MessageReceiptModel> {
|
|||
const type = receipt.get('type');
|
||||
|
||||
try {
|
||||
const messages = await window.Signal.Data.getMessagesBySentAt(
|
||||
messageSentAt
|
||||
);
|
||||
const messages =
|
||||
await window.Signal.Data.getMessagesIncludingEditedBySentAt(
|
||||
messageSentAt
|
||||
);
|
||||
|
||||
const message = await getTargetMessage(
|
||||
sourceConversationId,
|
||||
|
|
|
@ -82,9 +82,10 @@ export class ReadSyncs extends Collection {
|
|||
|
||||
async onSync(sync: ReadSyncModel): Promise<void> {
|
||||
try {
|
||||
const messages = await window.Signal.Data.getMessagesBySentAt(
|
||||
sync.get('timestamp')
|
||||
);
|
||||
const messages =
|
||||
await window.Signal.Data.getMessagesIncludingEditedBySentAt(
|
||||
sync.get('timestamp')
|
||||
);
|
||||
|
||||
const found = messages.find(item => {
|
||||
const sender = window.ConversationController.lookupOrCreate({
|
||||
|
|
|
@ -98,7 +98,12 @@ export class ViewSyncs extends Collection {
|
|||
|
||||
const attachments = message.get('attachments');
|
||||
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>;
|
||||
};
|
||||
|
||||
export type EditHistoryType = {
|
||||
attachments?: Array<AttachmentType>;
|
||||
body?: string;
|
||||
bodyRanges?: BodyRangesType;
|
||||
preview?: Array<LinkPreviewType>;
|
||||
timestamp: number;
|
||||
};
|
||||
|
||||
export type MessageAttributesType = {
|
||||
bodyAttachment?: AttachmentType;
|
||||
bodyRanges?: BodyRangesType;
|
||||
|
@ -141,6 +149,8 @@ export type MessageAttributesType = {
|
|||
isErased?: boolean;
|
||||
isTapToViewInvalid?: boolean;
|
||||
isViewOnce?: boolean;
|
||||
editHistory?: Array<EditHistoryType>;
|
||||
editMessageTimestamp?: number;
|
||||
key_changed?: string;
|
||||
local?: boolean;
|
||||
logger?: unknown;
|
||||
|
|
|
@ -31,7 +31,7 @@ import { normalizeUuid } from '../util/normalizeUuid';
|
|||
import { clearTimeoutIfNecessary } from '../util/clearTimeoutIfNecessary';
|
||||
import type { AttachmentType, ThumbnailType } from '../types/Attachment';
|
||||
import { toDayMillis } from '../util/timestamp';
|
||||
import { isGIF, isVoiceMessage } from '../types/Attachment';
|
||||
import { isVoiceMessage } from '../types/Attachment';
|
||||
import type { CallHistoryDetailsType } from '../types/Calling';
|
||||
import { CallMode } from '../types/Calling';
|
||||
import * as Conversation from '../types/Conversation';
|
||||
|
@ -73,7 +73,7 @@ import { sniffImageMimeType } from '../util/sniffImageMimeType';
|
|||
import { isValidE164 } from '../util/isValidE164';
|
||||
import { canConversationBeUnarchived } from '../util/canConversationBeUnarchived';
|
||||
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 type { UUIDStringType } from '../types/UUID';
|
||||
import {
|
||||
|
@ -108,15 +108,7 @@ import { ReadStatus } from '../messages/MessageReadStatus';
|
|||
import { SendStatus } from '../messages/MessageSendState';
|
||||
import type { LinkPreviewType } from '../types/message/LinkPreviews';
|
||||
import { MINUTE, SECOND, DurationInSeconds } from '../util/durations';
|
||||
import {
|
||||
concat,
|
||||
filter,
|
||||
map,
|
||||
take,
|
||||
repeat,
|
||||
zipObject,
|
||||
collect,
|
||||
} from '../util/iterables';
|
||||
import { concat, filter, map, repeat, zipObject } from '../util/iterables';
|
||||
import * as universalExpireTimer from '../util/universalExpireTimer';
|
||||
import type { GroupNameCollisionsWithIdsByTitle } from '../util/groupMemberNameCollisions';
|
||||
import {
|
||||
|
@ -130,10 +122,8 @@ import { SignalService as Proto } from '../protobuf';
|
|||
import {
|
||||
getMessagePropStatus,
|
||||
hasErrors,
|
||||
isGiftBadge,
|
||||
isIncoming,
|
||||
isStory,
|
||||
isTapToView,
|
||||
} from '../state/selectors/message';
|
||||
import {
|
||||
conversationJobQueue,
|
||||
|
@ -162,6 +152,7 @@ import { removePendingMember } from '../util/removePendingMember';
|
|||
import { isMemberPending } from '../util/isMemberPending';
|
||||
import { imageToBlurHash } from '../util/imageToBlurHash';
|
||||
import { ReceiptType } from '../types/Receipt';
|
||||
import { getQuoteAttachment } from '../util/makeQuote';
|
||||
|
||||
const EMPTY_ARRAY: Readonly<[]> = [];
|
||||
const EMPTY_GROUP_COLLISIONS: GroupNameCollisionsWithIdsByTitle = {};
|
||||
|
@ -175,7 +166,6 @@ const {
|
|||
deleteAttachmentData,
|
||||
doesAttachmentExist,
|
||||
getAbsoluteAttachmentPath,
|
||||
loadAttachmentData,
|
||||
readStickerData,
|
||||
upgradeMessageSchema,
|
||||
writeNewAttachmentData,
|
||||
|
@ -3860,109 +3850,7 @@ export class ConversationModel extends window.Backbone
|
|||
thumbnail: ThumbnailType | null;
|
||||
}>
|
||||
> {
|
||||
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 [];
|
||||
}
|
||||
|
||||
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(),
|
||||
};
|
||||
return getQuoteAttachment(attachments, preview, sticker);
|
||||
}
|
||||
|
||||
async sendStickerMessage(packId: string, stickerId: number): Promise<void> {
|
||||
|
|
|
@ -80,7 +80,6 @@ import { migrateLegacySendAttributes } from '../messages/migrateLegacySendAttrib
|
|||
import { getOwn } from '../util/getOwn';
|
||||
import { markRead, markViewed } from '../services/MessageUpdater';
|
||||
import { scheduleOptimizeFTS } from '../services/ftsOptimizer';
|
||||
import { isMessageUnread } from '../util/isMessageUnread';
|
||||
import {
|
||||
isDirectConversation,
|
||||
isGroup,
|
||||
|
@ -181,78 +180,10 @@ import {
|
|||
} from '../util/attachmentDownloadQueue';
|
||||
import { getTitleNoDefault, getNumber } from '../util/getTitle';
|
||||
import dataInterface from '../sql/Client';
|
||||
|
||||
function isSameUuid(
|
||||
a: UUID | string | null | undefined,
|
||||
b: UUID | string | null | undefined
|
||||
): 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;
|
||||
}
|
||||
import * as Edits from '../messageModifiers/Edits';
|
||||
import { handleEditMessage } from '../util/handleEditMessage';
|
||||
import { getQuoteBodyText } from '../util/getQuoteBodyText';
|
||||
import { shouldReplyNotifyUser } from '../util/shouldReplyNotifyUser';
|
||||
|
||||
/* eslint-disable more/no-then */
|
||||
|
||||
|
@ -1184,14 +1115,15 @@ export class MessageModel extends window.Backbone.Model<MessageAttributesType> {
|
|||
}
|
||||
|
||||
this.set({
|
||||
isErased: true,
|
||||
attachments: [],
|
||||
body: '',
|
||||
bodyRanges: undefined,
|
||||
attachments: [],
|
||||
quote: undefined,
|
||||
contact: [],
|
||||
sticker: undefined,
|
||||
editHistory: undefined,
|
||||
isErased: true,
|
||||
preview: [],
|
||||
quote: undefined,
|
||||
sticker: undefined,
|
||||
...additionalProperties,
|
||||
});
|
||||
this.getConversation()?.debouncedUpdateLastMessage?.();
|
||||
|
@ -2045,7 +1977,8 @@ export class MessageModel extends window.Backbone.Model<MessageAttributesType> {
|
|||
queryMessage = matchingMessage;
|
||||
} else {
|
||||
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 =>
|
||||
isQuoteAMatch(item, conversationId, result)
|
||||
);
|
||||
|
@ -2065,18 +1998,6 @@ export class MessageModel extends window.Backbone.Model<MessageAttributesType> {
|
|||
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(
|
||||
originalMessage: MessageModel,
|
||||
quote: QuotedMessageType
|
||||
|
@ -2125,7 +2046,7 @@ export class MessageModel extends window.Backbone.Model<MessageAttributesType> {
|
|||
quote.isViewOnce = false;
|
||||
|
||||
// eslint-disable-next-line no-param-reassign
|
||||
quote.text = originalMessage.getQuoteBodyText();
|
||||
quote.text = getQuoteBodyText(originalMessage.attributes, quote.id);
|
||||
if (firstAttachment) {
|
||||
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) {
|
||||
log.info(
|
||||
`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 { notificationService } from './notifications';
|
||||
import { SeenStatus } from '../MessageSeenStatus';
|
||||
import { queueUpdateMessage } from '../util/messageBatcher';
|
||||
|
||||
function markReadOrViewed(
|
||||
messageAttrs: Readonly<MessageAttributesType>,
|
||||
|
@ -34,7 +35,7 @@ function markReadOrViewed(
|
|||
notificationService.removeBy({ messageId });
|
||||
|
||||
if (!skipSave) {
|
||||
window.Signal.Util.queueUpdateMessage(nextMessageAttributes);
|
||||
queueUpdateMessage(nextMessageAttributes);
|
||||
}
|
||||
|
||||
return nextMessageAttributes;
|
||||
|
|
|
@ -128,12 +128,14 @@ class NotificationService extends EventEmitter {
|
|||
public notify({
|
||||
icon,
|
||||
message,
|
||||
messageId,
|
||||
onNotificationClick,
|
||||
silent,
|
||||
title,
|
||||
}: Readonly<{
|
||||
icon?: string;
|
||||
message: string;
|
||||
messageId?: string;
|
||||
onNotificationClick: () => void;
|
||||
silent: boolean;
|
||||
title: string;
|
||||
|
@ -149,6 +151,7 @@ class NotificationService extends EventEmitter {
|
|||
icon,
|
||||
silent:
|
||||
silent || audioNotificationSupport !== AudioNotificationSupport.Native,
|
||||
tag: messageId,
|
||||
});
|
||||
notification.onclick = onNotificationClick;
|
||||
|
||||
|
|
|
@ -387,6 +387,13 @@ export type FTSOptimizationStateType = Readonly<{
|
|||
done?: boolean;
|
||||
}>;
|
||||
|
||||
export type EditedMessageType = Readonly<{
|
||||
fromId: string;
|
||||
messageId: string;
|
||||
sentAt: number;
|
||||
readStatus: MessageType['readStatus'];
|
||||
}>;
|
||||
|
||||
export type DataInterface = {
|
||||
close: () => Promise<void>;
|
||||
removeDB: () => Promise<void>;
|
||||
|
@ -514,6 +521,10 @@ export type DataInterface = {
|
|||
readAt?: number;
|
||||
storyId?: string;
|
||||
}) => Promise<GetUnreadByConversationAndMarkReadResultType>;
|
||||
getUnreadEditedMessagesAndMarkRead: (options: {
|
||||
fromId: string;
|
||||
newestUnreadAt: number;
|
||||
}) => Promise<GetUnreadByConversationAndMarkReadResultType>;
|
||||
getUnreadReactionsAndMarkRead: (options: {
|
||||
conversationId: string;
|
||||
newestUnreadAt: number;
|
||||
|
@ -543,9 +554,15 @@ export type DataInterface = {
|
|||
messageIds: ReadonlyArray<string>
|
||||
) => Promise<Array<MessageType>>;
|
||||
_getAllMessages: () => Promise<Array<MessageType>>;
|
||||
_getAllEditedMessages: () => Promise<
|
||||
Array<{ messageId: string; sentAt: number }>
|
||||
>;
|
||||
_removeAllMessages: () => Promise<void>;
|
||||
getAllMessageIds: () => Promise<Array<string>>;
|
||||
getMessagesBySentAt: (sentAt: number) => Promise<Array<MessageType>>;
|
||||
getMessagesIncludingEditedBySentAt: (
|
||||
sentAt: number
|
||||
) => Promise<Array<MessageType>>;
|
||||
getExpiredMessages: () => Promise<Array<MessageType>>;
|
||||
getMessagesUnexpectedlyMissingExpirationStartTimestamp: () => Promise<
|
||||
Array<MessageType>
|
||||
|
@ -592,6 +609,11 @@ export type DataInterface = {
|
|||
getNearbyMessageFromDeletedSet: (
|
||||
options: GetNearbyMessageFromDeletedSetOptionsType
|
||||
) => Promise<string | null>;
|
||||
saveEditedMessage: (
|
||||
mainMessage: MessageType,
|
||||
ourUuid: UUIDStringType,
|
||||
opts: EditedMessageType
|
||||
) => Promise<void>;
|
||||
getUnprocessedCount: () => Promise<number>;
|
||||
getUnprocessedByIdsAndIncrementAttempts: (
|
||||
ids: ReadonlyArray<string>
|
||||
|
|
138
ts/sql/Server.ts
138
ts/sql/Server.ts
|
@ -87,6 +87,7 @@ import type {
|
|||
ConversationType,
|
||||
DeleteSentProtoRecipientOptionsType,
|
||||
DeleteSentProtoRecipientResultType,
|
||||
EditedMessageType,
|
||||
EmojiType,
|
||||
FTSOptimizationStateType,
|
||||
GetAllStoriesResultType,
|
||||
|
@ -252,9 +253,12 @@ const dataInterface: ServerInterface = {
|
|||
getMessageById,
|
||||
getMessagesById,
|
||||
_getAllMessages,
|
||||
_getAllEditedMessages,
|
||||
_removeAllMessages,
|
||||
getAllMessageIds,
|
||||
getMessagesBySentAt,
|
||||
getMessagesIncludingEditedBySentAt,
|
||||
getUnreadEditedMessagesAndMarkRead,
|
||||
getExpiredMessages,
|
||||
getMessagesUnexpectedlyMissingExpirationStartTimestamp,
|
||||
getSoonestMessageExpiry,
|
||||
|
@ -273,6 +277,7 @@ const dataInterface: ServerInterface = {
|
|||
migrateConversationMessages,
|
||||
getMessagesBetween,
|
||||
getNearbyMessageFromDeletedSet,
|
||||
saveEditedMessage,
|
||||
|
||||
getUnprocessedCount,
|
||||
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 updateToSchemaVersion78 from './78-merge-receipt-jobs';
|
||||
import updateToSchemaVersion79 from './79-paging-lightbox';
|
||||
import updateToSchemaVersion80 from './80-edited-messages';
|
||||
|
||||
function updateToSchemaVersion1(
|
||||
currentVersion: number,
|
||||
|
@ -1979,6 +1980,8 @@ export const SCHEMA_VERSIONS = [
|
|||
updateToSchemaVersion77,
|
||||
updateToSchemaVersion78,
|
||||
updateToSchemaVersion79,
|
||||
|
||||
updateToSchemaVersion80,
|
||||
];
|
||||
|
||||
export function updateSchema(db: Database, logger: LoggerType): void {
|
||||
|
|
|
@ -88,6 +88,7 @@ import type {
|
|||
import { longRunningTaskWrapper } from '../../util/longRunningTaskWrapper';
|
||||
import { drop } from '../../util/drop';
|
||||
import { strictAssert } from '../../util/assert';
|
||||
import { makeQuote } from '../../util/makeQuote';
|
||||
|
||||
// State
|
||||
// eslint-disable-next-line local-rules/type-alias-readonlydeep
|
||||
|
@ -630,7 +631,7 @@ export function setQuoteByMessageId(
|
|||
}
|
||||
|
||||
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
|
||||
if (getState().conversations.selectedConversationId !== conversationId) {
|
||||
|
|
|
@ -1991,7 +1991,15 @@ function kickOffAttachmentDownload(
|
|||
`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({
|
||||
type: 'NOOP',
|
||||
|
|
|
@ -4,15 +4,24 @@
|
|||
import type { ThunkAction } from 'redux-thunk';
|
||||
import type { ReadonlyDeep } from 'type-fest';
|
||||
import type { ExplodePromiseResultType } from '../../util/explodePromise';
|
||||
import type { GroupV2PendingMemberType } from '../../model-types.d';
|
||||
import type { PropsForMessage } from '../selectors/message';
|
||||
import type {
|
||||
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 { SafetyNumberChangeSource } from '../../components/SafetyNumberChangeDialog';
|
||||
import type { StateType as RootStateType } from '../reducer';
|
||||
import type { UUIDStringType } from '../../types/UUID';
|
||||
import * as Errors from '../../types/errors';
|
||||
import * as SingleServePromise from '../../services/singleServePromise';
|
||||
import * as Stickers from '../../types/Stickers';
|
||||
import * as Errors from '../../types/errors';
|
||||
import * as log from '../../logging/log';
|
||||
import { getMessageById } from '../../messages/getMessageById';
|
||||
import { getMessagePropsSelector } from '../selectors/message';
|
||||
import type { BoundActionCreatorsMapObject } from '../../hooks/useBoundActions';
|
||||
|
@ -22,19 +31,24 @@ import { isGroupV1 } from '../../util/whatTypeOfConversation';
|
|||
import { authorizeArtCreator } from '../../textsecure/authorizeArtCreator';
|
||||
import type { AuthorizeArtCreatorOptionsType } from '../../textsecure/authorizeArtCreator';
|
||||
import { getGroupMigrationMembers } from '../../groups';
|
||||
import * as log from '../../logging/log';
|
||||
import { ToastType } from '../../types/Toast';
|
||||
import {
|
||||
MESSAGE_CHANGED,
|
||||
MESSAGE_DELETED,
|
||||
MESSAGE_EXPIRED,
|
||||
} from './conversations';
|
||||
import { SHOW_TOAST } from './toast';
|
||||
import type { ShowToastActionType } from './toast';
|
||||
|
||||
// State
|
||||
|
||||
export type EditHistoryMessagesType = ReadonlyDeep<
|
||||
Array<MessageAttributesType>
|
||||
>;
|
||||
export type ConfirmDeleteForMeModalProps = ReadonlyDeep<{
|
||||
count: number;
|
||||
}>;
|
||||
export type ForwardMessagePropsType = ReadonlyDeep<
|
||||
Omit<PropsForMessage, 'renderingContext' | 'menu' | 'contextMenu'>
|
||||
>;
|
||||
export type ForwardMessagePropsType = ReadonlyDeep<MessagePropsType>;
|
||||
export type ForwardMessagesPropsType = ReadonlyDeep<{
|
||||
messages: Array<ForwardMessagePropsType>;
|
||||
onForward?: () => void;
|
||||
|
@ -57,6 +71,7 @@ type MigrateToGV2PropsType = ReadonlyDeep<{
|
|||
export type GlobalModalsStateType = ReadonlyDeep<{
|
||||
addUserToAnotherGroupModalContactId?: string;
|
||||
contactModalState?: ContactModalStateType;
|
||||
editHistoryMessages?: EditHistoryMessagesType;
|
||||
errorModalProps?: {
|
||||
description?: string;
|
||||
title?: string;
|
||||
|
@ -115,6 +130,8 @@ const CONFIRM_AUTH_ART_CREATOR_PENDING =
|
|||
'globalModals/CONFIRM_AUTH_ART_CREATOR_PENDING';
|
||||
const 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<{
|
||||
contactId: string;
|
||||
|
@ -264,34 +281,50 @@ type ConfirmAuthArtCreatorFulfilledActionType = ReadonlyDeep<{
|
|||
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<
|
||||
| StartMigrationToGV2ActionType
|
||||
| CloseGV2MigrationDialogActionType
|
||||
| HideContactModalActionType
|
||||
| ShowContactModalActionType
|
||||
| HideWhatsNewModalActionType
|
||||
| ShowWhatsNewModalActionType
|
||||
| HideUserNotFoundModalActionType
|
||||
| ShowUserNotFoundModalActionType
|
||||
| HideStoriesSettingsActionType
|
||||
| ShowStoriesSettingsActionType
|
||||
| HideSendAnywayDialogActiontype
|
||||
| ShowSendAnywayDialogActionType
|
||||
| CloseStickerPackPreviewActionType
|
||||
| ShowStickerPackPreviewActionType
|
||||
| CloseErrorModalActionType
|
||||
| ShowErrorModalActionType
|
||||
| CloseShortcutGuideModalActionType
|
||||
| ShowShortcutGuideModalActionType
|
||||
| CancelAuthArtCreatorActionType
|
||||
| ConfirmAuthArtCreatorPendingActionType
|
||||
| CloseEditHistoryModalActionType
|
||||
| CloseErrorModalActionType
|
||||
| CloseGV2MigrationDialogActionType
|
||||
| CloseShortcutGuideModalActionType
|
||||
| CloseStickerPackPreviewActionType
|
||||
| ConfirmAuthArtCreatorFulfilledActionType
|
||||
| ConfirmAuthArtCreatorPendingActionType
|
||||
| HideContactModalActionType
|
||||
| HideSendAnywayDialogActiontype
|
||||
| HideStoriesSettingsActionType
|
||||
| HideUserNotFoundModalActionType
|
||||
| HideWhatsNewModalActionType
|
||||
| MessageChangedActionType
|
||||
| MessageDeletedActionType
|
||||
| MessageExpiredActionType
|
||||
| ShowAuthArtCreatorActionType
|
||||
| ShowContactModalActionType
|
||||
| ShowEditHistoryModalActionType
|
||||
| ShowErrorModalActionType
|
||||
| ShowSendAnywayDialogActionType
|
||||
| ShowShortcutGuideModalActionType
|
||||
| ShowStickerPackPreviewActionType
|
||||
| ShowStoriesSettingsActionType
|
||||
| ShowUserNotFoundModalActionType
|
||||
| ShowWhatsNewModalActionType
|
||||
| StartMigrationToGV2ActionType
|
||||
| ToggleAddUserToAnotherGroupModalActionType
|
||||
| ToggleForwardMessagesModalActionType
|
||||
| ToggleProfileEditorActionType
|
||||
| ToggleProfileEditorErrorActionType
|
||||
| ToggleSafetyNumberModalActionType
|
||||
| ToggleAddUserToAnotherGroupModalActionType
|
||||
| ToggleSignalConnectionsModalActionType
|
||||
| ToggleConfirmationModalActionType
|
||||
>;
|
||||
|
@ -299,34 +332,36 @@ export type GlobalModalsActionType = ReadonlyDeep<
|
|||
// Action Creators
|
||||
|
||||
export const actions = {
|
||||
hideContactModal,
|
||||
showContactModal,
|
||||
hideWhatsNewModal,
|
||||
showWhatsNewModal,
|
||||
hideUserNotFoundModal,
|
||||
showUserNotFoundModal,
|
||||
hideStoriesSettings,
|
||||
showStoriesSettings,
|
||||
cancelAuthorizeArtCreator,
|
||||
closeEditHistoryModal,
|
||||
closeErrorModal,
|
||||
closeGV2MigrationDialog,
|
||||
closeShortcutGuideModal,
|
||||
closeStickerPackPreview,
|
||||
confirmAuthorizeArtCreator,
|
||||
hideBlockingSafetyNumberChangeDialog,
|
||||
hideContactModal,
|
||||
hideStoriesSettings,
|
||||
hideUserNotFoundModal,
|
||||
hideWhatsNewModal,
|
||||
showAuthorizeArtCreator,
|
||||
showBlockingSafetyNumberChangeDialog,
|
||||
showContactModal,
|
||||
showEditHistoryModal,
|
||||
showErrorModal,
|
||||
showGV2MigrationDialog,
|
||||
showShortcutGuideModal,
|
||||
showStickerPackPreview,
|
||||
showStoriesSettings,
|
||||
showUserNotFoundModal,
|
||||
showWhatsNewModal,
|
||||
toggleAddUserToAnotherGroupModal,
|
||||
toggleConfirmationModal,
|
||||
toggleForwardMessagesModal,
|
||||
toggleProfileEditor,
|
||||
toggleProfileEditorHasError,
|
||||
toggleSafetyNumberModal,
|
||||
toggleAddUserToAnotherGroupModal,
|
||||
toggleSignalConnectionsModal,
|
||||
toggleConfirmationModal,
|
||||
showGV2MigrationDialog,
|
||||
closeGV2MigrationDialog,
|
||||
showStickerPackPreview,
|
||||
closeStickerPackPreview,
|
||||
closeErrorModal,
|
||||
showErrorModal,
|
||||
closeShortcutGuideModal,
|
||||
showShortcutGuideModal,
|
||||
showAuthorizeArtCreator,
|
||||
cancelAuthorizeArtCreator,
|
||||
confirmAuthorizeArtCreator,
|
||||
};
|
||||
|
||||
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(
|
||||
data: AuthorizeArtCreatorDataType
|
||||
): 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;
|
||||
}
|
||||
|
|
|
@ -136,6 +136,10 @@ type FormattedContact = Partial<ConversationType> &
|
|||
| 'unblurredAvatarPath'
|
||||
>;
|
||||
export type PropsForMessage = Omit<TimelineMessagePropsData, 'interactionMode'>;
|
||||
export type MessagePropsType = Omit<
|
||||
PropsForMessage,
|
||||
'renderingContext' | 'menu' | 'contextMenu'
|
||||
>;
|
||||
type PropsForUnsupportedMessage = {
|
||||
canProcessNow: boolean;
|
||||
contact: FormattedContact;
|
||||
|
@ -718,6 +722,7 @@ export const getPropsForMessage = (
|
|||
giftBadge: message.giftBadge,
|
||||
id: message.id,
|
||||
isBlocked: conversation.isBlocked || false,
|
||||
isEditedMessage: Boolean(message.editHistory),
|
||||
isMessageRequestAccepted: conversation?.acceptedMessageRequest ?? true,
|
||||
isTargeted,
|
||||
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 { SmartAddUserToAnotherGroupModal } from './AddUserToAnotherGroupModal';
|
||||
import { SmartContactModal } from './ContactModal';
|
||||
import { SmartEditHistoryMessagesModal } from './EditHistoryMessagesModal';
|
||||
import { SmartForwardMessagesModal } from './ForwardMessagesModal';
|
||||
import { SmartProfileEditorModal } from './ProfileEditorModal';
|
||||
import { SmartSafetyNumberModal } from './SafetyNumberModal';
|
||||
|
@ -21,6 +22,10 @@ import { getConversationsStoppingSend } from '../selectors/conversations';
|
|||
import { getIntl, getTheme } from '../selectors/user';
|
||||
import { useGlobalModalActions } from '../ducks/globalModals';
|
||||
|
||||
function renderEditHistoryMessagesModal(): JSX.Element {
|
||||
return <SmartEditHistoryMessagesModal />;
|
||||
}
|
||||
|
||||
function renderProfileEditor(): JSX.Element {
|
||||
return <SmartProfileEditorModal />;
|
||||
}
|
||||
|
@ -55,6 +60,7 @@ export function SmartGlobalModalContainer(): JSX.Element {
|
|||
const {
|
||||
addUserToAnotherGroupModalContactId,
|
||||
contactModalState,
|
||||
editHistoryMessages,
|
||||
errorModalProps,
|
||||
forwardMessagesProps,
|
||||
isProfileEditorVisible,
|
||||
|
@ -120,6 +126,7 @@ export function SmartGlobalModalContainer(): JSX.Element {
|
|||
<GlobalModalContainer
|
||||
addUserToAnotherGroupModalContactId={addUserToAnotherGroupModalContactId}
|
||||
contactModalState={contactModalState}
|
||||
editHistoryMessages={editHistoryMessages}
|
||||
errorModalProps={errorModalProps}
|
||||
forwardMessagesProps={forwardMessagesProps}
|
||||
hasSafetyNumberChangeModal={hasSafetyNumberChangeModal}
|
||||
|
@ -133,6 +140,7 @@ export function SmartGlobalModalContainer(): JSX.Element {
|
|||
isWhatsNewVisible={isWhatsNewVisible}
|
||||
renderAddUserToAnotherGroup={renderAddUserToAnotherGroup}
|
||||
renderContactModal={renderContactModal}
|
||||
renderEditHistoryMessagesModal={renderEditHistoryMessagesModal}
|
||||
renderErrorModal={renderErrorModal}
|
||||
renderForwardMessagesModal={renderForwardMessagesModal}
|
||||
renderProfileEditor={renderProfileEditor}
|
||||
|
|
|
@ -130,6 +130,7 @@ export function SmartTimelineItem(props: ExternalProps): JSX.Element {
|
|||
|
||||
const {
|
||||
showContactModal,
|
||||
showEditHistoryModal,
|
||||
toggleForwardMessagesModal,
|
||||
toggleSafetyNumberModal,
|
||||
} = useGlobalModalActions();
|
||||
|
@ -161,6 +162,7 @@ export function SmartTimelineItem(props: ExternalProps): JSX.Element {
|
|||
shouldCollapseBelow={shouldCollapseBelow}
|
||||
shouldHideMetadata={shouldHideMetadata}
|
||||
shouldRenderDateHeader={shouldRenderDateHeader}
|
||||
showEditHistoryModal={showEditHistoryModal}
|
||||
i18n={i18n}
|
||||
interactionMode={interactionMode}
|
||||
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 { isNotNil } from '../util/isNotNil';
|
||||
import { chunk } from '../util/iterables';
|
||||
import { isOlderThan } from '../util/timestamp';
|
||||
|
||||
const GROUPV1_ID_LENGTH = 16;
|
||||
const GROUPV2_ID_LENGTH = 32;
|
||||
|
@ -2242,6 +2243,84 @@ export default class MessageReceiver
|
|||
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(
|
||||
envelope: UnsealedEnvelope,
|
||||
msg: Proto.IDataMessage
|
||||
|
@ -2358,6 +2437,7 @@ export default class MessageReceiver
|
|||
},
|
||||
this.removeFromCache.bind(this, envelope)
|
||||
);
|
||||
|
||||
return this.dispatchAndWait(logId, ev);
|
||||
}
|
||||
|
||||
|
@ -2463,6 +2543,11 @@ export default class MessageReceiver
|
|||
return;
|
||||
}
|
||||
|
||||
if (content.editMessage) {
|
||||
await this.handleEditMessage(envelope, content.editMessage);
|
||||
return;
|
||||
}
|
||||
|
||||
this.removeFromCache(envelope);
|
||||
|
||||
if (Bytes.isEmpty(content.senderKeyDistributionMessage)) {
|
||||
|
@ -2859,6 +2944,10 @@ export default class MessageReceiver
|
|||
if (syncMessage.sent) {
|
||||
const sentMessage = syncMessage.sent;
|
||||
|
||||
if (sentMessage.editMessage) {
|
||||
return this.handleSentEditMessage(envelope, sentMessage);
|
||||
}
|
||||
|
||||
if (sentMessage.storyMessageRecipients && sentMessage.isRecipientUpdate) {
|
||||
if (getStoriesBlocked()) {
|
||||
log.info(
|
||||
|
@ -2886,12 +2975,11 @@ export default class MessageReceiver
|
|||
}
|
||||
|
||||
if (sentMessage.storyMessage) {
|
||||
void this.handleStoryMessage(
|
||||
return this.handleStoryMessage(
|
||||
envelope,
|
||||
sentMessage.storyMessage,
|
||||
sentMessage
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!sentMessage || !sentMessage.message) {
|
||||
|
@ -2916,6 +3004,7 @@ export default class MessageReceiver
|
|||
'from',
|
||||
getEnvelopeId(envelope)
|
||||
);
|
||||
|
||||
return this.handleSentMessage(envelope, sentMessage);
|
||||
}
|
||||
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(
|
||||
envelope: ProcessedEnvelope,
|
||||
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;
|
||||
text?: string;
|
||||
attachments: ReadonlyArray<ProcessedQuoteAttachment>;
|
||||
bodyRanges: ReadonlyArray<Proto.DataMessage.IBodyRange>;
|
||||
bodyRanges: ReadonlyArray<ProcessedBodyRange>;
|
||||
type: Proto.DataMessage.Quote.Type;
|
||||
};
|
||||
|
||||
|
@ -219,6 +219,7 @@ export type ProcessedDataMessage = {
|
|||
preview?: ReadonlyArray<ProcessedPreview>;
|
||||
sticker?: ProcessedSticker;
|
||||
requiredProtocolVersion?: number;
|
||||
editedMessageTimestamp?: number;
|
||||
isStory?: boolean;
|
||||
isViewOnce: boolean;
|
||||
reaction?: ProcessedReaction;
|
||||
|
|
|
@ -50,7 +50,7 @@ export async function downloadAttachment(
|
|||
const data = getFirstBytes(paddedData, size);
|
||||
|
||||
return {
|
||||
...omit(attachment, 'digest', 'key'),
|
||||
...omit(attachment, 'key'),
|
||||
|
||||
size,
|
||||
contentType: contentType
|
||||
|
|
|
@ -27,6 +27,7 @@ import { ThemeType } from './Util';
|
|||
import * as GoogleChrome from '../util/GoogleChrome';
|
||||
import { ReadStatus } from '../messages/MessageReadStatus';
|
||||
import type { MessageStatusType } from '../components/conversation/Message';
|
||||
import { softAssert } from '../util/assert';
|
||||
|
||||
const MAX_WIDTH = 300;
|
||||
const MAX_HEIGHT = MAX_WIDTH * 1.5;
|
||||
|
@ -40,7 +41,9 @@ export type AttachmentType = {
|
|||
blurHash?: string;
|
||||
caption?: string;
|
||||
contentType: MIME.MIMEType;
|
||||
digest?: string;
|
||||
fileName?: string;
|
||||
uploadTimestamp?: number;
|
||||
/** Not included in protobuf, needs to be pulled from flags */
|
||||
isVoiceMessage?: boolean;
|
||||
/** For messages not already on disk, this will be a data url */
|
||||
|
@ -78,7 +81,6 @@ export type AttachmentType = {
|
|||
schemaVersion?: number;
|
||||
|
||||
/** Removed once we download the attachment */
|
||||
digest?: string;
|
||||
key?: string;
|
||||
};
|
||||
|
||||
|
@ -187,6 +189,7 @@ export async function migrateDataToFileSystem(
|
|||
const { data } = attachment;
|
||||
const attachmentHasData = !isUndefined(data);
|
||||
const shouldSkipSchemaUpgrade = !attachmentHasData;
|
||||
|
||||
if (shouldSkipSchemaUpgrade) {
|
||||
return attachment;
|
||||
}
|
||||
|
@ -1001,3 +1004,8 @@ export const canBeDownloaded = (
|
|||
): boolean => {
|
||||
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) => {
|
||||
const { attachments, quote, contact, preview, sticker } = message;
|
||||
const { attachments, editHistory, quote, contact, preview, sticker } =
|
||||
message;
|
||||
|
||||
if (attachments && attachments.length) {
|
||||
await Promise.all(attachments.map(deleteAttachmentData));
|
||||
|
@ -858,15 +859,7 @@ export const deleteAllExternalFiles = ({
|
|||
}
|
||||
|
||||
if (preview && preview.length) {
|
||||
await Promise.all(
|
||||
preview.map(async item => {
|
||||
const { image } = item;
|
||||
|
||||
if (image && image.path) {
|
||||
await deleteOnDisk(image.path);
|
||||
}
|
||||
})
|
||||
);
|
||||
await deletePreviews(preview, deleteOnDisk);
|
||||
}
|
||||
|
||||
if (sticker && sticker.data && sticker.data.path) {
|
||||
|
@ -876,9 +869,42 @@ export const deleteAllExternalFiles = ({
|
|||
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)
|
||||
// Message ->
|
||||
// IO (Promise Message)
|
||||
|
|
|
@ -1,7 +1,6 @@
|
|||
// Copyright 2021 Signal Messenger, LLC
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
import { omit } from 'lodash';
|
||||
import { blobToArrayBuffer } from 'blob-util';
|
||||
|
||||
import { scaleImageToLevel } from './scaleImageToLevel';
|
||||
|
@ -59,8 +58,7 @@ export async function autoOrientJPEG(
|
|||
// by potentially doubling stored image data.
|
||||
// See: https://github.com/signalapp/Signal-Desktop/issues/1589
|
||||
const xcodedAttachment = {
|
||||
// `digest` is no longer valid for auto-oriented image data, so we discard it:
|
||||
...omit(attachment, 'digest'),
|
||||
...attachment,
|
||||
data: new Uint8Array(xcodedDataArrayBuffer),
|
||||
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;
|
||||
}
|
||||
|
||||
const hasNormalAttachments = normalAttachments.some(attachment => {
|
||||
if (!attachment) {
|
||||
return false;
|
||||
}
|
||||
// We've already downloaded this!
|
||||
if (attachment.path) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
const hasNormalAttachments = hasNormalAttachmentDownloads(normalAttachments);
|
||||
if (hasNormalAttachments) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const previews = 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;
|
||||
});
|
||||
const hasPreviews = hasPreviewDownloads(message.preview);
|
||||
if (hasPreviews) {
|
||||
return true;
|
||||
}
|
||||
|
@ -85,5 +66,48 @@ export function hasAttachmentDownloads(
|
|||
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;
|
||||
}
|
||||
|
||||
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",
|
||||
"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",
|
||||
"path": "ts/components/ForwardMessagesModal.tsx",
|
||||
|
@ -2399,6 +2406,13 @@
|
|||
"reasonCategory": "usageTrusted",
|
||||
"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",
|
||||
"path": "ts/components/conversation/conversation-details/AddGroupMembersModal/ChooseGroupMembersModal.tsx",
|
||||
|
@ -2435,13 +2449,6 @@
|
|||
"updated": "2019-11-01T22:46:33.013Z",
|
||||
"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",
|
||||
"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,18 +34,23 @@ export async function markConversationRead(
|
|||
): Promise<boolean> {
|
||||
const { id: conversationId } = conversationAttrs;
|
||||
|
||||
const [unreadMessages, unreadReactions] = await Promise.all([
|
||||
window.Signal.Data.getUnreadByConversationAndMarkRead({
|
||||
conversationId,
|
||||
newestUnreadAt,
|
||||
readAt: options.readAt,
|
||||
includeStoryReplies: !isGroup(conversationAttrs),
|
||||
}),
|
||||
window.Signal.Data.getUnreadReactionsAndMarkRead({
|
||||
conversationId,
|
||||
newestUnreadAt,
|
||||
}),
|
||||
]);
|
||||
const [unreadMessages, unreadEditedMessages, unreadReactions] =
|
||||
await Promise.all([
|
||||
window.Signal.Data.getUnreadByConversationAndMarkRead({
|
||||
conversationId,
|
||||
newestUnreadAt,
|
||||
readAt: options.readAt,
|
||||
includeStoryReplies: !isGroup(conversationAttrs),
|
||||
}),
|
||||
window.Signal.Data.getUnreadEditedMessagesAndMarkRead({
|
||||
fromId: conversationId,
|
||||
newestUnreadAt,
|
||||
}),
|
||||
window.Signal.Data.getUnreadReactionsAndMarkRead({
|
||||
conversationId,
|
||||
newestUnreadAt,
|
||||
}),
|
||||
]);
|
||||
|
||||
log.info('markConversationRead', {
|
||||
conversationId: getConversationIdForLogging(conversationAttrs),
|
||||
|
@ -55,7 +60,11 @@ export async function markConversationRead(
|
|||
unreadReactions: unreadReactions.length,
|
||||
});
|
||||
|
||||
if (!unreadMessages.length && !unreadReactions.length) {
|
||||
if (
|
||||
!unreadMessages.length &&
|
||||
!unreadEditedMessages.length &&
|
||||
!unreadReactions.length
|
||||
) {
|
||||
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);
|
||||
// we update the in-memory MessageModel with the fresh database call data
|
||||
if (message) {
|
||||
|
|
|
@ -16,16 +16,23 @@ import dataInterface from '../sql/Client';
|
|||
import type { AttachmentType } from '../types/Attachment';
|
||||
import type { EmbeddedContactType } from '../types/EmbeddedContact';
|
||||
import type {
|
||||
EditHistoryType,
|
||||
MessageAttributesType,
|
||||
QuotedMessageType,
|
||||
} from '../model-types.d';
|
||||
import * as Errors from '../types/errors';
|
||||
import {
|
||||
getAttachmentSignature,
|
||||
isDownloading,
|
||||
isDownloaded,
|
||||
} from '../types/Attachment';
|
||||
import type { StickerType } from '../types/Stickers';
|
||||
import type { LinkPreviewType } from '../types/message/LinkPreviews';
|
||||
|
||||
type ReturnType = {
|
||||
bodyAttachment?: AttachmentType;
|
||||
attachments: Array<AttachmentType>;
|
||||
editHistory?: Array<EditHistoryType>;
|
||||
preview: Array<LinkPreviewType>;
|
||||
contact: Array<EmbeddedContactType>;
|
||||
quote?: QuotedMessageType;
|
||||
|
@ -45,8 +52,10 @@ export async function queueAttachmentDownloads(
|
|||
let count = 0;
|
||||
let bodyAttachment;
|
||||
|
||||
const idLog = `queueAttachmentDownloads(${idForLogging}})`;
|
||||
|
||||
log.info(
|
||||
`Queueing ${attachmentsToQueue.length} attachment downloads for message ${idForLogging}`
|
||||
`${idLog}: Queueing ${attachmentsToQueue.length} attachment downloads`
|
||||
);
|
||||
|
||||
const [longMessageAttachments, normalAttachments] = partition(
|
||||
|
@ -55,13 +64,11 @@ export async function queueAttachmentDownloads(
|
|||
);
|
||||
|
||||
if (longMessageAttachments.length > 1) {
|
||||
log.error(
|
||||
`Received more than one long message attachment in message ${idForLogging}`
|
||||
);
|
||||
log.error(`${idLog}: Received more than one long message attachment`);
|
||||
}
|
||||
|
||||
log.info(
|
||||
`Queueing ${longMessageAttachments.length} long message attachment downloads for message ${idForLogging}`
|
||||
`${idLog}: Queueing ${longMessageAttachments.length} long message attachment downloads`
|
||||
);
|
||||
|
||||
if (longMessageAttachments.length > 0) {
|
||||
|
@ -82,63 +89,31 @@ export async function queueAttachmentDownloads(
|
|||
}
|
||||
|
||||
log.info(
|
||||
`Queueing ${normalAttachments.length} normal attachment downloads for message ${idForLogging}`
|
||||
`${idLog}: Queueing ${normalAttachments.length} normal attachment downloads`
|
||||
);
|
||||
const attachments = await Promise.all(
|
||||
normalAttachments.map((attachment, index) => {
|
||||
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,
|
||||
type: 'attachment',
|
||||
index,
|
||||
});
|
||||
})
|
||||
const { attachments, count: attachmentsCount } = await queueNormalAttachments(
|
||||
idLog,
|
||||
messageId,
|
||||
normalAttachments,
|
||||
message.editHistory?.flatMap(x => x.attachments ?? [])
|
||||
);
|
||||
count += attachmentsCount;
|
||||
|
||||
const previewsToQueue = message.preview || [];
|
||||
log.info(
|
||||
`Queueing ${previewsToQueue.length} preview attachment downloads for message ${idForLogging}`
|
||||
`${idLog}: Queueing ${previewsToQueue.length} preview attachment downloads`
|
||||
);
|
||||
const preview = await Promise.all(
|
||||
previewsToQueue.map(async (item, index) => {
|
||||
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,
|
||||
type: 'preview',
|
||||
index,
|
||||
}),
|
||||
};
|
||||
})
|
||||
const { preview, count: previewCount } = await queuePreviews(
|
||||
idLog,
|
||||
messageId,
|
||||
previewsToQueue,
|
||||
message.editHistory?.flatMap(x => x.preview ?? [])
|
||||
);
|
||||
count += previewCount;
|
||||
|
||||
const contactsToQueue = message.contact || [];
|
||||
log.info(
|
||||
`Queueing ${contactsToQueue.length} contact attachment downloads for message ${idForLogging}`
|
||||
`${idLog}: Queueing ${contactsToQueue.length} contact attachment downloads`
|
||||
);
|
||||
const contact = await Promise.all(
|
||||
contactsToQueue.map(async (item, index) => {
|
||||
|
@ -147,9 +122,7 @@ export async function queueAttachmentDownloads(
|
|||
}
|
||||
// We've already downloaded this!
|
||||
if (item.avatar.avatar.path) {
|
||||
log.info(
|
||||
`Contact attachment already downloaded for message ${idForLogging}`
|
||||
);
|
||||
log.info(`${idLog}: Contact attachment already downloaded`);
|
||||
return item;
|
||||
}
|
||||
|
||||
|
@ -172,7 +145,7 @@ export async function queueAttachmentDownloads(
|
|||
const quoteAttachmentsToQueue =
|
||||
quote && quote.attachments ? quote.attachments : [];
|
||||
log.info(
|
||||
`Queueing ${quoteAttachmentsToQueue.length} quote attachment downloads for message ${idForLogging}`
|
||||
`${idLog}: Queueing ${quoteAttachmentsToQueue.length} quote attachment downloads`
|
||||
);
|
||||
if (quote && quoteAttachmentsToQueue.length > 0) {
|
||||
quote = {
|
||||
|
@ -184,9 +157,7 @@ export async function queueAttachmentDownloads(
|
|||
}
|
||||
// We've already downloaded this!
|
||||
if (item.thumbnail.path) {
|
||||
log.info(
|
||||
`Quote attachment already downloaded for message ${idForLogging}`
|
||||
);
|
||||
log.info(`${idLog}: Quote attachment already downloaded`);
|
||||
return item;
|
||||
}
|
||||
|
||||
|
@ -206,11 +177,9 @@ export async function queueAttachmentDownloads(
|
|||
|
||||
let { sticker } = message;
|
||||
if (sticker && sticker.data && sticker.data.path) {
|
||||
log.info(
|
||||
`Sticker attachment already downloaded for message ${idForLogging}`
|
||||
);
|
||||
log.info(`${idLog}: Sticker attachment already downloaded`);
|
||||
} else if (sticker) {
|
||||
log.info(`Queueing sticker download for message ${idForLogging}`);
|
||||
log.info(`${idLog}: Queueing sticker download`);
|
||||
count += 1;
|
||||
const { packId, stickerId, packKey } = sticker;
|
||||
|
||||
|
@ -222,7 +191,7 @@ export async function queueAttachmentDownloads(
|
|||
data = await copyStickerToAttachments(packId, stickerId);
|
||||
} catch (error) {
|
||||
log.error(
|
||||
`Problem copying sticker (${packId}, ${stickerId}) to attachments:`,
|
||||
`${idLog}: Problem copying sticker (${packId}, ${stickerId}) to attachments:`,
|
||||
Errors.toLogFormat(error)
|
||||
);
|
||||
}
|
||||
|
@ -252,20 +221,197 @@ export async function queueAttachmentDownloads(
|
|||
};
|
||||
}
|
||||
|
||||
log.info(
|
||||
`Queued ${count} total attachment downloads for message ${idForLogging}`
|
||||
);
|
||||
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(
|
||||
`${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) {
|
||||
return;
|
||||
}
|
||||
|
||||
return {
|
||||
bodyAttachment,
|
||||
attachments,
|
||||
preview,
|
||||
bodyAttachment,
|
||||
contact,
|
||||
editHistory,
|
||||
preview,
|
||||
quote,
|
||||
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"
|
||||
uuid "^8.3.0"
|
||||
|
||||
"@signalapp/mock-server@2.15.0":
|
||||
version "2.15.0"
|
||||
resolved "https://registry.yarnpkg.com/@signalapp/mock-server/-/mock-server-2.15.0.tgz#de86ddc4c3f7cbe1e91941832c4b317946e90364"
|
||||
integrity sha512-bxu4hpnEAAvDT7Yg2LZQNIL/9ciNrGG0hPJlj+dT2iwsHo2AAP8Ej4sLfAiy0O2kYbf2bKcvfTE9C+XwkdAW+w==
|
||||
"@signalapp/mock-server@2.17.0":
|
||||
version "2.17.0"
|
||||
resolved "https://registry.yarnpkg.com/@signalapp/mock-server/-/mock-server-2.17.0.tgz#070eb1e0ea33f5947450ac54fe86ade78e589447"
|
||||
integrity sha512-qhvhRvvWAlpR2lCGKLJWUSf5fpx//ZljwxqoQy1FZVnRYy6kEPsEUiO3oNE5Y+7HqFlMgD0Yt2OJbamvbQKngg==
|
||||
dependencies:
|
||||
"@signalapp/libsignal-client" "^0.22.0"
|
||||
debug "^4.3.2"
|
||||
|
|
Loading…
Reference in a new issue