605 lines
		
	
	
	
		
			18 KiB
			
		
	
	
	
		
			TypeScript
		
	
	
	
	
	
			
		
		
	
	
			605 lines
		
	
	
	
		
			18 KiB
			
		
	
	
	
		
			TypeScript
		
	
	
	
	
	
| // Copyright 2020 Signal Messenger, LLC
 | |
| // SPDX-License-Identifier: AGPL-3.0-only
 | |
| 
 | |
| import { maxBy } from 'lodash';
 | |
| 
 | |
| import type { AciString } from '../types/ServiceId';
 | |
| import type {
 | |
|   MessageAttributesType,
 | |
|   MessageReactionType,
 | |
|   ReadonlyMessageAttributesType,
 | |
| } from '../model-types.d';
 | |
| import { MessageModel } from '../models/messages';
 | |
| import { ReactionSource } from '../reactions/ReactionSource';
 | |
| import { DataReader, DataWriter } from '../sql/Client';
 | |
| import * as Errors from '../types/errors';
 | |
| import * as log from '../logging/log';
 | |
| import { getAuthor, isIncoming, isOutgoing } from '../messages/helpers';
 | |
| import { getMessageSentTimestampSet } from '../util/getMessageSentTimestampSet';
 | |
| import { isDirectConversation, isMe } from '../util/whatTypeOfConversation';
 | |
| import {
 | |
|   getMessagePropStatus,
 | |
|   hasErrors,
 | |
|   isStory,
 | |
| } from '../state/selectors/message';
 | |
| import { getPropForTimestamp } from '../util/editHelpers';
 | |
| import { isSent } from '../messages/MessageSendState';
 | |
| import { strictAssert } from '../util/assert';
 | |
| import { repeat, zipObject } from '../util/iterables';
 | |
| import { getMessageIdForLogging } from '../util/idForLogging';
 | |
| import { hydrateStoryContext } from '../util/hydrateStoryContext';
 | |
| import { shouldReplyNotifyUser } from '../util/shouldReplyNotifyUser';
 | |
| import { drop } from '../util/drop';
 | |
| import * as reactionUtil from '../reactions/util';
 | |
| import { isNewReactionReplacingPrevious } from '../reactions/util';
 | |
| import { notificationService } from '../services/notifications';
 | |
| import { ReactionReadStatus } from '../types/Reactions';
 | |
| import type { ConversationQueueJobData } from '../jobs/conversationJobQueue';
 | |
| import {
 | |
|   conversationJobQueue,
 | |
|   conversationQueueJobEnum,
 | |
| } from '../jobs/conversationJobQueue';
 | |
| import { postSaveUpdates } from '../util/cleanup';
 | |
| 
 | |
| export type ReactionAttributesType = {
 | |
|   emoji: string;
 | |
|   envelopeId: string;
 | |
|   fromId: string;
 | |
|   remove?: boolean;
 | |
|   removeFromMessageReceiverCache: () => unknown;
 | |
|   source: ReactionSource;
 | |
|   // If this is a reaction to a 1:1 story, we can use this message, generated from the
 | |
|   //   reaction message itself. Necessary to put 1:1 story replies into the right
 | |
|   //   conversation - not the same conversation as the target message!
 | |
|   generatedMessageForStoryReaction?: MessageModel;
 | |
|   targetAuthorAci: AciString;
 | |
|   targetTimestamp: number;
 | |
|   timestamp: number;
 | |
|   receivedAtDate: number;
 | |
| };
 | |
| 
 | |
| const reactionCache = new Map<string, ReactionAttributesType>();
 | |
| 
 | |
| function remove(reaction: ReactionAttributesType): void {
 | |
|   reactionCache.delete(reaction.envelopeId);
 | |
|   reaction.removeFromMessageReceiverCache();
 | |
| }
 | |
| 
 | |
| export function findReactionsForMessage(
 | |
|   message: ReadonlyMessageAttributesType
 | |
| ): Array<ReactionAttributesType> {
 | |
|   const matchingReactions = Array.from(reactionCache.values()).filter(
 | |
|     reaction => {
 | |
|       return isMessageAMatchForReaction({
 | |
|         message,
 | |
|         targetTimestamp: reaction.targetTimestamp,
 | |
|         targetAuthorAci: reaction.targetAuthorAci,
 | |
|         reactionSenderConversationId: reaction.fromId,
 | |
|       });
 | |
|     }
 | |
|   );
 | |
| 
 | |
|   matchingReactions.forEach(reaction => remove(reaction));
 | |
|   return matchingReactions;
 | |
| }
 | |
