239 lines
		
	
	
	
		
			6.8 KiB
			
		
	
	
	
		
			TypeScript
		
	
	
	
	
	
			
		
		
	
	
			239 lines
		
	
	
	
		
			6.8 KiB
			
		
	
	
	
		
			TypeScript
		
	
	
	
	
	
// 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 { createWaitBatcher } from '../util/waitBatcher';
 | 
						|
import {
 | 
						|
  SendActionType,
 | 
						|
  SendStatus,
 | 
						|
  sendStateReducer,
 | 
						|
} from '../messages/MessageSendState';
 | 
						|
import type { DeleteSentProtoRecipientOptionsType } from '../sql/Interface';
 | 
						|
import dataInterface from '../sql/Client';
 | 
						|
 | 
						|
const { deleteSentProtoRecipient } = dataInterface;
 | 
						|
 | 
						|
export enum MessageReceiptType {
 | 
						|
  Delivery = 'Delivery',
 | 
						|
  Read = 'Read',
 | 
						|
  View = 'View',
 | 
						|
}
 | 
						|
 | 
						|
type MessageReceiptAttributesType = {
 | 
						|
  messageSentAt: number;
 | 
						|
  receiptTimestamp: number;
 | 
						|
  sourceConversationId: string;
 | 
						|
  sourceDevice: number;
 | 
						|
  type: MessageReceiptType;
 | 
						|
};
 | 
						|
 | 
						|
class MessageReceiptModel extends Model<MessageReceiptAttributesType> {}
 | 
						|
 | 
						|
let singleton: MessageReceipts | undefined;
 | 
						|
 | 
						|
const deleteSentProtoBatcher = createWaitBatcher({
 | 
						|
  name: 'deleteSentProtoBatcher',
 | 
						|
  wait: 250,
 | 
						|
  maxSize: 30,
 | 
						|
  async processBatch(items: Array<DeleteSentProtoRecipientOptionsType>) {
 | 
						|
    window.log.info(
 | 
						|
      `MessageReceipts: Batching ${items.length} sent proto recipients deletes`
 | 
						|
    );
 | 
						|
    await deleteSentProtoRecipient(items);
 | 
						|
  },
 | 
						|
});
 | 
						|
 | 
						|
async function getTargetMessage(
 | 
						|
  sourceId: string,
 | 
						|
  messages: MessageModelCollectionType
 | 
						|
): Promise<MessageModel | null> {
 | 
						|
  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<MessageReceiptModel> {
 | 
						|
  static getSingleton(): MessageReceipts {
 | 
						|
    if (!singleton) {
 | 
						|
      singleton = new MessageReceipts();
 | 
						|
    }
 | 
						|
 | 
						|
    return singleton;
 | 
						|
  }
 | 
						|
 | 
						|
  forMessage(
 | 
						|
    conversation: ConversationModel,
 | 
						|
    message: MessageModel
 | 
						|
  ): Array<MessageReceiptModel> {
 | 
						|
    if (!isOutgoing(message.attributes)) {
 | 
						|
      return [];
 | 
						|
    }
 | 
						|
    let ids: Array<string>;
 | 
						|
    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 receipts for message');
 | 
						|
      this.remove(receipts);
 | 
						|
    }
 | 
						|
    return receipts;
 | 
						|
  }
 | 
						|
 | 
						|
  async onReceipt(receipt: MessageReceiptModel): Promise<void> {
 | 
						|
    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
 | 
						|
      ) ?? { 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: 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();
 | 
						|
        }
 | 
						|
      }
 | 
						|
 | 
						|
      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 deleteSentProtoBatcher.add({
 | 
						|
            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
 | 
						|
      );
 | 
						|
    }
 | 
						|
  }
 | 
						|
}
 |