Receive support for editing messages

This commit is contained in:
Josh Perez 2023-03-27 19:48:57 -04:00 committed by GitHub
parent 2781e621ad
commit 36e21c0134
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
46 changed files with 2053 additions and 405 deletions

View file

@ -6463,6 +6463,14 @@
"messageformat": "Signal desktop no longer works on this computer. To use Signal desktop again, update your computers 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"

View file

@ -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",

View file

@ -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;
}

View file

@ -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;

View file

@ -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;

View 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>
);
}

View file

@ -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();
}

View file

@ -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}

View file

@ -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

View file

@ -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],

View 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));
}
}

View file

@ -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,

View file

@ -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({

View file

@ -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
View file

@ -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;

View file

@ -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> {

View file

@ -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.`

View file

@ -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;

View file

@ -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;

View file

@ -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>

View file

@ -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,
};
});
})();
}

View 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!');
}

View file

@ -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 {

View file

@ -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) {

View file

@ -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',

View file

@ -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;
}

View file

@ -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,

View 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}
/>
);
}

View file

@ -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}

View file

@ -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}

View 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');
});
});

View file

@ -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

View file

@ -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;

View file

@ -50,7 +50,7 @@ export async function downloadAttachment(
const data = getFirstBytes(paddedData, size);
return {
...omit(attachment, 'digest', 'key'),
...omit(attachment, 'key'),
size,
contentType: contentType

View file

@ -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);
}

View file

@ -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)

View file

@ -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,
};

View 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;
}

View 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);
}
}

View file

@ -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;
});
}

View file

@ -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
View 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 [];
}

View file

@ -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) {

View file

@ -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,
};
}

View 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;
}

View file

@ -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"