| 
 | |
| async function findMessageForReaction({
 | |
|   targetTimestamp,
 | |
|   targetAuthorAci,
 | |
|   reactionSenderConversationId,
 | |
|   logId,
 | |
| }: {
 | |
|   targetTimestamp: number;
 | |
|   targetAuthorAci: string;
 | |
|   reactionSenderConversationId: string;
 | |
|   logId: string;
 | |
| }): Promise<MessageAttributesType | undefined> {
 | |
|   const messages = await DataReader.getMessagesBySentAt(targetTimestamp);
 | |
| 
 | |
|   const matchingMessages = messages.filter(message =>
 | |
|     isMessageAMatchForReaction({
 | |
|       message,
 | |
|       targetTimestamp,
 | |
|       targetAuthorAci,
 | |
|       reactionSenderConversationId,
 | |
|     })
 | |
|   );
 | |
| 
 | |
|   if (!matchingMessages.length) {
 | |
|     return undefined;
 | |
|   }
 | |
| 
 | |
|   if (matchingMessages.length > 1) {
 | |
|     // This could theoretically happen given limitations in the reaction proto but
 | |
|     // is very unlikely
 | |
|     log.warn(
 | |
|       `${logId}/findMessageForReaction: found ${matchingMessages.length} matching messages for the reaction!`
 | |
|     );
 | |
|   }
 | |
| 
 | |
|   return matchingMessages[0];
 | |
| }
 | |
| 
 | |
| function isMessageAMatchForReaction({
 | |
|   message,
 | |
|   targetTimestamp,
 | |
|   targetAuthorAci,
 | |
|   reactionSenderConversationId,
 | |
| }: {
 | |
|   message: ReadonlyMessageAttributesType;
 | |
|   targetTimestamp: number;
 | |
|   targetAuthorAci: string;
 | |
|   reactionSenderConversationId: string;
 | |
| }): boolean {
 | |
|   if (!getMessageSentTimestampSet(message).has(targetTimestamp)) {
 | |
|     return false;
 | |
|   }
 | |
| 
 | |
|   const targetAuthorConversation =
 | |
|     window.ConversationController.get(targetAuthorAci);
 | |
|   const reactionSenderConversation = window.ConversationController.get(
 | |
|     reactionSenderConversationId
 | |
|   );
 | |
| 
 | |
|   if (!targetAuthorConversation || !reactionSenderConversation) {
 | |
|     return false;
 | |
|   }
 | |
| 
 | |
|   const author = getAuthor(message);
 | |
|   if (!author) {
 | |
|     return false;
 | |
|   }
 | |
| 
 | |
|   if (author.id !== targetAuthorConversation.id) {
 | |
|     return false;
 | |
|   }
 | |
| 
 | |
|   if (isMe(reactionSenderConversation.attributes)) {
 | |
|     // I am either the recipient or sender of all the messages I know about!
 | |
|     return true;
 | |
|   }
 | |
| 
 | |
|   if (message.type === 'outgoing') {
 | |
|     const sendStateByConversationId = getPropForTimestamp({
 | |
|       log,
 | |
|       message,
 | |
|       prop: 'sendStateByConversationId',
 | |
|       targetTimestamp,
 | |
|     });
 | |
| 
 | |
|     const sendState =
 | |
|       sendStateByConversationId?.[reactionSenderConversation.id];
 | |
|     if (!sendState) {
 | |
|       return false;
 | |
|     }
 | |
| 
 | |
|     return isSent(sendState.status);
 | |
|   }
 | |
| 
 | |
|   if (message.type === 'incoming') {
 | |
|     const messageConversation = window.ConversationController.get(
 | |
|       message.conversationId
 | |
|     );
 | |
|     if (!messageConversation) {
 | |
|       return false;
 | |
|     }
 | |
| 
 | |
|     const reactionSenderServiceId = reactionSenderConversation.getServiceId();
 | |
|     return (
 | |
|       reactionSenderServiceId != null &&
 | |
|       messageConversation.hasMember(reactionSenderServiceId)
 | |
|     );
 | |
|   }
 | |
| 
 | |
|   return true;
 | |
| }
 | |
