From 863ae9ed839b5e1047d0e4050bbb7eab21d96485 Mon Sep 17 00:00:00 2001 From: Evan Hahn <69474926+EvanHahn-Signal@users.noreply.github.com> Date: Tue, 20 Jul 2021 15:17:25 -0500 Subject: [PATCH] Combine DeliveryReceipts and MessageReceipts modifiers --- ts/background.ts | 64 ++++--- ts/messageModifiers/DeliveryReceipts.ts | 186 ------------------- ts/messageModifiers/MessageReceipts.ts | 226 ++++++++++++++++++++++++ ts/messageModifiers/ReadReceipts.ts | 177 ------------------- ts/models/messages.ts | 53 +++--- 5 files changed, 290 insertions(+), 416 deletions(-) delete mode 100644 ts/messageModifiers/DeliveryReceipts.ts create mode 100644 ts/messageModifiers/MessageReceipts.ts delete mode 100644 ts/messageModifiers/ReadReceipts.ts diff --git a/ts/background.ts b/ts/background.ts index cce6c9713..229783a6c 100644 --- a/ts/background.ts +++ b/ts/background.ts @@ -69,10 +69,12 @@ import { AppViewType } from './state/ducks/app'; import { isIncoming } from './state/selectors/message'; import { actionCreators } from './state/actions'; import { Deletes } from './messageModifiers/Deletes'; -import { DeliveryReceipts } from './messageModifiers/DeliveryReceipts'; +import { + MessageReceipts, + MessageReceiptType, +} from './messageModifiers/MessageReceipts'; import { MessageRequests } from './messageModifiers/MessageRequests'; import { Reactions } from './messageModifiers/Reactions'; -import { ReadReceipts } from './messageModifiers/ReadReceipts'; import { ReadSyncs } from './messageModifiers/ReadSyncs'; import { ViewSyncs } from './messageModifiers/ViewSyncs'; import { @@ -3726,38 +3728,40 @@ export async function startApp(): Promise { sourceUuid, sourceDevice, } = ev.read; - const readAt = envelopeTimestamp; - const reader = window.ConversationController.ensureContactIds({ - e164: source, - uuid: sourceUuid, - highTrust: true, - }); + const sourceConversationId = window.ConversationController.ensureContactIds( + { + e164: source, + uuid: sourceUuid, + highTrust: true, + } + ); window.log.info( 'read receipt', source, sourceUuid, sourceDevice, envelopeTimestamp, - reader, + sourceConversationId, 'for sent message', timestamp ); ev.confirm(); - if (!window.storage.get('read-receipt-setting') || !reader) { + if (!window.storage.get('read-receipt-setting') || !sourceConversationId) { return; } - const receipt = ReadReceipts.getSingleton().add({ - reader, - readerDevice: sourceDevice, - timestamp, - readAt, + const receipt = MessageReceipts.getSingleton().add({ + messageSentAt: timestamp, + receiptTimestamp: envelopeTimestamp, + sourceConversationId, + sourceDevice, + type: MessageReceiptType.Read, }); // Note: We do not wait for completion here - ReadReceipts.getSingleton().onReceipt(receipt); + MessageReceipts.getSingleton().onReceipt(receipt); } function onReadSync(ev: ReadSyncEvent) { @@ -3875,36 +3879,40 @@ export async function startApp(): Promise { ev.confirm(); - const deliveredTo = window.ConversationController.ensureContactIds({ - e164: source, - uuid: sourceUuid, - highTrust: true, - }); + const sourceConversationId = window.ConversationController.ensureContactIds( + { + e164: source, + uuid: sourceUuid, + highTrust: true, + } + ); window.log.info( 'delivery receipt from', source, sourceUuid, sourceDevice, - deliveredTo, + sourceConversationId, envelopeTimestamp, 'for sent message', timestamp ); - if (!deliveredTo) { + if (!sourceConversationId) { window.log.info('no conversation for', source, sourceUuid); return; } - const receipt = DeliveryReceipts.getSingleton().add({ - timestamp, - deliveredTo, - deliveredToDevice: sourceDevice, + const receipt = MessageReceipts.getSingleton().add({ + messageSentAt: timestamp, + receiptTimestamp: envelopeTimestamp, + sourceConversationId, + sourceDevice, + type: MessageReceiptType.Delivery, }); // Note: We don't wait for completion here - DeliveryReceipts.getSingleton().onReceipt(receipt); + MessageReceipts.getSingleton().onReceipt(receipt); } } diff --git a/ts/messageModifiers/DeliveryReceipts.ts b/ts/messageModifiers/DeliveryReceipts.ts deleted file mode 100644 index 22a561659..000000000 --- a/ts/messageModifiers/DeliveryReceipts.ts +++ /dev/null @@ -1,186 +0,0 @@ -// Copyright 2016-2021 Signal Messenger, LLC -// SPDX-License-Identifier: AGPL-3.0-only - -/* eslint-disable max-classes-per-file */ - -import { isEqual } from 'lodash'; -import { Collection, Model } from 'backbone'; - -import { ConversationModel } from '../models/conversations'; -import { MessageModel } from '../models/messages'; -import { MessageModelCollectionType } from '../model-types.d'; -import { isIncoming } from '../state/selectors/message'; -import { isDirectConversation } from '../util/whatTypeOfConversation'; -import { getOwn } from '../util/getOwn'; -import { SendActionType, sendStateReducer } from '../messages/MessageSendState'; -import dataInterface from '../sql/Client'; - -const { deleteSentProtoRecipient } = dataInterface; - -type DeliveryReceiptAttributesType = { - timestamp: number; - deliveredTo: string; - deliveredToDevice: number; -}; - -class DeliveryReceiptModel extends Model {} - -let singleton: DeliveryReceipts | undefined; - -async function getTargetMessage( - sourceId: string, - messages: MessageModelCollectionType -): Promise { - if (messages.length === 0) { - return null; - } - const message = messages.find( - item => - !isIncoming(item.attributes) && sourceId === item.get('conversationId') - ); - if (message) { - return window.MessageController.register(message.id, message); - } - - const groups = await window.Signal.Data.getAllGroupsInvolvingId(sourceId, { - ConversationCollection: window.Whisper.ConversationCollection, - }); - - const ids = groups.pluck('id'); - ids.push(sourceId); - - const target = messages.find( - item => - !isIncoming(item.attributes) && ids.includes(item.get('conversationId')) - ); - if (!target) { - return null; - } - - return window.MessageController.register(target.id, target); -} - -export class DeliveryReceipts extends Collection { - static getSingleton(): DeliveryReceipts { - if (!singleton) { - singleton = new DeliveryReceipts(); - } - - return singleton; - } - - forMessage( - conversation: ConversationModel, - message: MessageModel - ): Array { - let recipients: Array; - if (isDirectConversation(conversation.attributes)) { - recipients = [conversation.id]; - } else { - recipients = conversation.getMemberIds(); - } - const receipts = this.filter( - receipt => - receipt.get('timestamp') === message.get('sent_at') && - recipients.indexOf(receipt.get('deliveredTo')) > -1 - ); - this.remove(receipts); - return receipts; - } - - async onReceipt(receipt: DeliveryReceiptModel): Promise { - const timestamp = receipt.get('timestamp'); - const deliveredTo = receipt.get('deliveredTo'); - - try { - const messages = await window.Signal.Data.getMessagesBySentAt(timestamp, { - MessageCollection: window.Whisper.MessageCollection, - }); - - const message = await getTargetMessage(deliveredTo, messages); - if (!message) { - window.log.info( - 'No message for delivery receipt', - deliveredTo, - timestamp - ); - return; - } - - const oldSendStateByConversationId = - message.get('sendStateByConversationId') || {}; - const oldSendState = getOwn(oldSendStateByConversationId, deliveredTo); - if (oldSendState) { - const newSendState = sendStateReducer(oldSendState, { - type: SendActionType.GotDeliveryReceipt, - updatedAt: timestamp, - }); - - // The send state may not change. This can happen if the message was marked read - // before we got the delivery receipt, or if we got double delivery receipts, or - // things like that. - if (!isEqual(oldSendState, newSendState)) { - message.set('sendStateByConversationId', { - ...oldSendStateByConversationId, - [deliveredTo]: newSendState, - }); - - window.Signal.Util.queueUpdateMessage(message.attributes); - - // notify frontend listeners - const conversation = window.ConversationController.get( - message.get('conversationId') - ); - const updateLeftPane = conversation - ? conversation.debouncedUpdateLastMessage - : undefined; - if (updateLeftPane) { - updateLeftPane(); - } - } - } else { - window.log.warn( - `Got a delivery receipt from someone (${deliveredTo}), but the message (sent at ${message.get( - 'sent_at' - )}) wasn't sent to them. It was sent to ${ - Object.keys(oldSendStateByConversationId).length - } recipients` - ); - } - - const unidentifiedLookup = ( - message.get('unidentifiedDeliveries') || [] - ).reduce((accumulator: Record, identifier: string) => { - const id = window.ConversationController.getConversationId(identifier); - if (id) { - accumulator[id] = true; - } - return accumulator; - }, Object.create(null) as Record); - const recipient = window.ConversationController.get(deliveredTo); - if (recipient && unidentifiedLookup[recipient.id]) { - const recipientUuid = recipient?.get('uuid'); - const deviceId = receipt.get('deliveredToDevice'); - - if (recipientUuid && deviceId) { - await deleteSentProtoRecipient({ - timestamp, - recipientUuid, - deviceId, - }); - } else { - window.log.warn( - `DeliveryReceipts.onReceipt: Missing uuid or deviceId for deliveredTo ${deliveredTo}` - ); - } - } - - this.remove(receipt); - } catch (error) { - window.log.error( - 'DeliveryReceipts.onReceipt error:', - error && error.stack ? error.stack : error - ); - } - } -} diff --git a/ts/messageModifiers/MessageReceipts.ts b/ts/messageModifiers/MessageReceipts.ts new file mode 100644 index 000000000..e2954b32b --- /dev/null +++ b/ts/messageModifiers/MessageReceipts.ts @@ -0,0 +1,226 @@ +// Copyright 2016-2021 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +/* eslint-disable max-classes-per-file */ + +import { isEqual } from 'lodash'; +import { Collection, Model } from 'backbone'; + +import { ConversationModel } from '../models/conversations'; +import { MessageModel } from '../models/messages'; +import { MessageModelCollectionType } from '../model-types.d'; +import { isOutgoing } from '../state/selectors/message'; +import { isDirectConversation } from '../util/whatTypeOfConversation'; +import { getOwn } from '../util/getOwn'; +import { missingCaseError } from '../util/missingCaseError'; +import { SendActionType, sendStateReducer } from '../messages/MessageSendState'; +import dataInterface from '../sql/Client'; + +const { deleteSentProtoRecipient } = dataInterface; + +export enum MessageReceiptType { + Delivery = 'Delivery', + Read = 'Read', +} + +type MessageReceiptAttributesType = { + messageSentAt: number; + receiptTimestamp: number; + sourceConversationId: string; + sourceDevice: number; + type: MessageReceiptType; +}; + +class MessageReceiptModel extends Model {} + +let singleton: MessageReceipts | undefined; + +async function getTargetMessage( + sourceId: string, + messages: MessageModelCollectionType +): Promise { + if (messages.length === 0) { + return null; + } + const message = messages.find( + item => + isOutgoing(item.attributes) && sourceId === item.get('conversationId') + ); + if (message) { + return window.MessageController.register(message.id, message); + } + + const groups = await window.Signal.Data.getAllGroupsInvolvingId(sourceId, { + ConversationCollection: window.Whisper.ConversationCollection, + }); + + const ids = groups.pluck('id'); + ids.push(sourceId); + + const target = messages.find( + item => + isOutgoing(item.attributes) && ids.includes(item.get('conversationId')) + ); + if (!target) { + return null; + } + + return window.MessageController.register(target.id, target); +} + +const wasDeliveredWithSealedSender = ( + conversationId: string, + message: MessageModel +): boolean => + (message.get('unidentifiedDeliveries') || []).some( + identifier => + window.ConversationController.getConversationId(identifier) === + conversationId + ); + +export class MessageReceipts extends Collection { + static getSingleton(): MessageReceipts { + if (!singleton) { + singleton = new MessageReceipts(); + } + + return singleton; + } + + forMessage( + conversation: ConversationModel, + message: MessageModel + ): Array { + if (!isOutgoing(message.attributes)) { + return []; + } + let ids: Array; + if (isDirectConversation(conversation.attributes)) { + ids = [conversation.id]; + } else { + ids = conversation.getMemberIds(); + } + const receipts = this.filter( + receipt => + receipt.get('messageSentAt') === message.get('sent_at') && + ids.includes(receipt.get('sourceConversationId')) + ); + if (receipts.length) { + window.log.info('Found early read receipts for message'); + this.remove(receipts); + } + return receipts; + } + + async onReceipt(receipt: MessageReceiptModel): Promise { + const type = receipt.get('type'); + const messageSentAt = receipt.get('messageSentAt'); + const sourceConversationId = receipt.get('sourceConversationId'); + + try { + const messages = await window.Signal.Data.getMessagesBySentAt( + messageSentAt, + { + MessageCollection: window.Whisper.MessageCollection, + } + ); + + const message = await getTargetMessage(sourceConversationId, messages); + if (!message) { + window.log.info( + 'No message for receipt', + type, + sourceConversationId, + messageSentAt + ); + return; + } + + const oldSendStateByConversationId = + message.get('sendStateByConversationId') || {}; + const oldSendState = getOwn( + oldSendStateByConversationId, + sourceConversationId + ); + if (oldSendState) { + let sendActionType: SendActionType; + switch (type) { + case MessageReceiptType.Delivery: + sendActionType = SendActionType.GotDeliveryReceipt; + break; + case MessageReceiptType.Read: + sendActionType = SendActionType.GotReadReceipt; + break; + default: + throw missingCaseError(type); + } + + const newSendState = sendStateReducer(oldSendState, { + type: sendActionType, + updatedAt: messageSentAt, + }); + + // The send state may not change. For example, this can happen if we get a read + // receipt before a delivery receipt. + if (!isEqual(oldSendState, newSendState)) { + message.set('sendStateByConversationId', { + ...oldSendStateByConversationId, + [sourceConversationId]: newSendState, + }); + + window.Signal.Util.queueUpdateMessage(message.attributes); + + // notify frontend listeners + const conversation = window.ConversationController.get( + message.get('conversationId') + ); + const updateLeftPane = conversation + ? conversation.debouncedUpdateLastMessage + : undefined; + if (updateLeftPane) { + updateLeftPane(); + } + } + } else { + window.log.warn( + `Got a receipt from someone (${sourceConversationId}), but the message (sent at ${message.get( + 'sent_at' + )}) wasn't sent to them. It was sent to ${ + Object.keys(oldSendStateByConversationId).length + } recipients` + ); + } + + if ( + (type === MessageReceiptType.Delivery && + wasDeliveredWithSealedSender(sourceConversationId, message)) || + type === MessageReceiptType.Read + ) { + const recipient = window.ConversationController.get( + sourceConversationId + ); + const recipientUuid = recipient?.get('uuid'); + const deviceId = receipt.get('sourceDevice'); + + if (recipientUuid && deviceId) { + await deleteSentProtoRecipient({ + timestamp: messageSentAt, + recipientUuid, + deviceId, + }); + } else { + window.log.warn( + `MessageReceipts.onReceipt: Missing uuid or deviceId for deliveredTo ${sourceConversationId}` + ); + } + } + + this.remove(receipt); + } catch (error) { + window.log.error( + 'MessageReceipts.onReceipt error:', + error && error.stack ? error.stack : error + ); + } + } +} diff --git a/ts/messageModifiers/ReadReceipts.ts b/ts/messageModifiers/ReadReceipts.ts deleted file mode 100644 index 5cf5ced61..000000000 --- a/ts/messageModifiers/ReadReceipts.ts +++ /dev/null @@ -1,177 +0,0 @@ -// Copyright 2016-2021 Signal Messenger, LLC -// SPDX-License-Identifier: AGPL-3.0-only - -/* eslint-disable max-classes-per-file */ - -import { isEqual } from 'lodash'; -import { Collection, Model } from 'backbone'; - -import { ConversationModel } from '../models/conversations'; -import { MessageModel } from '../models/messages'; -import { MessageModelCollectionType } from '../model-types.d'; -import { isOutgoing } from '../state/selectors/message'; -import { isDirectConversation } from '../util/whatTypeOfConversation'; -import { getOwn } from '../util/getOwn'; -import { SendActionType, sendStateReducer } from '../messages/MessageSendState'; -import dataInterface from '../sql/Client'; - -const { deleteSentProtoRecipient } = dataInterface; - -type ReadReceiptAttributesType = { - reader: string; - readerDevice: number; - timestamp: number; - readAt: number; -}; - -class ReadReceiptModel extends Model {} - -let singleton: ReadReceipts | undefined; - -async function getTargetMessage( - reader: string, - messages: MessageModelCollectionType -): Promise { - if (messages.length === 0) { - return null; - } - const message = messages.find( - item => isOutgoing(item.attributes) && reader === item.get('conversationId') - ); - if (message) { - return window.MessageController.register(message.id, message); - } - - const groups = await window.Signal.Data.getAllGroupsInvolvingId(reader, { - ConversationCollection: window.Whisper.ConversationCollection, - }); - const ids = groups.pluck('id'); - ids.push(reader); - - const target = messages.find( - item => - isOutgoing(item.attributes) && ids.includes(item.get('conversationId')) - ); - if (!target) { - return null; - } - - return window.MessageController.register(target.id, target); -} - -export class ReadReceipts extends Collection { - static getSingleton(): ReadReceipts { - if (!singleton) { - singleton = new ReadReceipts(); - } - - return singleton; - } - - forMessage( - conversation: ConversationModel, - message: MessageModel - ): Array { - if (!isOutgoing(message.attributes)) { - return []; - } - let ids: Array; - if (isDirectConversation(conversation.attributes)) { - ids = [conversation.id]; - } else { - ids = conversation.getMemberIds(); - } - const receipts = this.filter( - receipt => - receipt.get('timestamp') === message.get('sent_at') && - ids.includes(receipt.get('reader')) - ); - if (receipts.length) { - window.log.info('Found early read receipts for message'); - this.remove(receipts); - } - return receipts; - } - - async onReceipt(receipt: ReadReceiptModel): Promise { - const timestamp = receipt.get('timestamp'); - const reader = receipt.get('reader'); - - try { - const messages = await window.Signal.Data.getMessagesBySentAt(timestamp, { - MessageCollection: window.Whisper.MessageCollection, - }); - - const message = await getTargetMessage(reader, messages); - - if (!message) { - window.log.info('No message for read receipt', reader, timestamp); - return; - } - - const oldSendStateByConversationId = - message.get('sendStateByConversationId') || {}; - const oldSendState = getOwn(oldSendStateByConversationId, reader); - if (oldSendState) { - const newSendState = sendStateReducer(oldSendState, { - type: SendActionType.GotReadReceipt, - updatedAt: timestamp, - }); - - // The send state may not change. This can happen if we get read receipts after - // we get viewed receipts, or if we get double read receipts, or things like - // that. - if (!isEqual(oldSendState, newSendState)) { - message.set('sendStateByConversationId', { - ...oldSendStateByConversationId, - [reader]: newSendState, - }); - - window.Signal.Util.queueUpdateMessage(message.attributes); - - // notify frontend listeners - const conversation = window.ConversationController.get( - message.get('conversationId') - ); - const updateLeftPane = conversation - ? conversation.debouncedUpdateLastMessage - : undefined; - if (updateLeftPane) { - updateLeftPane(); - } - } - } else { - window.log.warn( - `Got a read receipt from someone (${reader}), but the message (sent at ${message.get( - 'sent_at' - )}) wasn't sent to them. It was sent to ${ - Object.keys(oldSendStateByConversationId).length - } recipients` - ); - } - - const deviceId = receipt.get('readerDevice'); - const recipient = window.ConversationController.get(reader); - const recipientUuid = recipient?.get('uuid'); - - if (recipientUuid && deviceId) { - await deleteSentProtoRecipient({ - timestamp, - recipientUuid, - deviceId, - }); - } else { - window.log.warn( - `ReadReceipts.onReceipt: Missing uuid or deviceId for reader ${reader}` - ); - } - - this.remove(receipt); - } catch (error) { - window.log.error( - 'ReadReceipts.onReceipt error:', - error && error.stack ? error.stack : error - ); - } - } -} diff --git a/ts/models/messages.ts b/ts/models/messages.ts index ef3904165..33cd00173 100644 --- a/ts/models/messages.ts +++ b/ts/models/messages.ts @@ -12,10 +12,11 @@ import { QuotedMessageType, WhatIsThis, } from '../model-types.d'; -import { concat, filter, find, map, reduce } from '../util/iterables'; +import { filter, find, map, reduce } from '../util/iterables'; import { isNotNil } from '../util/isNotNil'; import { isNormalNumber } from '../util/isNormalNumber'; import { strictAssert } from '../util/assert'; +import { missingCaseError } from '../util/missingCaseError'; import { dropNull } from '../util/dropNull'; import { ConversationModel } from './conversations'; import { @@ -44,7 +45,6 @@ import { AttachmentType, isImage, isVideo } from '../types/Attachment'; import { MIMEType, IMAGE_WEBP } from '../types/MIME'; import { ourProfileKeyService } from '../services/ourProfileKey'; import { - SendAction, SendActionType, SendStateByConversationId, SendStatus, @@ -96,10 +96,12 @@ import { getActiveCall, } from '../state/selectors/calling'; import { getAccountSelector } from '../state/selectors/accounts'; -import { DeliveryReceipts } from '../messageModifiers/DeliveryReceipts'; +import { + MessageReceipts, + MessageReceiptType, +} from '../messageModifiers/MessageReceipts'; import { Deletes } from '../messageModifiers/Deletes'; import { Reactions } from '../messageModifiers/Reactions'; -import { ReadReceipts } from '../messageModifiers/ReadReceipts'; import { ReadSyncs } from '../messageModifiers/ReadSyncs'; import { ViewSyncs } from '../messageModifiers/ViewSyncs'; import * as AttachmentDownloads from '../messageModifiers/AttachmentDownloads'; @@ -3181,29 +3183,30 @@ export class MessageModel extends window.Backbone.Model { let changed = false; if (type === 'outgoing') { - const sendActions = concat<{ - destinationConversationId: string; - action: SendAction; - }>( - DeliveryReceipts.getSingleton() - .forMessage(conversation, message) - .map(receipt => ({ - destinationConversationId: receipt.get('deliveredTo'), + const sendActions = MessageReceipts.getSingleton() + .forMessage(conversation, message) + .map(receipt => { + let sendActionType: SendActionType; + const receiptType = receipt.get('type'); + switch (receiptType) { + case MessageReceiptType.Delivery: + sendActionType = SendActionType.GotDeliveryReceipt; + break; + case MessageReceiptType.Read: + sendActionType = SendActionType.GotReadReceipt; + break; + default: + throw missingCaseError(receiptType); + } + + return { + destinationConversationId: receipt.get('sourceConversationId'), action: { - type: SendActionType.GotDeliveryReceipt, - updatedAt: receipt.get('timestamp'), + type: sendActionType, + updatedAt: receipt.get('receiptTimestamp'), }, - })), - ReadReceipts.getSingleton() - .forMessage(conversation, message) - .map(receipt => ({ - destinationConversationId: receipt.get('reader'), - action: { - type: SendActionType.GotReadReceipt, - updatedAt: receipt.get('timestamp'), - }, - })) - ); + }; + }); const oldSendStateByConversationId = this.get('sendStateByConversationId') || {};