mirror of
https://github.com/signalapp/Signal-Desktop.git
synced 2025-12-20 10:19:08 +00:00
231 lines
7.0 KiB
TypeScript
231 lines
7.0 KiB
TypeScript
// Copyright 2023 Signal Messenger, LLC
|
|
// SPDX-License-Identifier: AGPL-3.0-only
|
|
|
|
import { v4 as generateUuid } from 'uuid';
|
|
|
|
import type { DraftBodyRanges } from '../types/BodyRange.std.js';
|
|
import type { LinkPreviewType } from '../types/message/LinkPreviews.std.js';
|
|
import type {
|
|
MessageAttributesType,
|
|
QuotedMessageType,
|
|
} from '../model-types.d.ts';
|
|
import { createLogger } from '../logging/log.std.js';
|
|
import { DataReader, DataWriter } from '../sql/Client.preload.js';
|
|
import { ErrorWithToast } from '../types/ErrorWithToast.std.js';
|
|
import { SendStatus } from '../messages/MessageSendState.std.js';
|
|
import { ToastType } from '../types/Toast.dom.js';
|
|
import type { AciString } from '../types/ServiceId.std.js';
|
|
import { canEditMessage, isWithinMaxEdits } from './canEditMessage.dom.js';
|
|
import {
|
|
conversationJobQueue,
|
|
conversationQueueJobEnum,
|
|
} from '../jobs/conversationJobQueue.preload.js';
|
|
import {
|
|
concat,
|
|
filter,
|
|
map,
|
|
repeat,
|
|
zipObject,
|
|
find,
|
|
} from './iterables.std.js';
|
|
import { getConversationIdForLogging } from './idForLogging.preload.js';
|
|
import { isQuoteAMatch } from '../messages/quotes.preload.js';
|
|
import { getMessageById } from '../messages/getMessageById.preload.js';
|
|
import { handleEditMessage } from './handleEditMessage.preload.js';
|
|
import { incrementMessageCounter } from './incrementMessageCounter.preload.js';
|
|
import { isGroupV1 } from './whatTypeOfConversation.dom.js';
|
|
import { isNotNil } from './isNotNil.std.js';
|
|
import { isSignalConversation } from './isSignalConversation.dom.js';
|
|
import { strictAssert } from './assert.std.js';
|
|
import { timeAndLogIfTooLong } from './timeAndLogIfTooLong.std.js';
|
|
import { makeQuote } from './makeQuote.preload.js';
|
|
import { getMessageSentTimestamp } from './getMessageSentTimestamp.std.js';
|
|
import { itemStorage } from '../textsecure/Storage.preload.js';
|
|
|
|
const log = createLogger('sendEditedMessage');
|
|
|
|
const SEND_REPORT_THRESHOLD_MS = 25;
|
|
|
|
export async function sendEditedMessage(
|
|
conversationId: string,
|
|
{
|
|
body,
|
|
bodyRanges,
|
|
preview,
|
|
quoteSentAt,
|
|
quoteAuthorAci,
|
|
targetMessageId,
|
|
}: {
|
|
body?: string;
|
|
bodyRanges?: DraftBodyRanges;
|
|
preview: Array<LinkPreviewType>;
|
|
quoteSentAt?: number;
|
|
quoteAuthorAci?: AciString;
|
|
targetMessageId: string;
|
|
}
|
|
): Promise<void> {
|
|
const conversation = window.ConversationController.get(conversationId);
|
|
strictAssert(conversation, 'no conversation found');
|
|
|
|
const idLog = `sendEditedMessage(${getConversationIdForLogging(
|
|
conversation.attributes
|
|
)})`;
|
|
|
|
const targetMessage = await getMessageById(targetMessageId);
|
|
strictAssert(targetMessage, 'could not find message to edit');
|
|
|
|
if (isGroupV1(conversation.attributes)) {
|
|
log.warn(`${idLog}: can't send to gv1`);
|
|
return;
|
|
}
|
|
|
|
if (isSignalConversation(conversation.attributes)) {
|
|
log.warn(`${idLog}: can't send to Signal`);
|
|
return;
|
|
}
|
|
|
|
if (
|
|
!canEditMessage(targetMessage.attributes) ||
|
|
!isWithinMaxEdits(targetMessage.attributes)
|
|
) {
|
|
throw new ErrorWithToast(
|
|
`${idLog}: cannot edit`,
|
|
ToastType.CannotEditMessage
|
|
);
|
|
}
|
|
|
|
const timestamp = Date.now();
|
|
const targetSentTimestamp = getMessageSentTimestamp(
|
|
targetMessage.attributes,
|
|
{
|
|
log,
|
|
}
|
|
);
|
|
|
|
log.info(`${idLog}: edited(${timestamp}) original(${targetSentTimestamp})`);
|
|
|
|
conversation.clearTypingTimers();
|
|
|
|
let quote: QuotedMessageType | undefined;
|
|
if (quoteSentAt !== undefined && quoteAuthorAci !== undefined) {
|
|
const existingQuote = targetMessage.get('quote');
|
|
|
|
// Keep the quote if unchanged.
|
|
if (quoteSentAt === existingQuote?.id) {
|
|
quote = existingQuote;
|
|
} else {
|
|
const messages = await DataReader.getMessagesBySentAt(quoteSentAt);
|
|
const matchingMessage = find(messages, item =>
|
|
isQuoteAMatch(item, conversationId, {
|
|
id: quoteSentAt,
|
|
authorAci: quoteAuthorAci,
|
|
})
|
|
);
|
|
|
|
if (matchingMessage) {
|
|
quote = await makeQuote(matchingMessage);
|
|
}
|
|
}
|
|
}
|
|
|
|
const ourConversation =
|
|
window.ConversationController.getOurConversationOrThrow();
|
|
const fromId = ourConversation.id;
|
|
|
|
// Create the send state for later use
|
|
const recipientMaybeConversations = map(
|
|
conversation.getRecipients(),
|
|
identifier => window.ConversationController.get(identifier)
|
|
);
|
|
const recipientConversations = filter(recipientMaybeConversations, isNotNil);
|
|
const recipientConversationIds = concat(
|
|
map(recipientConversations, c => c.id),
|
|
[fromId]
|
|
);
|
|
const sendStateByConversationId = zipObject(
|
|
recipientConversationIds,
|
|
repeat({
|
|
status: SendStatus.Pending,
|
|
updatedAt: timestamp,
|
|
})
|
|
);
|
|
|
|
const originalAttachments = targetMessage.get('attachments');
|
|
let previewToSend: Array<LinkPreviewType> | undefined = preview;
|
|
if (originalAttachments?.length && preview.length) {
|
|
log.error('Cannot send message with both attachments and preview');
|
|
previewToSend = undefined;
|
|
}
|
|
|
|
// An ephemeral message that we just use to handle the edit
|
|
const tmpMessage: MessageAttributesType = {
|
|
attachments: originalAttachments,
|
|
body,
|
|
bodyRanges,
|
|
conversationId,
|
|
preview: previewToSend,
|
|
id: generateUuid(),
|
|
quote,
|
|
received_at: incrementMessageCounter(),
|
|
received_at_ms: timestamp,
|
|
sendStateByConversationId,
|
|
sent_at: timestamp,
|
|
timestamp,
|
|
type: 'outgoing',
|
|
};
|
|
|
|
// Takes care of putting the message in the edit history, replacing the
|
|
// main message's values, and updating the conversation's properties.
|
|
await handleEditMessage(targetMessage.attributes, {
|
|
conversationId,
|
|
fromId,
|
|
fromDevice: itemStorage.user.getDeviceId() ?? 1,
|
|
message: tmpMessage,
|
|
});
|
|
|
|
// Reset send state prior to send
|
|
targetMessage.set({ sendStateByConversationId });
|
|
|
|
// Inserting the send into a job and saving it to the message
|
|
await timeAndLogIfTooLong(
|
|
SEND_REPORT_THRESHOLD_MS,
|
|
() =>
|
|
conversationJobQueue.add(
|
|
{
|
|
type: conversationQueueJobEnum.enum.NormalMessage,
|
|
conversationId,
|
|
messageId: targetMessageId,
|
|
revision: conversation.get('revision'),
|
|
editedMessageTimestamp: timestamp,
|
|
},
|
|
async jobToInsert => {
|
|
log.info(
|
|
`${idLog}: saving message ${targetMessageId} and job ${jobToInsert.id}`
|
|
);
|
|
await window.MessageCache.saveMessage(targetMessage.attributes, {
|
|
jobToInsert,
|
|
});
|
|
}
|
|
),
|
|
duration => `${idLog}: db save took ${duration}ms`
|
|
);
|
|
|
|
// Does the same render dance that models/conversations does when we call
|
|
// enqueueMessageForSend. Calls redux actions, clears drafts, unarchives, and
|
|
// updates storage service if needed.
|
|
await timeAndLogIfTooLong(
|
|
SEND_REPORT_THRESHOLD_MS,
|
|
async () => {
|
|
conversation.beforeMessageSend({
|
|
message: targetMessage.attributes,
|
|
dontClearDraft: false,
|
|
dontAddMessage: true,
|
|
now: timestamp,
|
|
});
|
|
},
|
|
duration => `${idLog}: batchDispatch took ${duration}ms`
|
|
);
|
|
|
|
await DataWriter.updateConversation(conversation.attributes);
|
|
}
|