| 
 | |
| export async function onReaction(
 | |
|   reaction: ReactionAttributesType
 | |
| ): Promise<void> {
 | |
|   reactionCache.set(reaction.envelopeId, reaction);
 | |
| 
 | |
|   const logId = `Reactions.onReaction(timestamp=${reaction.timestamp};target=${reaction.targetTimestamp})`;
 | |
| 
 | |
|   try {
 | |
|     const matchingMessage = await findMessageForReaction({
 | |
|       targetTimestamp: reaction.targetTimestamp,
 | |
|       targetAuthorAci: reaction.targetAuthorAci,
 | |
|       reactionSenderConversationId: reaction.fromId,
 | |
|       logId,
 | |
|     });
 | |
| 
 | |
|     if (!matchingMessage) {
 | |
|       log.info(
 | |
|         `${logId}: No message for reaction`,
 | |
|         'targeting',
 | |
|         reaction.targetAuthorAci
 | |
|       );
 | |
|       return;
 | |
|     }
 | |
| 
 | |
|     const matchingMessageConversation = window.ConversationController.get(
 | |
|       matchingMessage.conversationId
 | |
|     );
 | |
| 
 | |
|     if (!matchingMessageConversation) {
 | |
|       log.info(
 | |
|         `${logId}: No target conversation for reaction`,
 | |
|         reaction.targetAuthorAci,
 | |
|         reaction.targetTimestamp
 | |
|       );
 | |
|       remove(reaction);
 | |
|       return undefined;
 | |
|     }
 | |
| 
 | |
|     // awaiting is safe since `onReaction` is never called from inside the queue
 | |
|     await matchingMessageConversation.queueJob(
 | |
|       'Reactions.onReaction',
 | |
|       async () => {
 | |
|         log.info(`${logId}: handling`);
 | |
| 
 | |
|         // Message is fetched inside the conversation queue so we have the
 | |
|         // most recent data
 | |
|         const targetMessage = await findMessageForReaction({
 | |
|           targetTimestamp: reaction.targetTimestamp,
 | |
|           targetAuthorAci: reaction.targetAuthorAci,
 | |
|           reactionSenderConversationId: reaction.fromId,
 | |
|           logId: `${logId}/conversationQueue`,
 | |
|         });
 | |
| 
 | |
|         if (!targetMessage || targetMessage.id !== matchingMessage.id) {
 | |
|           log.warn(
 | |
|             `${logId}: message no longer a match for reaction! Maybe it's been deleted?`
 | |
|           );
 | |
|           remove(reaction);
 | |
|           return;
 | |
|         }
 | |
| 
 | |
|         const targetMessageModel = window.MessageCache.register(
 | |
|           new MessageModel(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 handleReaction(targetMessageModel, reaction);
 | |
|         } else {
 | |
|           const generatedMessage = reaction.generatedMessageForStoryReaction;
 | |
|           strictAssert(
 | |
|             generatedMessage,
 | |
|             'Generated message must exist for story reaction'
 | |
|           );
 | |
|           await handleReaction(generatedMessage, reaction, {
 | |
|             storyMessage: targetMessage,
 | |
|           });
 | |
|         }
 | |
| 
 | |
|         remove(reaction);
 | |
|       }
 | |
|     );
 | |
|   } catch (error) {
 | |
|     remove(reaction);
 | |
|     log.error(`${logId} error:`, Errors.toLogFormat(error));
 | |
|   }
 | |
| }
 | |
| 
 | |
| export async function handleReaction(
 | |
|   message: MessageModel,
 | |
|   reaction: ReactionAttributesType,
 | |
|   {
 | |
|     storyMessage,
 | |
|     shouldPersist = true,
 | |
|   }: {
 | |
|     storyMessage?: MessageAttributesType;
 | |
|     shouldPersist?: boolean;
 | |
|   } = {}
 | |
| ): Promise<void> {
 | |
|   const { attributes } = message;
 | |
| 
 | |
|   if (message.get('deletedForEveryone')) {
 | |
|     return;
 | |
|   }
 | |
| 
 | |
|   // We allow you to react to messages with outgoing errors only if it has sent
 | |
|   //   successfully to at least one person.
 | |
|   if (
 | |
|     hasErrors(attributes) &&
 | |
|     (isIncoming(attributes) ||
 | |
|       getMessagePropStatus(
 | |
|         attributes,
 | |
|         window.ConversationController.getOurConversationIdOrThrow()
 | |
|       ) !== 'partial-sent')
 | |
|   ) {
 | |
|     return;
 | |
|   }
 | |
| 
 | |
|   const conversation = window.ConversationController.get(
 | |
|     message.attributes.conversationId
 | |
|   );
 | |
|   if (!conversation) {
 | |
|     return;
 | |
|   }
 | |
| 
 | |
|   const isFromThisDevice = reaction.source === ReactionSource.FromThisDevice;
 | |
|   const isFromSync = reaction.source === ReactionSource.FromSync;
 | |
|   const isFromSomeoneElse = reaction.source === ReactionSource.FromSomeoneElse;
 | |
|   strictAssert(
 | |
|     isFromThisDevice || isFromSync || isFromSomeoneElse,
 | |
|     'Reaction can only be from this device, from sync, or from someone else'
 | |
|   );
 | |
| 
 | |
|   const newReaction: MessageReactionType = {
 | |
|     emoji: reaction.remove ? undefined : reaction.emoji,
 | |
|     fromId: reaction.fromId,
 | |
|     targetTimestamp: reaction.targetTimestamp,
 | |
|     timestamp: reaction.timestamp,
 | |
|     isSentByConversationId: isFromThisDevice
 | |
|       ? zipObject(conversation.getMemberConversationIds(), repeat(false))
 | |
|       : undefined,
 | |
|   };
 | |
| 
 | |
|   // Reactions to stories are saved as separate messages, and so require a totally
 | |
|   //   different codepath.
 | |
|   if (storyMessage) {
 | |
|     if (isFromThisDevice) {
 | |
|       log.info(
 | |
|         'handleReaction: sending story reaction to ' +
 | |
|           `${getMessageIdForLogging(storyMessage)} from this device`
 | |
|       );
 | |
|     } else {
 | |
|       if (isFromSomeoneElse) {
 | |
|         log.info(
 | |
|           'handleReaction: receiving story reaction to ' +
 | |
|             `${getMessageIdForLogging(storyMessage)} from someone else`
 | |
|         );
 | |
|       } else if (isFromSync) {
 | |
|         log.info(
 | |
|           'handleReaction: receiving story reaction to ' +
 | |
|             `${getMessageIdForLogging(storyMessage)} from another device`
 | |
|         );
 | |
|       }
 | |
| 
 | |
|       const generatedMessage = reaction.generatedMessageForStoryReaction;
 | |
|       strictAssert(
 | |
|         generatedMessage,
 | |
|         'Story reactions must provide storyReactionMessage'
 | |
|       );
 | |
|       const targetConversation = window.ConversationController.get(
 | |
|         generatedMessage.get('conversationId')
 | |
|       );
 | |
|       strictAssert(
 | |
|         targetConversation,
 | |
|         'handleReaction: targetConversation not found'
 | |
|       );
 | |
| 
 | |
|       window.MessageCache.register(generatedMessage);
 | |
|       generatedMessage.set({
 | |
|         expireTimer: isDirectConversation(targetConversation.attributes)
 | |
|           ? targetConversation.get('expireTimer')
 | |
|           : undefined,
 | |
|         storyId: storyMessage.id,
 | |
|         storyReaction: {
 | |
|           emoji: reaction.emoji,
 | |
|           targetAuthorAci: reaction.targetAuthorAci,
 | |
|           targetTimestamp: reaction.targetTimestamp,
 | |
|         },
 | |
|       });
 | |
| 
 | |
|       await hydrateStoryContext(generatedMessage.id, storyMessage, {
 | |
|         shouldSave: false,
 | |
|       });
 | |
|       // Note: generatedMessage comes with an id, so we have to force this save
 | |
|       await DataWriter.saveMessage(generatedMessage.attributes, {
 | |
|         ourAci: window.textsecure.storage.user.getCheckedAci(),
 | |
|         forceSave: true,
 | |
|         postSaveUpdates,
 | |
|       });
 | |
| 
 | |
|       log.info('Reactions.onReaction adding reaction to story', {
 | |
|         reactionMessageId: getMessageIdForLogging(generatedMessage.attributes),
 | |
|         storyId: getMessageIdForLogging(storyMessage),
 | |
|         targetTimestamp: reaction.targetTimestamp,
 | |
|         timestamp: reaction.timestamp,
 | |
|       });
 | |
| 
 | |
|       window.MessageCache.register(generatedMessage);
 | |
|       if (isDirectConversation(targetConversation.attributes)) {
 | |
|         await targetConversation.addSingleMessage(generatedMessage.attributes);
 | |
|         if (!targetConversation.get('active_at')) {
 | |
|           targetConversation.set({
 | |
|             active_at: generatedMessage.attributes.timestamp,
 | |
|           });
 | |
|           await DataWriter.updateConversation(targetConversation.attributes);
 | |
|         }
 | |
|       }
 | |
| 
 | |
|       if (isFromSomeoneElse) {
 | |
|         log.info(
 | |
|           'handleReaction: notifying for story reaction to ' +
 | |
|             `${getMessageIdForLogging(storyMessage)} from someone else`
 | |
|         );
 | |
|         if (
 | |
|           await shouldReplyNotifyUser(
 | |
|             generatedMessage.attributes,
 | |
|             targetConversation
 | |
|           )
 | |
|         ) {
 | |
|           drop(targetConversation.notify(generatedMessage.attributes));
 | |
|         }
 | |
|       }
 | |
|     }
 | |
|   } else {
 | |
|     // Reactions to all messages other than stories will update the target message
 | |
|     const previousLength = (message.get('reactions') || []).length;
 | |
| 
 | |
|     if (isFromThisDevice) {
 | |
|       log.info(
 | |
|         `handleReaction: sending reaction to ${getMessageIdForLogging(message.attributes)} ` +
 | |
|           'from this device'
 | |
|       );
 | |
| 
 | |
|       const reactions = reactionUtil.addOutgoingReaction(
 | |
|         message.get('reactions') || [],
 | |
|         newReaction
 | |
|       );
 | |
|       message.set({ reactions });
 | |
|     } else {
 | |
|       const oldReactions = message.get('reactions') || [];
 | |
|       let reactions: Array<MessageReactionType>;
 | |
|       const oldReaction = oldReactions.find(re =>
 | |
|         isNewReactionReplacingPrevious(re, newReaction)
 | |
|       );
 | |
|       if (oldReaction) {
 | |
|         notificationService.removeBy({
 | |
|           ...oldReaction,
 | |
|           messageId: message.id,
 | |
|         });
 | |
|       }
 | |
| 
 | |
|       if (reaction.remove) {
 | |
|         log.info(
 | |
|           'handleReaction: removing reaction for message',
 | |
|           getMessageIdForLogging(message.attributes)
 | |
|         );
 | |
| 
 | |
|         if (isFromSync) {
 | |
|           reactions = oldReactions.filter(
 | |
|             re =>
 | |
|               !isNewReactionReplacingPrevious(re, newReaction) ||
 | |
|               re.timestamp > reaction.timestamp
 | |
|           );
 | |
|         } else {
 | |
|           reactions = oldReactions.filter(
 | |
|             re => !isNewReactionReplacingPrevious(re, newReaction)
 | |
|           );
 | |
|         }
 | |
|         message.set({ reactions });
 | |
|       } else {
 | |
|         log.info(
 | |
|           'handleReaction: adding reaction for message',
 | |
|           getMessageIdForLogging(message.attributes)
 | |
|         );
 | |
| 
 | |
|         let reactionToAdd: MessageReactionType;
 | |
|         if (isFromSync) {
 | |
|           const ourReactions = [
 | |
|             newReaction,
 | |
|             ...oldReactions.filter(re => re.fromId === reaction.fromId),
 | |
|           ];
 | |
|           reactionToAdd = maxBy(ourReactions, 'timestamp') || newReaction;
 | |
|         } else {
 | |
|           reactionToAdd = newReaction;
 | |
|         }
 | |
| 
 | |
|         reactions = oldReactions.filter(
 | |
|           re => !isNewReactionReplacingPrevious(re, reaction)
 | |
|         );
 | |
|         reactions.push(reactionToAdd);
 | |
|         message.set({ reactions });
 | |
| 
 | |
|         if (isOutgoing(message.attributes) && isFromSomeoneElse) {
 | |
|           void conversation.notify(message.attributes, reaction);
 | |
|         }
 | |
|       }
 | |
|     }
 | |
| 
 | |
|     if (reaction.remove) {
 | |
|       await DataWriter.removeReactionFromConversation({
 | |
|         emoji: reaction.emoji,
 | |
|         fromId: reaction.fromId,
 | |
|         targetAuthorServiceId: reaction.targetAuthorAci,
 | |
|         targetTimestamp: reaction.targetTimestamp,
 | |
|       });
 | |
|     } else {
 | |
|       await DataWriter.addReaction(
 | |
|         {
 | |
|           conversationId: message.get('conversationId'),
 | |
|           emoji: reaction.emoji,
 | |
|           fromId: reaction.fromId,
 | |
|           messageId: message.id,
 | |
|           messageReceivedAt: message.get('received_at'),
 | |
|           targetAuthorAci: reaction.targetAuthorAci,
 | |
|           targetTimestamp: reaction.targetTimestamp,
 | |
|           timestamp: reaction.timestamp,
 | |
|         },
 | |
|         {
 | |
|           readStatus: isFromThisDevice
 | |
|             ? ReactionReadStatus.Read
 | |
|             : ReactionReadStatus.Unread,
 | |
|         }
 | |
|       );
 | |
|     }
 | |
| 
 | |
|     const currentLength = (message.get('reactions') || []).length;
 | |
|     log.info(
 | |
|       'handleReaction:',
 | |
|       `Done processing reaction for message ${getMessageIdForLogging(message.attributes)}.`,
 | |
|       `Went from ${previousLength} to ${currentLength} reactions.`
 | |
|     );
 | |
|   }
 | |
| 
 | |
|   if (isFromThisDevice) {
 | |
|     let jobData: ConversationQueueJobData;
 | |
|     if (storyMessage) {
 | |
|       strictAssert(
 | |
|         newReaction.emoji !== undefined,
 | |
|         'New story reaction must have an emoji'
 | |
|       );
 | |
| 
 | |
|       const generatedMessage = reaction.generatedMessageForStoryReaction;
 | |
|       strictAssert(
 | |
|         generatedMessage,
 | |
|         'Story reactions must provide storyReactionmessage'
 | |
|       );
 | |
| 
 | |
|       await hydrateStoryContext(generatedMessage.id, message.attributes, {
 | |
|         shouldSave: false,
 | |
|       });
 | |
|       await DataWriter.saveMessage(generatedMessage.attributes, {
 | |
|         ourAci: window.textsecure.storage.user.getCheckedAci(),
 | |
|         forceSave: true,
 | |
|         postSaveUpdates,
 | |
|       });
 | |
| 
 | |
|       window.MessageCache.register(generatedMessage);
 | |
| 
 | |
|       void conversation.addSingleMessage(generatedMessage.attributes);
 | |
| 
 | |
|       jobData = {
 | |
|         type: conversationQueueJobEnum.enum.NormalMessage,
 | |
|         conversationId: conversation.id,
 | |
|         messageId: generatedMessage.id,
 | |
|         revision: conversation.get('revision'),
 | |
|       };
 | |
|     } else {
 | |
|       jobData = {
 | |
|         type: conversationQueueJobEnum.enum.Reaction,
 | |
|         conversationId: conversation.id,
 | |
|         messageId: message.id,
 | |
|         revision: conversation.get('revision'),
 | |
|       };
 | |
|     }
 | |
|     if (shouldPersist) {
 | |
|       await conversationJobQueue.add(jobData, async jobToInsert => {
 | |
|         log.info(
 | |
|           `enqueueReactionForSend: saving message ${getMessageIdForLogging(message.attributes)} and job ${
 | |
|             jobToInsert.id
 | |
|           }`
 | |
|         );
 | |
|         await DataWriter.saveMessage(message.attributes, {
 | |
|           jobToInsert,
 | |
|           ourAci: window.textsecure.storage.user.getCheckedAci(),
 | |
|           postSaveUpdates,
 | |
|         });
 | |
|       });
 | |
|     } else {
 | |
|       await conversationJobQueue.add(jobData);
 | |
|     }
 | |
|   } else if (shouldPersist && !isStory(message.attributes)) {
 | |
|     await DataWriter.saveMessage(message.attributes, {
 | |
|       ourAci: window.textsecure.storage.user.getCheckedAci(),
 | |
|       postSaveUpdates,
 | |
|     });
 | |
|     window.reduxActions.conversations.markOpenConversationRead(conversation.id);
 | |
|   }
 | |
| }
 | 
