// Copyright 2020 Signal Messenger, LLC // SPDX-License-Identifier: AGPL-3.0-only /* eslint-disable max-classes-per-file */ import { Collection, Model } from 'backbone'; import type { ConversationModel } from '../models/conversations'; import type { MessageModel } from '../models/messages'; import type { MessageAttributesType, ReactionAttributesType, } from '../model-types.d'; import * as Errors from '../types/errors'; import * as log from '../logging/log'; import { getContactId, getContact } from '../messages/helpers'; import { isDirectConversation, isMe } from '../util/whatTypeOfConversation'; import { isOutgoing, isStory } from '../state/selectors/message'; import { strictAssert } from '../util/assert'; import { getMessageSentTimestampSet } from '../util/getMessageSentTimestampSet'; export class ReactionModel extends Model {} let singleton: Reactions | undefined; export class Reactions extends Collection { static getSingleton(): Reactions { if (!singleton) { singleton = new Reactions(); } return singleton; } forMessage(message: MessageModel): Array { const sentTimestamps = getMessageSentTimestampSet(message.attributes); if (isOutgoing(message.attributes)) { const outgoingReactions = this.filter(item => sentTimestamps.has(item.get('targetTimestamp')) ); if (outgoingReactions.length > 0) { log.info('Found early reaction for outgoing message'); this.remove(outgoingReactions); return outgoingReactions; } } const senderId = getContactId(message.attributes); const reactionsBySource = this.filter(re => { const targetSender = window.ConversationController.lookupOrCreate({ uuid: re.get('targetAuthorUuid'), reason: 'Reactions.forMessage', }); const targetTimestamp = re.get('targetTimestamp'); return ( targetSender?.id === senderId && sentTimestamps.has(targetTimestamp) ); }); if (reactionsBySource.length > 0) { log.info('Found early reaction for message'); this.remove(reactionsBySource); return reactionsBySource; } return []; } private async findMessage( targetTimestamp: number, targetConversationId: string ): Promise { const messages = await window.Signal.Data.getMessagesBySentAt( targetTimestamp ); return messages.find(m => { const contact = getContact(m); if (!contact) { return false; } const mcid = contact.get('id'); return mcid === targetConversationId; }); } async onReaction(reaction: ReactionModel): Promise { try { // The conversation the target message was in; we have to find it in the database // to to figure that out. const targetAuthorConversation = window.ConversationController.lookupOrCreate({ uuid: reaction.get('targetAuthorUuid'), reason: 'Reactions.onReaction', }); const targetConversationId = targetAuthorConversation?.id; if (!targetConversationId) { throw new Error( 'onReaction: No conversationId returned from lookupOrCreate!' ); } const generatedMessage = reaction.get('storyReactionMessage'); strictAssert( generatedMessage, 'Story reactions must provide storyReactionMessage' ); const fromConversation = window.ConversationController.get( generatedMessage.get('conversationId') ); let targetConversation: ConversationModel | undefined | null; const targetMessageCheck = await this.findMessage( reaction.get('targetTimestamp'), targetConversationId ); if (!targetMessageCheck) { log.info( 'No message for reaction', reaction.get('timestamp'), 'targeting', reaction.get('targetAuthorUuid'), reaction.get('targetTimestamp') ); return; } if ( fromConversation && isStory(targetMessageCheck) && isDirectConversation(fromConversation.attributes) && !isMe(fromConversation.attributes) ) { targetConversation = fromConversation; } else { targetConversation = await window.ConversationController.getConversationForTargetMessage( targetConversationId, reaction.get('targetTimestamp') ); } if (!targetConversation) { log.info( 'No target conversation for reaction', reaction.get('targetAuthorUuid'), reaction.get('targetTimestamp') ); return undefined; } // awaiting is safe since `onReaction` is never called from inside the queue await targetConversation.queueJob('Reactions.onReaction', async () => { log.info('Handling reaction for', reaction.get('targetTimestamp')); // Thanks TS. if (!targetConversation) { return; } // Message is fetched inside the conversation queue so we have the // most recent data const targetMessage = await this.findMessage( reaction.get('targetTimestamp'), targetConversationId ); if (!targetMessage) { return; } const message = window.MessageController.register( targetMessage.id, targetMessage ); // Use the generated message in ts/background.ts to create a message // if the reaction is targeted at a story. if (!isStory(targetMessage)) { await message.handleReaction(reaction); } else { await generatedMessage.handleReaction(reaction, { storyMessage: targetMessage, }); } this.remove(reaction); }); } catch (error) { log.error('Reactions.onReaction error:', Errors.toLogFormat(error)); } } }