// Copyright 2016 Signal Messenger, LLC // SPDX-License-Identifier: AGPL-3.0-only import { isEqual } from 'lodash'; import type { MessageModel } from '../models/messages'; import type { MessageAttributesType } from '../model-types.d'; import type { SendStateByConversationId } from '../messages/MessageSendState'; import { isOutgoing, isStory } from '../state/selectors/message'; import { getOwn } from '../util/getOwn'; import { missingCaseError } from '../util/missingCaseError'; import { createWaitBatcher } from '../util/waitBatcher'; import type { ServiceIdString } from '../types/ServiceId'; import * as Errors from '../types/errors'; import { SendActionType, SendStatus, sendStateReducer, } from '../messages/MessageSendState'; import type { DeleteSentProtoRecipientOptionsType } from '../sql/Interface'; import dataInterface from '../sql/Client'; import * as log from '../logging/log'; import { getSourceServiceId } from '../messages/helpers'; import { queueUpdateMessage } from '../util/messageBatcher'; import { getMessageSentTimestamp } from '../util/getMessageSentTimestamp'; import { getMessageIdForLogging } from '../util/idForLogging'; const { deleteSentProtoRecipient } = dataInterface; export enum MessageReceiptType { Delivery = 'Delivery', Read = 'Read', View = 'View', } export type MessageReceiptAttributesType = { envelopeId: string; messageSentAt: number; receiptTimestamp: number; removeFromMessageReceiverCache: () => unknown; sourceConversationId: string; sourceDevice: number; sourceServiceId: ServiceIdString; type: MessageReceiptType; wasSentEncrypted: boolean; }; const receipts = new Map(); const deleteSentProtoBatcher = createWaitBatcher({ name: 'deleteSentProtoBatcher', wait: 250, maxSize: 30, async processBatch(items: Array) { log.info( `MessageReceipts: Batching ${items.length} sent proto recipients deletes` ); const { successfulPhoneNumberShares } = await deleteSentProtoRecipient( items ); for (const serviceId of successfulPhoneNumberShares) { const convo = window.ConversationController.get(serviceId); if (!convo) { continue; } log.info( 'MessageReceipts: unsetting shareMyPhoneNumber ' + `for ${convo.idForLogging()}` ); // `deleteSentProtoRecipient` has already updated the database so there // is no need in calling `updateConversation` convo.unset('shareMyPhoneNumber'); } }, }); function remove(receipt: MessageReceiptAttributesType): void { receipts.delete(receipt.envelopeId); receipt.removeFromMessageReceiverCache(); } async function getTargetMessage( sourceId: string, serviceId: ServiceIdString, messages: ReadonlyArray ): Promise { if (messages.length === 0) { return null; } const message = messages.find( item => (isOutgoing(item) || isStory(item)) && sourceId === item.conversationId ); if (message) { return window.MessageCache.__DEPRECATED$register( message.id, message, 'MessageReceipts.getTargetMessage 1' ); } const groups = await window.Signal.Data.getAllGroupsInvolvingServiceId( serviceId ); const ids = groups.map(item => item.id); ids.push(sourceId); const target = messages.find( item => (isOutgoing(item) || isStory(item)) && ids.includes(item.conversationId) ); if (!target) { return null; } return window.MessageCache.__DEPRECATED$register( target.id, target, 'MessageReceipts.getTargetMessage 2' ); } const wasDeliveredWithSealedSender = ( conversationId: string, message: MessageModel ): boolean => (message.get('unidentifiedDeliveries') || []).some( identifier => window.ConversationController.getConversationId(identifier) === conversationId ); const shouldDropReceipt = ( receipt: MessageReceiptAttributesType, message: MessageModel ): boolean => { const { type } = receipt; switch (type) { case MessageReceiptType.Delivery: return false; case MessageReceiptType.Read: return !window.storage.get('read-receipt-setting'); case MessageReceiptType.View: if (isStory(message.attributes)) { return !window.Events.getStoryViewReceiptsEnabled(); } return !window.storage.get('read-receipt-setting'); default: throw missingCaseError(type); } }; export function forMessage( message: MessageModel ): Array { if (!isOutgoing(message.attributes) && !isStory(message.attributes)) { return []; } const logId = `MessageReceipts.forMessage(${getMessageIdForLogging( message.attributes )})`; const ourAci = window.textsecure.storage.user.getCheckedAci(); const sourceServiceId = getSourceServiceId(message.attributes); if (ourAci !== sourceServiceId) { return []; } const receiptValues = Array.from(receipts.values()); const sentAt = getMessageSentTimestamp(message.attributes, { log }); const result = receiptValues.filter(item => item.messageSentAt === sentAt); if (result.length > 0) { log.info(`${logId}: found early receipts for message ${sentAt}`); result.forEach(receipt => { remove(receipt); }); } return result.filter(receipt => { if (shouldDropReceipt(receipt, message)) { log.info( `${logId}: Dropping an early receipt ${receipt.type} for message ${sentAt}` ); return false; } return true; }); } function getNewSendStateByConversationId( oldSendStateByConversationId: SendStateByConversationId, receipt: MessageReceiptAttributesType ): SendStateByConversationId { const { receiptTimestamp, sourceConversationId, type } = receipt; const oldSendState = getOwn( oldSendStateByConversationId, sourceConversationId ) ?? { status: SendStatus.Sent, updatedAt: undefined }; let sendActionType: SendActionType; switch (type) { case MessageReceiptType.Delivery: sendActionType = SendActionType.GotDeliveryReceipt; break; case MessageReceiptType.Read: sendActionType = SendActionType.GotReadReceipt; break; case MessageReceiptType.View: sendActionType = SendActionType.GotViewedReceipt; break; default: throw missingCaseError(type); } const newSendState = sendStateReducer(oldSendState, { type: sendActionType, updatedAt: receiptTimestamp, }); return { ...oldSendStateByConversationId, [sourceConversationId]: newSendState, }; } async function updateMessageSendState( receipt: MessageReceiptAttributesType, message: MessageModel ): Promise { const { messageSentAt } = receipt; const logId = `MessageReceipts.updateMessageSendState(sentAt=${receipt.messageSentAt})`; if (shouldDropReceipt(receipt, message)) { log.info( `${logId}: Dropping a receipt ${receipt.type} for message ${messageSentAt}` ); return; } let hasChanges = false; const editHistory = message.get('editHistory') ?? []; const newEditHistory = editHistory?.map(edit => { if (messageSentAt !== edit.timestamp) { return edit; } const oldSendStateByConversationId = edit.sendStateByConversationId ?? {}; const newSendStateByConversationId = getNewSendStateByConversationId( oldSendStateByConversationId, receipt ); return { ...edit, sendStateByConversationId: newSendStateByConversationId, }; }); if (!isEqual(newEditHistory, editHistory)) { message.set('editHistory', newEditHistory); hasChanges = true; } const editMessageTimestamp = message.get('editMessageTimestamp'); if ( messageSentAt === message.get('timestamp') || messageSentAt === editMessageTimestamp ) { const oldSendStateByConversationId = message.get('sendStateByConversationId') ?? {}; const newSendStateByConversationId = getNewSendStateByConversationId( oldSendStateByConversationId, receipt ); // The send state may not change. For example, this can happen if we get a read // receipt before a delivery receipt. if (!isEqual(oldSendStateByConversationId, newSendStateByConversationId)) { message.set('sendStateByConversationId', newSendStateByConversationId); hasChanges = true; } } if (hasChanges) { queueUpdateMessage(message.attributes); // notify frontend listeners const conversation = window.ConversationController.get( message.get('conversationId') ); const updateLeftPane = conversation ? conversation.debouncedUpdateLastMessage : undefined; if (updateLeftPane) { updateLeftPane(); } } const { sourceConversationId, type } = receipt; if ( (type === MessageReceiptType.Delivery && wasDeliveredWithSealedSender(sourceConversationId, message) && receipt.wasSentEncrypted) || type === MessageReceiptType.Read ) { const recipient = window.ConversationController.get(sourceConversationId); const recipientServiceId = recipient?.getServiceId(); const deviceId = receipt.sourceDevice; if (recipientServiceId && deviceId) { await Promise.all([ deleteSentProtoBatcher.add({ timestamp: messageSentAt, recipientServiceId, deviceId, }), // We want the above call to not be delayed when testing with // CI. window.SignalCI ? deleteSentProtoBatcher.flushAndWait() : Promise.resolve(), ]); } else { log.warn( `${logId}: Missing serviceId or deviceId for deliveredTo ${sourceConversationId}` ); } } } export async function onReceipt( receipt: MessageReceiptAttributesType ): Promise { receipts.set(receipt.envelopeId, receipt); const { messageSentAt, sourceConversationId, sourceServiceId, type } = receipt; const logId = `MessageReceipts.onReceipt(sentAt=${receipt.messageSentAt})`; try { const messages = await window.Signal.Data.getMessagesBySentAt( messageSentAt ); const message = await getTargetMessage( sourceConversationId, sourceServiceId, messages ); if (message) { await updateMessageSendState(receipt, message); } else { // We didn't find any messages but maybe it's a story sent message const targetMessages = messages.filter( item => item.storyDistributionListId && item.sendStateByConversationId && !item.deletedForEveryone && Boolean(item.sendStateByConversationId[sourceConversationId]) ); // Nope, no target message was found if (!targetMessages.length) { log.info( `${logId}: No message for receipt`, type, sourceConversationId, sourceServiceId ); return; } await Promise.all( targetMessages.map(msg => { const model = window.MessageCache.__DEPRECATED$register( msg.id, msg, 'MessageReceipts.onReceipt' ); return updateMessageSendState(receipt, model); }) ); } remove(receipt); } catch (error) { remove(receipt); log.error(`${logId} error:`, Errors.toLogFormat(error)); } }