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

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