// Copyright 2020 Signal Messenger, LLC // SPDX-License-Identifier: AGPL-3.0-only import { last, sortBy } from 'lodash'; import * as log from '../logging/log'; import { isAciString } from './isAciString'; import { isGroup, isGroupV2 } from './whatTypeOfConversation'; import { getConversationIdForLogging, getMessageIdForLogging, } from './idForLogging'; import { missingCaseError } from './missingCaseError'; import { getMessageSentTimestampSet } from './getMessageSentTimestampSet'; import { getAuthor } from '../messages/helpers'; import dataInterface, { deleteAndCleanup } from '../sql/Client'; import type { ConversationAttributesType, MessageAttributesType, } from '../model-types'; import type { ConversationModel } from '../models/conversations'; import type { ConversationToDelete, MessageToDelete, } from '../textsecure/messageReceiverEvents'; import type { AciString } from '../types/ServiceId'; const { getMessagesBySentAt, getMostRecentAddressableMessages, removeMessagesInConversation, } = dataInterface; export function doesMessageMatch({ conversationId, message, query, sentTimestamps, }: { message: MessageAttributesType; conversationId: string; query: MessageQuery; sentTimestamps: ReadonlySet; }): boolean { const author = getAuthor(message); const conversationMatches = message.conversationId === conversationId; const aciMatches = query.authorAci && author?.attributes.serviceId === query.authorAci; const e164Matches = query.authorE164 && author?.attributes.e164 === query.authorE164; const timestampMatches = sentTimestamps.has(query.sentAt); return Boolean( conversationMatches && timestampMatches && (aciMatches || e164Matches) ); } export async function findMatchingMessage( conversationId: string, query: MessageQuery ): Promise { const sentAtMatches = await getMessagesBySentAt(query.sentAt); if (!sentAtMatches.length) { return undefined; } return sentAtMatches.find(message => { const sentTimestamps = getMessageSentTimestampSet(message); return doesMessageMatch({ conversationId, message, query, sentTimestamps, }); }); } export async function deleteMessage( conversationId: string, targetMessage: MessageToDelete, logId: string ): Promise { const query = getMessageQueryFromTarget(targetMessage); const found = await findMatchingMessage(conversationId, query); if (!found) { log.warn(`${logId}: Couldn't find matching message`); return false; } await deleteAndCleanup([found], logId); return true; } export async function deleteConversation( conversation: ConversationModel, mostRecentMessages: Array, isFullDelete: boolean, logId: string ): Promise { const queries = mostRecentMessages.map(getMessageQueryFromTarget); const found = await Promise.all( queries.map(query => findMatchingMessage(conversation.id, query)) ); const sorted = sortBy(found, 'received_at'); const newestMessage = last(sorted); if (newestMessage) { const { received_at: receivedAt } = newestMessage; await removeMessagesInConversation(conversation.id, { receivedAt, logId: `${logId}(receivedAt=${receivedAt})`, }); } if (!newestMessage) { log.warn(`${logId}: Found no target messages for delete`); } if (isFullDelete) { log.info(`${logId}: isFullDelete=true, proceeding to local-only delete`); return deleteLocalOnlyConversation(conversation, logId); } return true; } export async function deleteLocalOnlyConversation( conversation: ConversationModel, logId: string ): Promise { const limit = 1; const messages = await getMostRecentAddressableMessages( conversation.id, limit ); if (messages.length > 0) { log.warn( `${logId}: Attempted local-only delete but found an addressable message` ); return false; } // This will delete all messages and remove the conversation from the left pane. // We need to call destroyMessagesInner, since we're already in conversation.queueJob() await conversation.destroyMessagesInner({ logId, source: 'local-delete-sync', }); return true; } export function getConversationFromTarget( targetConversation: ConversationToDelete ): ConversationModel | undefined { const { type } = targetConversation; if (type === 'aci') { return window.ConversationController.get(targetConversation.aci); } if (type === 'group') { return window.ConversationController.get(targetConversation.groupId); } if (type === 'e164') { return window.ConversationController.get(targetConversation.e164); } throw missingCaseError(type); } type MessageQuery = { sentAt: number; authorAci?: AciString; authorE164?: string; }; export function getMessageQueryFromTarget( targetMessage: MessageToDelete ): MessageQuery { const { type, sentAt } = targetMessage; if (type === 'aci') { if (!isAciString(targetMessage.authorAci)) { throw new Error('Provided authorAci was not an ACI!'); } return { sentAt, authorAci: targetMessage.authorAci }; } if (type === 'e164') { return { sentAt, authorE164: targetMessage.authorE164 }; } throw missingCaseError(type); } export function getConversationToDelete( attributes: ConversationAttributesType ): ConversationToDelete { const { groupId, serviceId: aci, e164 } = attributes; const idForLogging = getConversationIdForLogging(attributes); const logId = `getConversationToDelete(${idForLogging})`; if (isGroupV2(attributes) && groupId) { return { type: 'group', groupId, }; } if (isGroup(attributes)) { throw new Error(`${logId}: is a group, but not groupV2 or no groupId!`); } if (aci && isAciString(aci)) { return { type: 'aci', aci, }; } if (e164) { return { type: 'e164', e164, }; } throw new Error(`${logId}: No valid identifier found!`); } export function getMessageToDelete( attributes: MessageAttributesType ): MessageToDelete | undefined { const logId = `getMessageToDelete(${getMessageIdForLogging(attributes)})`; const { sent_at: sentAt } = attributes; const author = getAuthor(attributes); const authorAci = author?.get('serviceId'); const authorE164 = author?.get('e164'); if (authorAci && isAciString(authorAci)) { return { type: 'aci' as const, authorAci, sentAt, }; } if (authorE164) { return { type: 'e164' as const, authorE164, sentAt, }; } log.warn(`${logId}: Message was missing source ACI/e164`); return undefined; }