diff --git a/ts/background.ts b/ts/background.ts index 95c2e2e49..5c92a0b30 100644 --- a/ts/background.ts +++ b/ts/background.ts @@ -21,10 +21,7 @@ import createTaskWithTimeout, { resumeTasksWithTimeout, reportLongRunningTasks, } from './textsecure/TaskWithTimeout'; -import type { - MessageAttributesType, - ReactionAttributesType, -} from './model-types.d'; +import type { MessageAttributesType } from './model-types.d'; import * as Bytes from './Bytes'; import * as Timers from './Timers'; import * as indexedDb from './indexeddb'; @@ -116,15 +113,13 @@ import { actionCreators } from './state/actions'; import * as Deletes from './messageModifiers/Deletes'; import type { EditAttributesType } from './messageModifiers/Edits'; import * as Edits from './messageModifiers/Edits'; -import { - MessageReceipts, - MessageReceiptType, -} from './messageModifiers/MessageReceipts'; -import { MessageRequests } from './messageModifiers/MessageRequests'; -import { Reactions } from './messageModifiers/Reactions'; -import { ReadSyncs } from './messageModifiers/ReadSyncs'; -import { ViewSyncs } from './messageModifiers/ViewSyncs'; -import { ViewOnceOpenSyncs } from './messageModifiers/ViewOnceOpenSyncs'; +import type { ReactionAttributesType } from './messageModifiers/Reactions'; +import * as MessageReceipts from './messageModifiers/MessageReceipts'; +import * as MessageRequests from './messageModifiers/MessageRequests'; +import * as Reactions from './messageModifiers/Reactions'; +import * as ReadSyncs from './messageModifiers/ReadSyncs'; +import * as ViewSyncs from './messageModifiers/ViewSyncs'; +import * as ViewOnceOpenSyncs from './messageModifiers/ViewOnceOpenSyncs'; import type { DeleteAttributesType } from './messageModifiers/Deletes'; import type { MessageReceiptAttributesType } from './messageModifiers/MessageReceipts'; import type { MessageRequestAttributesType } from './messageModifiers/MessageRequests'; @@ -982,8 +977,7 @@ export async function startApp(): Promise { optimizeFTS(); - // Don't block on the following operation - void window.Signal.Data.ensureFilePermissions(); + drop(window.Signal.Data.ensureFilePermissions()); } setAppLoadingScreenMessage(window.i18n('icu:loading'), window.i18n); @@ -2002,8 +1996,7 @@ export async function startApp(): Promise { throw new Error('Expected challenge handler to be initialized'); } - // Intentionally not awaiting - void challengeHandler.onOnline(); + drop(challengeHandler.onOnline()); reconnectBackOff.reset(); } finally { @@ -2464,6 +2457,8 @@ export async function startApp(): Promise { log.info('Queuing incoming reaction for', reaction.targetTimestamp); const attributes: ReactionAttributesType = { + envelopeId: data.envelopeId, + removeFromMessageReceiverCache: confirm, emoji: reaction.emoji, fromId: fromConversation.id, remove: reaction.remove, @@ -2473,11 +2468,8 @@ export async function startApp(): Promise { targetTimestamp: reaction.targetTimestamp, timestamp, }; - const reactionModel = Reactions.getSingleton().add(attributes); - drop(Reactions.getSingleton().onReaction(reactionModel)); - - confirm(); + drop(Reactions.onReaction(attributes)); return; } @@ -2548,7 +2540,7 @@ export async function startApp(): Promise { } // Don't wait for handleDataMessage, as it has its own per-conversation queueing - void message.handleDataMessage(data.message, event.confirm); + drop(message.handleDataMessage(data.message, event.confirm)); } async function onProfileKeyUpdate({ @@ -2793,12 +2785,14 @@ export async function startApp(): Promise { if (!isValidReactionEmoji(reaction.emoji)) { log.warn('Received an invalid reaction emoji. Dropping it'); - event.confirm(); + confirm(); return; } log.info('Queuing sent reaction for', reaction.targetTimestamp); const attributes: ReactionAttributesType = { + envelopeId: data.envelopeId, + removeFromMessageReceiverCache: confirm, emoji: reaction.emoji, fromId: window.ConversationController.getOurConversationIdOrThrow(), remove: reaction.remove, @@ -2808,11 +2802,7 @@ export async function startApp(): Promise { targetTimestamp: reaction.targetTimestamp, timestamp, }; - const reactionModel = Reactions.getSingleton().add(attributes); - - drop(Reactions.getSingleton().onReaction(reactionModel)); - - event.confirm(); + drop(Reactions.onReaction(attributes)); return; } @@ -2867,9 +2857,11 @@ export async function startApp(): Promise { } // Don't wait for handleDataMessage, as it has its own per-conversation queueing - void message.handleDataMessage(data.message, event.confirm, { - data, - }); + drop( + message.handleDataMessage(data.message, event.confirm, { + data, + }) + ); } type MessageDescriptor = { @@ -3041,21 +3033,18 @@ export async function startApp(): Promise { } function onViewOnceOpenSync(ev: ViewOnceOpenSyncEvent): void { - ev.confirm(); - const { source, sourceAci, timestamp } = ev; log.info(`view once open sync ${source} ${timestamp}`); strictAssert(sourceAci, 'ViewOnceOpen without sourceAci'); strictAssert(timestamp, 'ViewOnceOpen without timestamp'); const attributes: ViewOnceOpenSyncAttributesType = { + removeFromMessageReceiverCache: ev.confirm, source, sourceAci, timestamp, }; - const sync = ViewOnceOpenSyncs.getSingleton().add(attributes); - - void ViewOnceOpenSyncs.getSingleton().onSync(sync); + drop(ViewOnceOpenSyncs.onSync(attributes)); } async function onFetchLatestSync(ev: FetchLatestEvent): Promise { @@ -3126,8 +3115,6 @@ export async function startApp(): Promise { } function onMessageRequestResponse(ev: MessageRequestResponseEvent): void { - ev.confirm(); - const { threadE164, threadAci, groupV2Id, messageRequestResponseType } = ev; log.info('onMessageRequestResponse', { @@ -3142,22 +3129,24 @@ export async function startApp(): Promise { 'onMessageRequestResponse: missing type' ); + strictAssert(ev.envelopeId, 'onMessageRequestResponse: no envelope id'); + const attributes: MessageRequestAttributesType = { + envelopeId: ev.envelopeId, + removeFromMessageReceiverCache: ev.confirm, threadE164, threadAci, groupV2Id, type: messageRequestResponseType, }; - const sync = MessageRequests.getSingleton().add(attributes); - - void MessageRequests.getSingleton().onResponse(sync); + drop(MessageRequests.onResponse(attributes)); } function onReadReceipt(event: Readonly): void { onReadOrViewReceipt({ logTitle: 'read receipt', event, - type: MessageReceiptType.Read, + type: MessageReceipts.MessageReceiptType.Read, }); } @@ -3165,7 +3154,7 @@ export async function startApp(): Promise { onReadOrViewReceipt({ logTitle: 'view receipt', event, - type: MessageReceiptType.View, + type: MessageReceipts.MessageReceiptType.View, }); } @@ -3176,7 +3165,9 @@ export async function startApp(): Promise { }: Readonly<{ event: ReadEvent | ViewEvent; logTitle: string; - type: MessageReceiptType.Read | MessageReceiptType.View; + type: + | MessageReceipts.MessageReceiptType.Read + | MessageReceipts.MessageReceiptType.View; }>): void { const { envelopeTimestamp, @@ -3200,8 +3191,6 @@ export async function startApp(): Promise { timestamp ); - event.confirm(); - strictAssert( isServiceIdString(sourceServiceId), 'onReadOrViewReceipt: Missing sourceServiceId' @@ -3209,6 +3198,8 @@ export async function startApp(): Promise { strictAssert(sourceDevice, 'onReadOrViewReceipt: Missing sourceDevice'); const attributes: MessageReceiptAttributesType = { + envelopeId: event.receipt.envelopeId, + removeFromMessageReceiverCache: event.confirm, messageSentAt: timestamp, receiptTimestamp: envelopeTimestamp, sourceConversationId: sourceConversation.id, @@ -3217,13 +3208,10 @@ export async function startApp(): Promise { type, wasSentEncrypted, }; - const receipt = MessageReceipts.getSingleton().add(attributes); - - // Note: We do not wait for completion here - void MessageReceipts.getSingleton().onReceipt(receipt); + drop(MessageReceipts.onReceipt(attributes)); } - function onReadSync(ev: ReadSyncEvent): Promise { + async function onReadSync(ev: ReadSyncEvent): Promise { const { envelopeTimestamp, sender, senderAci, timestamp } = ev.read; const readAt = envelopeTimestamp; const { conversation: senderConversation } = @@ -3249,22 +3237,19 @@ export async function startApp(): Promise { strictAssert(timestamp, 'onReadSync missing timestamp'); const attributes: ReadSyncAttributesType = { + envelopeId: ev.read.envelopeId, + removeFromMessageReceiverCache: ev.confirm, senderId, sender, senderAci, timestamp, readAt, }; - const receipt = ReadSyncs.getSingleton().add(attributes); - receipt.on('remove', ev.confirm); - - // Note: Here we wait, because we want read states to be in the database - // before we move on. - return ReadSyncs.getSingleton().onSync(receipt); + await ReadSyncs.onSync(attributes); } - function onViewSync(ev: ViewSyncEvent): Promise { + async function onViewSync(ev: ViewSyncEvent): Promise { const { envelopeTimestamp, senderE164, senderAci, timestamp } = ev.view; const { conversation: senderConversation } = window.ConversationController.maybeMergeContacts({ @@ -3289,19 +3274,16 @@ export async function startApp(): Promise { strictAssert(timestamp, 'onViewSync missing timestamp'); const attributes: ViewSyncAttributesType = { + envelopeId: ev.view.envelopeId, + removeFromMessageReceiverCache: ev.confirm, senderId, senderE164, senderAci, timestamp, viewedAt: envelopeTimestamp, }; - const receipt = ViewSyncs.getSingleton().add(attributes); - receipt.on('remove', ev.confirm); - - // Note: Here we wait, because we want viewed states to be in the database - // before we move on. - return ViewSyncs.getSingleton().onSync(receipt); + await ViewSyncs.onSync(attributes); } function onDeliveryReceipt(ev: DeliveryEvent): void { @@ -3315,8 +3297,6 @@ export async function startApp(): Promise { wasSentEncrypted, } = deliveryReceipt; - ev.confirm(); - const sourceConversation = window.ConversationController.lookupOrCreate({ serviceId: sourceServiceId, e164: source, @@ -3343,18 +3323,18 @@ export async function startApp(): Promise { strictAssert(sourceDevice, 'onDeliveryReceipt: missing sourceDevice'); const attributes: MessageReceiptAttributesType = { + envelopeId: ev.deliveryReceipt.envelopeId, + removeFromMessageReceiverCache: ev.confirm, messageSentAt: timestamp, receiptTimestamp: envelopeTimestamp, sourceConversationId: sourceConversation?.id, sourceServiceId, sourceDevice, - type: MessageReceiptType.Delivery, + type: MessageReceipts.MessageReceiptType.Delivery, wasSentEncrypted, }; - const receipt = MessageReceipts.getSingleton().add(attributes); - // Note: We don't wait for completion here - void MessageReceipts.getSingleton().onReceipt(receipt); + drop(MessageReceipts.onReceipt(attributes)); } } diff --git a/ts/messageModifiers/Deletes.ts b/ts/messageModifiers/Deletes.ts index e01672f35..71c089008 100644 --- a/ts/messageModifiers/Deletes.ts +++ b/ts/messageModifiers/Deletes.ts @@ -7,7 +7,6 @@ import * as log from '../logging/log'; import * as Errors from '../types/errors'; import { deleteForEveryone } from '../util/deleteForEveryone'; import { drop } from '../util/drop'; -import { filter, size } from '../util/iterables'; import { getMessageSentTimestampSet } from '../util/getMessageSentTimestampSet'; export type DeleteAttributesType = { @@ -20,28 +19,33 @@ export type DeleteAttributesType = { const deletes = new Map(); +function remove(del: DeleteAttributesType): void { + del.removeFromMessageReceiverCache(); + deletes.delete(del.envelopeId); +} + export function forMessage( messageAttributes: MessageAttributesType ): Array { const sentTimestamps = getMessageSentTimestampSet(messageAttributes); - const matchingDeletes = filter(deletes, ([_envelopeId, item]) => { + const deleteValues = Array.from(deletes.values()); + + const matchingDeletes = deleteValues.filter(item => { return ( item.fromId === getContactId(messageAttributes) && sentTimestamps.has(item.targetSentTimestamp) ); }); - if (size(matchingDeletes) > 0) { - log.info('Found early DOE for message'); - const result = Array.from(matchingDeletes); - result.forEach(([envelopeId, del]) => { - del.removeFromMessageReceiverCache(); - deletes.delete(envelopeId); - }); - return result.map(([_envelopeId, item]) => item); + if (!matchingDeletes.length) { + return []; } - return []; + log.info('Found early DOE for message'); + matchingDeletes.forEach(del => { + remove(del); + }); + return matchingDeletes; } export async function onDelete(del: DeleteAttributesType): Promise { @@ -88,11 +92,11 @@ export async function onDelete(del: DeleteAttributesType): Promise { await deleteForEveryone(message, del); - deletes.delete(del.envelopeId); - del.removeFromMessageReceiverCache(); + remove(del); }) ); } catch (error) { + remove(del); log.error(`${logId}: error`, Errors.toLogFormat(error)); } } diff --git a/ts/messageModifiers/Edits.ts b/ts/messageModifiers/Edits.ts index 45ceb369c..d8fde3fa9 100644 --- a/ts/messageModifiers/Edits.ts +++ b/ts/messageModifiers/Edits.ts @@ -5,7 +5,6 @@ import type { MessageAttributesType } from '../model-types.d'; import * as Errors from '../types/errors'; import * as log from '../logging/log'; import { drop } from '../util/drop'; -import { filter, size } from '../util/iterables'; import { getContactId } from '../messages/helpers'; import { handleEditMessage } from '../util/handleEditMessage'; import { getMessageSentTimestamp } from '../util/getMessageSentTimestamp'; @@ -22,6 +21,11 @@ export type EditAttributesType = { const edits = new Map(); +function remove(edit: EditAttributesType): void { + edits.delete(edit.envelopeId); + edit.removeFromMessageReceiverCache(); +} + export function forMessage( messageAttributes: Pick< MessageAttributesType, @@ -34,22 +38,22 @@ export function forMessage( > ): Array { const sentAt = getMessageSentTimestamp(messageAttributes, { log }); - const matchingEdits = filter(edits, ([_envelopeId, item]) => { + const editValues = Array.from(edits.values()); + + const matchingEdits = editValues.filter(item => { return ( item.targetSentTimestamp === sentAt && item.fromId === getContactId(messageAttributes) ); }); - if (size(matchingEdits) > 0) { - const result: Array = []; + if (matchingEdits.length > 0) { const editsLogIds: Array = []; - Array.from(matchingEdits).forEach(([envelopeId, item]) => { - result.push(item); + const result = matchingEdits.map(item => { editsLogIds.push(item.message.sent_at); - edits.delete(envelopeId); - item.removeFromMessageReceiverCache(); + remove(item); + return item; }); log.info( @@ -99,7 +103,6 @@ export async function onEdit(edit: EditAttributesType): Promise { if (!targetMessage) { log.info(`${logId}: No message`); - return; } @@ -110,11 +113,11 @@ export async function onEdit(edit: EditAttributesType): Promise { await handleEditMessage(message.attributes, edit); - edits.delete(edit.envelopeId); - edit.removeFromMessageReceiverCache(); + remove(edit); }) ); } catch (error) { + remove(edit); log.error(`${logId} error:`, Errors.toLogFormat(error)); } } diff --git a/ts/messageModifiers/MessageReceipts.ts b/ts/messageModifiers/MessageReceipts.ts index 6eca8a413..2b13962e4 100644 --- a/ts/messageModifiers/MessageReceipts.ts +++ b/ts/messageModifiers/MessageReceipts.ts @@ -1,10 +1,7 @@ // Copyright 2016 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 type { MessageModel } from '../models/messages'; import type { MessageAttributesType } from '../model-types.d'; @@ -26,6 +23,7 @@ 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; @@ -36,18 +34,18 @@ export enum MessageReceiptType { } export type MessageReceiptAttributesType = { + envelopeId: string; messageSentAt: number; receiptTimestamp: number; - sourceServiceId: ServiceIdString; + removeFromMessageReceiverCache: () => unknown; sourceConversationId: string; sourceDevice: number; + sourceServiceId: ServiceIdString; type: MessageReceiptType; wasSentEncrypted: boolean; }; -class MessageReceiptModel extends Model {} - -let singleton: MessageReceipts | undefined; +const receipts = new Map(); const deleteSentProtoBatcher = createWaitBatcher({ name: 'deleteSentProtoBatcher', @@ -79,6 +77,11 @@ const deleteSentProtoBatcher = createWaitBatcher({ }, }); +function remove(receipt: MessageReceiptAttributesType): void { + receipts.delete(receipt.envelopeId); + receipt.removeFromMessageReceiverCache(); +} + async function getTargetMessage( sourceId: string, serviceId: ServiceIdString, @@ -124,10 +127,10 @@ const wasDeliveredWithSealedSender = ( ); const shouldDropReceipt = ( - receipt: MessageReceiptModel, + receipt: MessageReceiptAttributesType, message: MessageModel ): boolean => { - const type = receipt.get('type'); + const { type } = receipt; switch (type) { case MessageReceiptType.Delivery: return false; @@ -143,248 +146,245 @@ const shouldDropReceipt = ( } }; -export class MessageReceipts extends Collection { - static getSingleton(): MessageReceipts { - if (!singleton) { - singleton = new MessageReceipts(); - } - - return singleton; +export function forMessage( + message: MessageModel +): Array { + if (!isOutgoing(message.attributes) && !isStory(message.attributes)) { + return []; } - 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 ourAci = window.textsecure.storage.user.getCheckedAci(); + const sourceServiceId = getSourceServiceId(message.attributes); + if (ourAci !== sourceServiceId) { + return []; + } - const sentAt = getMessageSentTimestamp(message.attributes, { log }); - const receipts = this.filter( - receipt => receipt.get('messageSentAt') === sentAt - ); - if (receipts.length) { - log.info(`MessageReceipts: found early receipts for message ${sentAt}`); - this.remove(receipts); - } - return receipts.filter(receipt => { - if (shouldDropReceipt(receipt, message)) { - log.info( - `MessageReceipts: Dropping an early receipt ${receipt.get('type')} ` + - `for message ${sentAt}` - ); - return false; - } + const receiptValues = Array.from(receipts.values()); - return true; + 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); }); } - private getNewSendStateByConversationId( - oldSendStateByConversationId: SendStateByConversationId, - receipt: MessageReceiptModel - ): SendStateByConversationId { - const receiptTimestamp = receipt.get('receiptTimestamp'); - const sourceConversationId = receipt.get('sourceConversationId'); - const type = receipt.get('type'); - - 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, - }; - } - - private async updateMessageSendState( - receipt: MessageReceiptModel, - message: MessageModel - ): Promise { - const messageSentAt = receipt.get('messageSentAt'); - + return result.filter(receipt => { if (shouldDropReceipt(receipt, message)) { log.info( - `MessageReceipts: Dropping a receipt ${receipt.get('type')} ` + - `for message ${messageSentAt}` + `${logId}: Dropping an early receipt ${receipt.type} for message ${sentAt}` ); - return; + return false; } - let hasChanges = false; + return true; + }); +} - const editHistory = message.get('editHistory') ?? []; - const newEditHistory = editHistory?.map(edit => { - if (messageSentAt !== edit.timestamp) { - return edit; - } +function getNewSendStateByConversationId( + oldSendStateByConversationId: SendStateByConversationId, + receipt: MessageReceiptAttributesType +): SendStateByConversationId { + const { receiptTimestamp, sourceConversationId, type } = receipt; - const oldSendStateByConversationId = edit.sendStateByConversationId ?? {}; - const newSendStateByConversationId = this.getNewSendStateByConversationId( - oldSendStateByConversationId, - receipt - ); + const oldSendState = getOwn( + oldSendStateByConversationId, + sourceConversationId + ) ?? { status: SendStatus.Sent, updatedAt: undefined }; - return { - ...edit, - sendStateByConversationId: newSendStateByConversationId, - }; - }); - if (!isEqual(newEditHistory, editHistory)) { - message.set('editHistory', newEditHistory); + 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; } - - const editMessageTimestamp = message.get('editMessageTimestamp'); - if ( - messageSentAt === message.get('timestamp') || - messageSentAt === editMessageTimestamp - ) { - const oldSendStateByConversationId = - message.get('sendStateByConversationId') ?? {}; - const newSendStateByConversationId = this.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 = receipt.get('sourceConversationId'); - const type = receipt.get('type'); - - if ( - (type === MessageReceiptType.Delivery && - wasDeliveredWithSealedSender(sourceConversationId, message) && - receipt.get('wasSentEncrypted')) || - type === MessageReceiptType.Read - ) { - const recipient = window.ConversationController.get(sourceConversationId); - const recipientServiceId = recipient?.getServiceId(); - const deviceId = receipt.get('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( - `MessageReceipts.onReceipt: Missing serviceId or deviceId for deliveredTo ${sourceConversationId}` - ); - } - } } - async onReceipt(receipt: MessageReceiptModel): Promise { - const messageSentAt = receipt.get('messageSentAt'); - const sourceConversationId = receipt.get('sourceConversationId'); - const sourceServiceId = receipt.get('sourceServiceId'); - const type = receipt.get('type'); + if (hasChanges) { + queueUpdateMessage(message.attributes); - try { - const messages = await window.Signal.Data.getMessagesBySentAt( - messageSentAt + // 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}` ); - - const message = await getTargetMessage( - sourceConversationId, - sourceServiceId, - messages - ); - - if (message) { - await this.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( - 'MessageReceipts: No message for receipt', - type, - sourceConversationId, - sourceServiceId, - messageSentAt - ); - return; - } - - await Promise.all( - targetMessages.map(msg => { - const model = window.MessageController.register(msg.id, msg); - return this.updateMessageSendState(receipt, model); - }) - ); - } - - this.remove(receipt); - } catch (error) { - log.error('MessageReceipts.onReceipt error:', Errors.toLogFormat(error)); } } } + +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.MessageController.register(msg.id, msg); + return updateMessageSendState(receipt, model); + }) + ); + } + + remove(receipt); + } catch (error) { + remove(receipt); + log.error(`${logId} error:`, Errors.toLogFormat(error)); + } +} diff --git a/ts/messageModifiers/MessageRequests.ts b/ts/messageModifiers/MessageRequests.ts index 3b6bc1d6b..55b823337 100644 --- a/ts/messageModifiers/MessageRequests.ts +++ b/ts/messageModifiers/MessageRequests.ts @@ -1,112 +1,115 @@ // 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 * as log from '../logging/log'; -import * as Errors from '../types/errors'; import type { AciString } from '../types/ServiceId'; +import type { ConversationModel } from '../models/conversations'; +import * as Errors from '../types/errors'; +import * as log from '../logging/log'; +import { drop } from '../util/drop'; +import { getConversationIdForLogging } from '../util/idForLogging'; export type MessageRequestAttributesType = { - threadE164?: string; - threadAci?: AciString; + envelopeId: string; groupV2Id?: string; + removeFromMessageReceiverCache: () => unknown; + threadAci?: AciString; + threadE164?: string; type: number; }; -class MessageRequestModel extends Model {} +const messageRequests = new Map(); -let singleton: MessageRequests | undefined; +function remove(sync: MessageRequestAttributesType): void { + messageRequests.delete(sync.envelopeId); + sync.removeFromMessageReceiverCache(); +} -export class MessageRequests extends Collection { - static getSingleton(): MessageRequests { - if (!singleton) { - singleton = new MessageRequests(); +export function forConversation( + conversation: ConversationModel +): MessageRequestAttributesType | null { + const logId = `MessageRequests.forConversation(${getConversationIdForLogging( + conversation.attributes + )})`; + + const messageRequestValues = Array.from(messageRequests.values()); + + if (conversation.get('e164')) { + const syncByE164 = messageRequestValues.find( + item => item.threadE164 === conversation.get('e164') + ); + if (syncByE164) { + log.info(`${logId}: Found early message request response for E164`); + remove(syncByE164); + return syncByE164; } - - return singleton; } - forConversation(conversation: ConversationModel): MessageRequestModel | null { - if (conversation.get('e164')) { - const syncByE164 = this.findWhere({ - threadE164: conversation.get('e164'), - }); - if (syncByE164) { - log.info( - `Found early message request response for E164 ${conversation.idForLogging()}` - ); - this.remove(syncByE164); - return syncByE164; - } + if (conversation.getServiceId()) { + const syncByServiceId = messageRequestValues.find( + item => item.threadAci === conversation.getServiceId() + ); + if (syncByServiceId) { + log.info(`${logId}: Found early message request response for serviceId`); + remove(syncByServiceId); + return syncByServiceId; } - - if (conversation.getServiceId()) { - const syncByAci = this.findWhere({ - threadAci: conversation.getServiceId(), - }); - if (syncByAci) { - log.info( - `Found early message request response for aci ${conversation.idForLogging()}` - ); - this.remove(syncByAci); - return syncByAci; - } - } - - // V2 group - if (conversation.get('groupId')) { - const syncByGroupId = this.findWhere({ - groupV2Id: conversation.get('groupId'), - }); - if (syncByGroupId) { - log.info( - `Found early message request response for group v2 ID ${conversation.idForLogging()}` - ); - this.remove(syncByGroupId); - return syncByGroupId; - } - } - - return null; } - async onResponse(sync: MessageRequestModel): Promise { - try { - const threadE164 = sync.get('threadE164'); - const threadAci = sync.get('threadAci'); - const groupV2Id = sync.get('groupV2Id'); + // V2 group + if (conversation.get('groupId')) { + const syncByGroupId = messageRequestValues.find( + item => item.groupV2Id === conversation.get('groupId') + ); + if (syncByGroupId) { + log.info(`${logId}: Found early message request response for gv2`); + remove(syncByGroupId); + return syncByGroupId; + } + } - let conversation; + return null; +} - // We multiplex between GV1/GV2 groups here, but we don't kick off migrations - if (groupV2Id) { - conversation = window.ConversationController.get(groupV2Id); - } - if (!conversation && (threadE164 || threadAci)) { - conversation = window.ConversationController.lookupOrCreate({ - e164: threadE164, - serviceId: threadAci, - reason: 'MessageRequests.onResponse', - }); - } +export async function onResponse( + sync: MessageRequestAttributesType +): Promise { + messageRequests.set(sync.envelopeId, sync); + const { threadE164, threadAci, groupV2Id } = sync; - if (!conversation) { - log.warn( - `Received message request response for unknown conversation: groupv2(${groupV2Id}) ${threadAci} ${threadE164}` - ); - return; - } + const logId = `MessageRequests.onResponse(groupv2(${groupV2Id}) ${threadAci} ${threadE164})`; - void conversation.applyMessageRequestResponse(sync.get('type'), { + try { + let conversation; + + // We multiplex between GV1/GV2 groups here, but we don't kick off migrations + if (groupV2Id) { + conversation = window.ConversationController.get(groupV2Id); + } + if (!conversation && (threadE164 || threadAci)) { + conversation = window.ConversationController.lookupOrCreate({ + e164: threadE164, + serviceId: threadAci, + reason: logId, + }); + } + + if (!conversation) { + log.warn( + `${logId}: received message request response for unknown conversation` + ); + remove(sync); + return; + } + + drop( + conversation.applyMessageRequestResponse(sync.type, { fromSync: true, - }); + }) + ); - this.remove(sync); - } catch (error) { - log.error('MessageRequests.onResponse error:', Errors.toLogFormat(error)); - } + remove(sync); + } catch (error) { + remove(sync); + log.error(`${logId} error:`, Errors.toLogFormat(error)); } } diff --git a/ts/messageModifiers/Reactions.ts b/ts/messageModifiers/Reactions.ts index 4d5ba4727..05f3002a9 100644 --- a/ts/messageModifiers/Reactions.ts +++ b/ts/messageModifiers/Reactions.ts @@ -1,197 +1,220 @@ // 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 { AciString } from '../types/ServiceId'; import type { ConversationModel } from '../models/conversations'; +import type { MessageAttributesType } from '../model-types.d'; import type { MessageModel } from '../models/messages'; -import type { - MessageAttributesType, - ReactionAttributesType, -} from '../model-types.d'; +import type { ReactionSource } from '../reactions/ReactionSource'; import * as Errors from '../types/errors'; import * as log from '../logging/log'; import { getContactId, getContact } from '../messages/helpers'; +import { getMessageIdForLogging } from '../util/idForLogging'; +import { getMessageSentTimestampSet } from '../util/getMessageSentTimestampSet'; 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 {} +export type ReactionAttributesType = { + emoji: string; + envelopeId: string; + fromId: string; + remove?: boolean; + removeFromMessageReceiverCache: () => unknown; + source: ReactionSource; + // Necessary to put 1:1 story replies into the right conversation - not the same + // conversation as the target message! + storyReactionMessage?: MessageModel; + targetAuthorAci: AciString; + targetTimestamp: number; + timestamp: number; +}; -let singleton: Reactions | undefined; +const reactions = new Map(); -export class Reactions extends Collection { - static getSingleton(): Reactions { - if (!singleton) { - singleton = new Reactions(); - } +function remove(reaction: ReactionAttributesType): void { + reactions.delete(reaction.envelopeId); + reaction.removeFromMessageReceiverCache(); +} - return singleton; - } +export function forMessage( + message: MessageModel +): Array { + const logId = `Reactions.forMessage(${getMessageIdForLogging( + message.attributes + )})`; - 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({ - serviceId: re.get('targetAuthorAci'), - 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 + const reactionValues = Array.from(reactions.values()); + const sentTimestamps = getMessageSentTimestampSet(message.attributes); + if (isOutgoing(message.attributes)) { + const outgoingReactions = reactionValues.filter(item => + sentTimestamps.has(item.targetTimestamp) ); - return messages.find(m => { - const contact = getContact(m); - - if (!contact) { - return false; - } - - const mcid = contact.get('id'); - return mcid === targetConversationId; - }); + if (outgoingReactions.length > 0) { + log.info(`${logId}: Found early reaction for outgoing message`); + outgoingReactions.forEach(item => { + remove(item); + }); + return outgoingReactions; + } } - 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({ - serviceId: reaction.get('targetAuthorAci'), - reason: 'Reactions.onReaction', - }); - const targetConversationId = targetAuthorConversation?.id; - if (!targetConversationId) { - throw new Error( - 'onReaction: No conversationId returned from lookupOrCreate!' + const senderId = getContactId(message.attributes); + const reactionsBySource = reactionValues.filter(re => { + const targetSender = window.ConversationController.lookupOrCreate({ + serviceId: re.targetAuthorAci, + reason: logId, + }); + return ( + targetSender?.id === senderId && sentTimestamps.has(re.targetTimestamp) + ); + }); + + if (reactionsBySource.length > 0) { + log.info(`${logId}: Found early reaction for message`); + reactionsBySource.forEach(item => { + remove(item); + item.removeFromMessageReceiverCache(); + }); + return reactionsBySource; + } + + return []; +} + +async function 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; + }); +} + +export async function onReaction( + reaction: ReactionAttributesType +): Promise { + reactions.set(reaction.envelopeId, reaction); + + const logId = `Reactions.onReaction(timestamp=${reaction.timestamp};target=${reaction.targetTimestamp})`; + + 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({ + serviceId: reaction.targetAuthorAci, + reason: logId, + }); + const targetConversationId = targetAuthorConversation?.id; + if (!targetConversationId) { + throw new Error( + `${logId} Error: No conversationId returned from lookupOrCreate!` + ); + } + + const generatedMessage = reaction.storyReactionMessage; + strictAssert( + generatedMessage, + `${logId} strictAssert: Story reactions must provide storyReactionMessage` + ); + const fromConversation = window.ConversationController.get( + generatedMessage.get('conversationId') + ); + + let targetConversation: ConversationModel | undefined | null; + + const targetMessageCheck = await findMessage( + reaction.targetTimestamp, + targetConversationId + ); + if (!targetMessageCheck) { + log.info( + `${logId}: No message for reaction`, + 'targeting', + reaction.targetAuthorAci + ); + return; + } + + if ( + fromConversation && + isStory(targetMessageCheck) && + isDirectConversation(fromConversation.attributes) && + !isMe(fromConversation.attributes) + ) { + targetConversation = fromConversation; + } else { + targetConversation = + await window.ConversationController.getConversationForTargetMessage( + targetConversationId, + reaction.targetTimestamp ); - } + } - const generatedMessage = reaction.get('storyReactionMessage'); - strictAssert( - generatedMessage, - 'Story reactions must provide storyReactionMessage' - ); - const fromConversation = window.ConversationController.get( - generatedMessage.get('conversationId') + if (!targetConversation) { + log.info( + `${logId}: No target conversation for reaction`, + reaction.targetAuthorAci, + reaction.targetTimestamp ); + remove(reaction); + return undefined; + } - 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('targetAuthorAci'), - reaction.get('targetTimestamp') - ); + // awaiting is safe since `onReaction` is never called from inside the queue + await targetConversation.queueJob('Reactions.onReaction', async () => { + log.info(`${logId}: handling`); + // Thanks TS. + if (!targetConversation) { + remove(reaction); return; } - if ( - fromConversation && - isStory(targetMessageCheck) && - isDirectConversation(fromConversation.attributes) && - !isMe(fromConversation.attributes) - ) { - targetConversation = fromConversation; + // Message is fetched inside the conversation queue so we have the + // most recent data + const targetMessage = await findMessage( + reaction.targetTimestamp, + targetConversationId + ); + + if (!targetMessage) { + remove(reaction); + 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 { - targetConversation = - await window.ConversationController.getConversationForTargetMessage( - targetConversationId, - reaction.get('targetTimestamp') - ); + await generatedMessage.handleReaction(reaction, { + storyMessage: targetMessage, + }); } - if (!targetConversation) { - log.info( - 'No target conversation for reaction', - reaction.get('targetAuthorAci'), - 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)); - } + remove(reaction); + }); + } catch (error) { + remove(reaction); + log.error(`${logId} error:`, Errors.toLogFormat(error)); } } diff --git a/ts/messageModifiers/ReadSyncs.ts b/ts/messageModifiers/ReadSyncs.ts index ca0676011..313724085 100644 --- a/ts/messageModifiers/ReadSyncs.ts +++ b/ts/messageModifiers/ReadSyncs.ts @@ -1,50 +1,52 @@ // Copyright 2017 Signal Messenger, LLC // SPDX-License-Identifier: AGPL-3.0-only -/* eslint-disable max-classes-per-file */ - -import { Collection, Model } from 'backbone'; - +import type { AciString } from '../types/ServiceId'; import type { MessageModel } from '../models/messages'; +import * as Errors from '../types/errors'; +import * as log from '../logging/log'; +import { StartupQueue } from '../util/StartupQueue'; +import { getMessageIdForLogging } from '../util/idForLogging'; +import { getMessageSentTimestamp } from '../util/getMessageSentTimestamp'; import { isIncoming } from '../state/selectors/message'; import { isMessageUnread } from '../util/isMessageUnread'; import { notificationService } from '../services/notifications'; -import * as log from '../logging/log'; -import * as Errors from '../types/errors'; -import type { AciString } from '../types/ServiceId'; -import { StartupQueue } from '../util/StartupQueue'; import { queueUpdateMessage } from '../util/messageBatcher'; -import { getMessageSentTimestamp } from '../util/getMessageSentTimestamp'; export type ReadSyncAttributesType = { - senderId: string; + envelopeId: string; + readAt: number; + removeFromMessageReceiverCache: () => unknown; sender?: string; senderAci: AciString; + senderId: string; timestamp: number; - readAt: number; }; -class ReadSyncModel extends Model {} +const readSyncs = new Map(); -let singleton: ReadSyncs | undefined; +function remove(sync: ReadSyncAttributesType): void { + readSyncs.delete(sync.envelopeId); + sync.removeFromMessageReceiverCache(); +} + +async function maybeItIsAReactionReadSync( + sync: ReadSyncAttributesType +): Promise { + const logId = `ReadSyncs.onSync(timestamp=${sync.timestamp})`; -async function maybeItIsAReactionReadSync(sync: ReadSyncModel): Promise { const readReaction = await window.Signal.Data.markReactionAsRead( - sync.get('senderAci'), - Number(sync.get('timestamp')) + sync.senderAci, + Number(sync.timestamp) ); if (!readReaction) { - log.info( - 'Nothing found for read sync', - sync.get('senderId'), - sync.get('sender'), - sync.get('senderAci'), - sync.get('timestamp') - ); + log.info(`${logId} not found:`, sync.senderId, sync.sender, sync.senderAci); return; } + remove(sync); + notificationService.removeBy({ conversationId: readReaction.conversationId, emoji: readReaction.emoji, @@ -53,109 +55,110 @@ async function maybeItIsAReactionReadSync(sync: ReadSyncModel): Promise { }); } -export class ReadSyncs extends Collection { - static getSingleton(): ReadSyncs { - if (!singleton) { - singleton = new ReadSyncs(); - } +export function forMessage( + message: MessageModel +): ReadSyncAttributesType | null { + const logId = `ReadSyncs.forMessage(${getMessageIdForLogging( + message.attributes + )})`; - return singleton; + const sender = window.ConversationController.lookupOrCreate({ + e164: message.get('source'), + serviceId: message.get('sourceServiceId'), + reason: logId, + }); + const messageTimestamp = getMessageSentTimestamp(message.attributes, { + log, + }); + const readSyncValues = Array.from(readSyncs.values()); + const foundSync = readSyncValues.find(item => { + return item.senderId === sender?.id && item.timestamp === messageTimestamp; + }); + if (foundSync) { + log.info( + `${logId}: Found early read sync for message ${foundSync.timestamp}` + ); + remove(foundSync); + return foundSync; } - forMessage(message: MessageModel): ReadSyncModel | null { - const sender = window.ConversationController.lookupOrCreate({ - e164: message.get('source'), - serviceId: message.get('sourceServiceId'), - reason: 'ReadSyncs.forMessage', - }); - const messageTimestamp = getMessageSentTimestamp(message.attributes, { - log, - }); - const sync = this.find(item => { - return ( - item.get('senderId') === sender?.id && - item.get('timestamp') === messageTimestamp - ); - }); - if (sync) { - log.info(`Found early read sync for message ${sync.get('timestamp')}`); - this.remove(sync); - return sync; - } + return null; +} - return null; - } +export async function onSync(sync: ReadSyncAttributesType): Promise { + readSyncs.set(sync.envelopeId, sync); - async onSync(sync: ReadSyncModel): Promise { - try { - const messages = await window.Signal.Data.getMessagesBySentAt( - sync.get('timestamp') - ); + const logId = `ReadSyncs.onSync(timestamp=${sync.timestamp})`; - const found = messages.find(item => { - const sender = window.ConversationController.lookupOrCreate({ - e164: item.source, - serviceId: item.sourceServiceId, - reason: 'ReadSyncs.onSync', - }); + try { + const messages = await window.Signal.Data.getMessagesBySentAt( + sync.timestamp + ); - return isIncoming(item) && sender?.id === sync.get('senderId'); + const found = messages.find(item => { + const sender = window.ConversationController.lookupOrCreate({ + e164: item.source, + serviceId: item.sourceServiceId, + reason: logId, }); - if (!found) { - await maybeItIsAReactionReadSync(sync); - return; - } + return isIncoming(item) && sender?.id === sync.senderId; + }); - notificationService.removeBy({ messageId: found.id }); + if (!found) { + await maybeItIsAReactionReadSync(sync); + return; + } - const message = window.MessageController.register(found.id, found); - const readAt = Math.min(sync.get('readAt'), Date.now()); + notificationService.removeBy({ messageId: found.id }); - // If message is unread, we mark it read. Otherwise, we update the expiration - // timer to the time specified by the read sync if it's earlier than - // the previous read time. - if (isMessageUnread(message.attributes)) { - // TODO DESKTOP-1509: use MessageUpdater.markRead once this is TS - message.markRead(readAt, { skipSave: true }); + const message = window.MessageController.register(found.id, found); + const readAt = Math.min(sync.readAt, Date.now()); - const updateConversation = async () => { - // onReadMessage may result in messages older than this one being - // marked read. We want those messages to have the same expire timer - // start time as this one, so we pass the readAt value through. - void message.getConversation()?.onReadMessage(message, readAt); - }; + // If message is unread, we mark it read. Otherwise, we update the expiration + // timer to the time specified by the read sync if it's earlier than + // the previous read time. + if (isMessageUnread(message.attributes)) { + // TODO DESKTOP-1509: use MessageUpdater.markRead once this is TS + message.markRead(readAt, { skipSave: true }); - // only available during initialization - if (StartupQueue.isAvailable()) { - const conversation = message.getConversation(); - if (conversation) { - StartupQueue.add( - conversation.get('id'), - message.get('sent_at'), - updateConversation - ); - } - } else { - // not awaiting since we don't want to block work happening in the - // eventHandlerQueue - void updateConversation(); + const updateConversation = async () => { + // onReadMessage may result in messages older than this one being + // marked read. We want those messages to have the same expire timer + // start time as this one, so we pass the readAt value through. + void message.getConversation()?.onReadMessage(message, readAt); + }; + + // only available during initialization + if (StartupQueue.isAvailable()) { + const conversation = message.getConversation(); + if (conversation) { + StartupQueue.add( + conversation.get('id'), + message.get('sent_at'), + updateConversation + ); } } else { - const now = Date.now(); - const existingTimestamp = message.get('expirationStartTimestamp'); - const expirationStartTimestamp = Math.min( - now, - Math.min(existingTimestamp || now, readAt || now) - ); - message.set({ expirationStartTimestamp }); + // not awaiting since we don't want to block work happening in the + // eventHandlerQueue + void updateConversation(); } - - queueUpdateMessage(message.attributes); - - this.remove(sync); - } catch (error) { - log.error('ReadSyncs.onSync error:', Errors.toLogFormat(error)); + } else { + const now = Date.now(); + const existingTimestamp = message.get('expirationStartTimestamp'); + const expirationStartTimestamp = Math.min( + now, + Math.min(existingTimestamp || now, readAt || now) + ); + message.set({ expirationStartTimestamp }); } + + queueUpdateMessage(message.attributes); + + remove(sync); + } catch (error) { + remove(sync); + log.error(`${logId} error:`, Errors.toLogFormat(error)); } } diff --git a/ts/messageModifiers/ViewOnceOpenSyncs.ts b/ts/messageModifiers/ViewOnceOpenSyncs.ts index 9dd11d954..0b8f20b7e 100644 --- a/ts/messageModifiers/ViewOnceOpenSyncs.ts +++ b/ts/messageModifiers/ViewOnceOpenSyncs.ts @@ -1,100 +1,108 @@ // Copyright 2019 Signal Messenger, LLC // SPDX-License-Identifier: AGPL-3.0-only -/* eslint-disable max-classes-per-file */ - -import { Collection, Model } from 'backbone'; -import type { MessageModel } from '../models/messages'; -import * as log from '../logging/log'; -import * as Errors from '../types/errors'; import type { AciString } from '../types/ServiceId'; +import type { MessageModel } from '../models/messages'; +import * as Errors from '../types/errors'; +import * as log from '../logging/log'; +import { getMessageIdForLogging } from '../util/idForLogging'; export type ViewOnceOpenSyncAttributesType = { + removeFromMessageReceiverCache: () => unknown; source?: string; sourceAci: AciString; timestamp: number; }; -class ViewOnceOpenSyncModel extends Model {} +const viewOnceSyncs = new Map(); -let singleton: ViewOnceOpenSyncs | undefined; +function remove(sync: ViewOnceOpenSyncAttributesType): void { + viewOnceSyncs.delete(sync.timestamp); + sync.removeFromMessageReceiverCache(); +} -export class ViewOnceOpenSyncs extends Collection { - static getSingleton(): ViewOnceOpenSyncs { - if (!singleton) { - singleton = new ViewOnceOpenSyncs(); - } +export function forMessage( + message: MessageModel +): ViewOnceOpenSyncAttributesType | null { + const logId = `ViewOnceOpenSyncs.forMessage(${getMessageIdForLogging( + message.attributes + )})`; - return singleton; + const viewOnceSyncValues = Array.from(viewOnceSyncs.values()); + + const syncBySourceServiceId = viewOnceSyncValues.find(item => { + return ( + item.sourceAci === message.get('sourceServiceId') && + item.timestamp === message.get('sent_at') + ); + }); + + if (syncBySourceServiceId) { + log.info(`${logId}: Found early view once open sync for message`); + remove(syncBySourceServiceId); + return syncBySourceServiceId; } - forMessage(message: MessageModel): ViewOnceOpenSyncModel | null { - const syncBySourceAci = this.find(item => { - return ( - item.get('sourceAci') === message.get('sourceServiceId') && - item.get('timestamp') === message.get('sent_at') - ); - }); - if (syncBySourceAci) { - log.info('Found early view once open sync for message'); - this.remove(syncBySourceAci); - return syncBySourceAci; - } - - const syncBySource = this.find(item => { - return ( - item.get('source') === message.get('source') && - item.get('timestamp') === message.get('sent_at') - ); - }); - if (syncBySource) { - log.info('Found early view once open sync for message'); - this.remove(syncBySource); - return syncBySource; - } - - return null; + const syncBySource = viewOnceSyncValues.find(item => { + return ( + item.source === message.get('source') && + item.timestamp === message.get('sent_at') + ); + }); + if (syncBySource) { + log.info(`${logId}: Found early view once open sync for message`); + remove(syncBySource); + return syncBySource; } - async onSync(sync: ViewOnceOpenSyncModel): Promise { - try { - const messages = await window.Signal.Data.getMessagesBySentAt( - sync.get('timestamp') + return null; +} + +export async function onSync( + sync: ViewOnceOpenSyncAttributesType +): Promise { + viewOnceSyncs.set(sync.timestamp, sync); + + const logId = `ViewOnceOpenSyncs.onSync(timestamp=${sync.timestamp})`; + + try { + const messages = await window.Signal.Data.getMessagesBySentAt( + sync.timestamp + ); + + const found = messages.find(item => { + const itemSourceAci = item.sourceServiceId; + const syncSourceAci = sync.sourceAci; + const itemSource = item.source; + const syncSource = sync.source; + + return Boolean( + (itemSourceAci && syncSourceAci && itemSourceAci === syncSourceAci) || + (itemSource && syncSource && itemSource === syncSource) ); + }); - const found = messages.find(item => { - const itemSourceAci = item.sourceServiceId; - const syncSourceAci = sync.get('sourceAci'); - const itemSource = item.source; - const syncSource = sync.get('source'); + const syncSource = sync.source; + const syncSourceAci = sync.sourceAci; + const syncTimestamp = sync.timestamp; + const wasMessageFound = Boolean(found); + log.info(`${logId} receive:`, { + syncSource, + syncSourceAci, + syncTimestamp, + wasMessageFound, + }); - return Boolean( - (itemSourceAci && syncSourceAci && itemSourceAci === syncSourceAci) || - (itemSource && syncSource && itemSource === syncSource) - ); - }); - - const syncSource = sync.get('source'); - const syncSourceAci = sync.get('sourceAci'); - const syncTimestamp = sync.get('timestamp'); - const wasMessageFound = Boolean(found); - log.info('Receive view once open sync:', { - syncSource, - syncSourceAci, - syncTimestamp, - wasMessageFound, - }); - - if (!found) { - return; - } - - const message = window.MessageController.register(found.id, found); - await message.markViewOnceMessageViewed({ fromSync: true }); - - this.remove(sync); - } catch (error) { - log.error('ViewOnceOpenSyncs.onSync error:', Errors.toLogFormat(error)); + if (!found) { + return; } + + const message = window.MessageController.register(found.id, found); + await message.markViewOnceMessageViewed({ fromSync: true }); + + viewOnceSyncs.delete(sync.timestamp); + sync.removeFromMessageReceiverCache(); + } catch (error) { + log.error(`${logId} error:`, Errors.toLogFormat(error)); } } diff --git a/ts/messageModifiers/ViewSyncs.ts b/ts/messageModifiers/ViewSyncs.ts index 876e893c9..d605950b8 100644 --- a/ts/messageModifiers/ViewSyncs.ts +++ b/ts/messageModifiers/ViewSyncs.ts @@ -1,136 +1,142 @@ // Copyright 2021 Signal Messenger, LLC // SPDX-License-Identifier: AGPL-3.0-only -/* eslint-disable max-classes-per-file */ - -import { Collection, Model } from 'backbone'; - -import type { MessageModel } from '../models/messages'; -import { ReadStatus } from '../messages/MessageReadStatus'; -import { markViewed } from '../services/MessageUpdater'; -import { isDownloaded } from '../types/Attachment'; import type { AciString } from '../types/ServiceId'; +import type { MessageModel } from '../models/messages'; import * as Errors from '../types/errors'; -import { isIncoming } from '../state/selectors/message'; -import { notificationService } from '../services/notifications'; -import { queueAttachmentDownloads } from '../util/queueAttachmentDownloads'; import * as log from '../logging/log'; import { GiftBadgeStates } from '../components/conversation/Message'; -import { queueUpdateMessage } from '../util/messageBatcher'; +import { ReadStatus } from '../messages/MessageReadStatus'; +import { getMessageIdForLogging } from '../util/idForLogging'; import { getMessageSentTimestamp } from '../util/getMessageSentTimestamp'; +import { isDownloaded } from '../types/Attachment'; +import { isIncoming } from '../state/selectors/message'; +import { markViewed } from '../services/MessageUpdater'; +import { notificationService } from '../services/notifications'; +import { queueAttachmentDownloads } from '../util/queueAttachmentDownloads'; +import { queueUpdateMessage } from '../util/messageBatcher'; export type ViewSyncAttributesType = { - senderId: string; - senderE164?: string; + envelopeId: string; + removeFromMessageReceiverCache: () => unknown; senderAci: AciString; + senderE164?: string; + senderId: string; timestamp: number; viewedAt: number; }; -class ViewSyncModel extends Model {} +const viewSyncs = new Map(); -let singleton: ViewSyncs | undefined; +function remove(sync: ViewSyncAttributesType): void { + viewSyncs.delete(sync.envelopeId); + sync.removeFromMessageReceiverCache(); +} -export class ViewSyncs extends Collection { - static getSingleton(): ViewSyncs { - if (!singleton) { - singleton = new ViewSyncs(); - } +export function forMessage( + message: MessageModel +): Array { + const logId = `ViewSyncs.forMessage(${getMessageIdForLogging( + message.attributes + )})`; - return singleton; + const sender = window.ConversationController.lookupOrCreate({ + e164: message.get('source'), + serviceId: message.get('sourceServiceId'), + reason: logId, + }); + const messageTimestamp = getMessageSentTimestamp(message.attributes, { + log, + }); + + const viewSyncValues = Array.from(viewSyncs.values()); + + const matchingSyncs = viewSyncValues.filter(item => { + return item.senderId === sender?.id && item.timestamp === messageTimestamp; + }); + + if (matchingSyncs.length > 0) { + log.info( + `${logId}: Found ${matchingSyncs.length} early view sync(s) for message ${messageTimestamp}` + ); } + matchingSyncs.forEach(sync => { + remove(sync); + }); - forMessage(message: MessageModel): Array { - const sender = window.ConversationController.lookupOrCreate({ - e164: message.get('source'), - serviceId: message.get('sourceServiceId'), - reason: 'ViewSyncs.forMessage', - }); - const messageTimestamp = getMessageSentTimestamp(message.attributes, { - log, - }); - const syncs = this.filter(item => { - return ( - item.get('senderId') === sender?.id && - item.get('timestamp') === messageTimestamp - ); - }); - if (syncs.length) { - log.info( - `Found ${syncs.length} early view sync(s) for message ${messageTimestamp}` - ); - this.remove(syncs); - } - return syncs; - } + return matchingSyncs; +} - async onSync(sync: ViewSyncModel): Promise { - try { - const messages = await window.Signal.Data.getMessagesBySentAt( - sync.get('timestamp') - ); +export async function onSync(sync: ViewSyncAttributesType): Promise { + viewSyncs.set(sync.envelopeId, sync); - const found = messages.find(item => { - const sender = window.ConversationController.lookupOrCreate({ - e164: item.source, - serviceId: item.sourceServiceId, - reason: 'ViewSyncs.onSync', - }); + const logId = `ViewSyncs.onSync(timestamp=${sync.timestamp})`; - return sender?.id === sync.get('senderId'); + try { + const messages = await window.Signal.Data.getMessagesBySentAt( + sync.timestamp + ); + + const found = messages.find(item => { + const sender = window.ConversationController.lookupOrCreate({ + e164: item.source, + serviceId: item.sourceServiceId, + reason: logId, }); - if (!found) { - log.info( - 'Nothing found for view sync', - sync.get('senderId'), - sync.get('senderE164'), - sync.get('senderAci'), - sync.get('timestamp') + return sender?.id === sync.senderId; + }); + + if (!found) { + log.info( + `${logId}: nothing found`, + sync.senderId, + sync.senderE164, + sync.senderAci + ); + return; + } + + notificationService.removeBy({ messageId: found.id }); + + const message = window.MessageController.register(found.id, found); + let didChangeMessage = false; + + if (message.get('readStatus') !== ReadStatus.Viewed) { + didChangeMessage = true; + message.set(markViewed(message.attributes, sync.viewedAt)); + + const attachments = message.get('attachments'); + if (!attachments?.every(isDownloaded)) { + const updatedFields = await queueAttachmentDownloads( + message.attributes ); - return; - } - - notificationService.removeBy({ messageId: found.id }); - - const message = window.MessageController.register(found.id, found); - let didChangeMessage = false; - - if (message.get('readStatus') !== ReadStatus.Viewed) { - didChangeMessage = true; - message.set(markViewed(message.attributes, sync.get('viewedAt'))); - - const attachments = message.get('attachments'); - if (!attachments?.every(isDownloaded)) { - const updatedFields = await queueAttachmentDownloads( - message.attributes - ); - if (updatedFields) { - message.set(updatedFields); - } + if (updatedFields) { + message.set(updatedFields); } } - - const giftBadge = message.get('giftBadge'); - if (giftBadge) { - didChangeMessage = true; - message.set({ - giftBadge: { - ...giftBadge, - state: isIncoming(message.attributes) - ? GiftBadgeStates.Redeemed - : GiftBadgeStates.Opened, - }, - }); - } - - if (didChangeMessage) { - queueUpdateMessage(message.attributes); - } - - this.remove(sync); - } catch (error) { - log.error('ViewSyncs.onSync error:', Errors.toLogFormat(error)); } + + const giftBadge = message.get('giftBadge'); + if (giftBadge) { + didChangeMessage = true; + message.set({ + giftBadge: { + ...giftBadge, + state: isIncoming(message.attributes) + ? GiftBadgeStates.Redeemed + : GiftBadgeStates.Opened, + }, + }); + } + + if (didChangeMessage) { + queueUpdateMessage(message.attributes); + } + + remove(sync); + } catch (error) { + remove(sync); + log.error(`${logId} error:`, Errors.toLogFormat(error)); } } diff --git a/ts/model-types.d.ts b/ts/model-types.d.ts index 57a98f945..c0867f34a 100644 --- a/ts/model-types.d.ts +++ b/ts/model-types.d.ts @@ -23,7 +23,6 @@ import { SignalService as Proto } from './protobuf'; import type { AvatarDataType } from './types/Avatar'; import type { AciString, PniString, ServiceIdString } from './types/ServiceId'; import type { StoryDistributionIdString } from './types/StoryDistributionId'; -import type { ReactionSource } from './reactions/ReactionSource'; import type { SeenStatus } from './MessageSeenStatus'; import type { GiftBadgeStates } from './components/conversation/Message'; import type { LinkPreviewType } from './types/message/LinkPreviews'; @@ -507,16 +506,3 @@ export declare class ConversationModelCollectionType extends Backbone.Collection } export declare class MessageModelCollectionType extends Backbone.Collection {} - -export type ReactionAttributesType = { - emoji: string; - fromId: string; - remove?: boolean; - source: ReactionSource; - // Necessary to put 1:1 story replies into the right conversation - not the same - // conversation as the target message! - storyReactionMessage?: MessageModel; - targetAuthorAci: AciString; - targetTimestamp: number; - timestamp: number; -}; diff --git a/ts/models/conversations.ts b/ts/models/conversations.ts index e5689d206..7e495d022 100644 --- a/ts/models/conversations.ts +++ b/ts/models/conversations.ts @@ -129,7 +129,7 @@ import { conversationJobQueue, conversationQueueJobEnum, } from '../jobs/conversationJobQueue'; -import type { ReactionModel } from '../messageModifiers/Reactions'; +import type { ReactionAttributesType } from '../messageModifiers/Reactions'; import { isAnnouncementGroupReady } from '../util/isAnnouncementGroupReady'; import { getProfile } from '../util/getProfile'; import { SEALED_SENDER } from '../types/SealedSender'; @@ -4900,7 +4900,7 @@ export class ConversationModel extends window.Backbone async notify( message: Readonly, - reaction?: Readonly + reaction?: Readonly ): Promise { // As a performance optimization don't perform any work if notifications are // disabled. @@ -4938,7 +4938,7 @@ export class ConversationModel extends window.Backbone const isMessageInDirectConversation = isDirectConversation(this.attributes); const sender = reaction - ? window.ConversationController.get(reaction.get('fromId')) + ? window.ConversationController.get(reaction.fromId) : getContact(message.attributes); const senderName = sender ? sender.getTitle() @@ -4967,7 +4967,13 @@ export class ConversationModel extends window.Backbone isExpiringMessage, message: message.getNotificationText(), messageId, - reaction: reaction ? reaction.toJSON() : null, + reaction: reaction + ? { + emoji: reaction.emoji, + targetAuthorAci: reaction.targetAuthorAci, + targetTimestamp: reaction.targetTimestamp, + } + : undefined, sentAt: message.get('timestamp'), type: reaction ? NotificationType.Reaction : NotificationType.Message, }); diff --git a/ts/models/messages.ts b/ts/models/messages.ts index 2dbbe8d1a..5097b886e 100644 --- a/ts/models/messages.ts +++ b/ts/models/messages.ts @@ -112,7 +112,7 @@ import { getCallSelector, getActiveCall, } from '../state/selectors/calling'; -import type { ReactionModel } from '../messageModifiers/Reactions'; +import type { ReactionAttributesType } from '../messageModifiers/Reactions'; import { ReactionSource } from '../reactions/ReactionSource'; import * as LinkPreview from '../types/LinkPreview'; import { SignalService as Proto } from '../protobuf'; @@ -2922,7 +2922,7 @@ export class MessageModel extends window.Backbone.Model { } async handleReaction( - reaction: ReactionModel, + reaction: ReactionAttributesType, { storyMessage, shouldPersist = true, @@ -2955,22 +2955,21 @@ export class MessageModel extends window.Backbone.Model { return; } - const isFromThisDevice = - reaction.get('source') === ReactionSource.FromThisDevice; - const isFromSync = reaction.get('source') === ReactionSource.FromSync; + const isFromThisDevice = reaction.source === ReactionSource.FromThisDevice; + const isFromSync = reaction.source === ReactionSource.FromSync; const isFromSomeoneElse = - reaction.get('source') === ReactionSource.FromSomeoneElse; + 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.get('remove') ? undefined : reaction.get('emoji'), - fromId: reaction.get('fromId'), - targetAuthorAci: reaction.get('targetAuthorAci'), - targetTimestamp: reaction.get('targetTimestamp'), - timestamp: reaction.get('timestamp'), + emoji: reaction.remove ? undefined : reaction.emoji, + fromId: reaction.fromId, + targetAuthorAci: reaction.targetAuthorAci, + targetTimestamp: reaction.targetTimestamp, + timestamp: reaction.timestamp, isSentByConversationId: isFromThisDevice ? zipObject(conversation.getMemberConversationIds(), repeat(false)) : undefined, @@ -2997,7 +2996,7 @@ export class MessageModel extends window.Backbone.Model { ); } - const generatedMessage = reaction.get('storyReactionMessage'); + const generatedMessage = reaction.storyReactionMessage; strictAssert( generatedMessage, 'Story reactions must provide storyReactionMessage' @@ -3016,9 +3015,9 @@ export class MessageModel extends window.Backbone.Model { : undefined, storyId: storyMessage.id, storyReaction: { - emoji: reaction.get('emoji'), - targetAuthorAci: reaction.get('targetAuthorAci'), - targetTimestamp: reaction.get('targetTimestamp'), + emoji: reaction.emoji, + targetAuthorAci: reaction.targetAuthorAci, + targetTimestamp: reaction.targetTimestamp, }, }); @@ -3036,8 +3035,8 @@ export class MessageModel extends window.Backbone.Model { generatedMessage.attributes ), storyId: getMessageIdForLogging(storyMessage), - targetTimestamp: reaction.get('targetTimestamp'), - timestamp: reaction.get('timestamp'), + targetTimestamp: reaction.targetTimestamp, + timestamp: reaction.timestamp, }); const messageToAdd = window.MessageController.register( @@ -3091,7 +3090,7 @@ export class MessageModel extends window.Backbone.Model { this.clearNotifications(oldReaction); } - if (reaction.get('remove')) { + if (reaction.remove) { log.info( 'handleReaction: removing reaction for message', this.idForLogging() @@ -3101,7 +3100,7 @@ export class MessageModel extends window.Backbone.Model { reactions = oldReactions.filter( re => !isNewReactionReplacingPrevious(re, newReaction) || - re.timestamp > reaction.get('timestamp') + re.timestamp > reaction.timestamp ); } else { reactions = oldReactions.filter( @@ -3111,10 +3110,10 @@ export class MessageModel extends window.Backbone.Model { this.set({ reactions }); await window.Signal.Data.removeReactionFromConversation({ - emoji: reaction.get('emoji'), - fromId: reaction.get('fromId'), - targetAuthorServiceId: reaction.get('targetAuthorAci'), - targetTimestamp: reaction.get('targetTimestamp'), + emoji: reaction.emoji, + fromId: reaction.fromId, + targetAuthorServiceId: reaction.targetAuthorAci, + targetTimestamp: reaction.targetTimestamp, }); } else { log.info( @@ -3126,9 +3125,7 @@ export class MessageModel extends window.Backbone.Model { if (isFromSync) { const ourReactions = [ newReaction, - ...oldReactions.filter( - re => re.fromId === reaction.get('fromId') - ), + ...oldReactions.filter(re => re.fromId === reaction.fromId), ]; reactionToAdd = maxBy(ourReactions, 'timestamp') || newReaction; } else { @@ -3136,7 +3133,7 @@ export class MessageModel extends window.Backbone.Model { } reactions = oldReactions.filter( - re => !isNewReactionReplacingPrevious(re, reaction.attributes) + re => !isNewReactionReplacingPrevious(re, reaction) ); reactions.push(reactionToAdd); this.set({ reactions }); @@ -3147,12 +3144,12 @@ export class MessageModel extends window.Backbone.Model { await window.Signal.Data.addReaction({ conversationId: this.get('conversationId'), - emoji: reaction.get('emoji'), - fromId: reaction.get('fromId'), + emoji: reaction.emoji, + fromId: reaction.fromId, messageId: this.id, messageReceivedAt: this.get('received_at'), - targetAuthorAci: reaction.get('targetAuthorAci'), - targetTimestamp: reaction.get('targetTimestamp'), + targetAuthorAci: reaction.targetAuthorAci, + targetTimestamp: reaction.targetTimestamp, }); } } @@ -3173,7 +3170,7 @@ export class MessageModel extends window.Backbone.Model { 'New story reaction must have an emoji' ); - const generatedMessage = reaction.get('storyReactionMessage'); + const generatedMessage = reaction.storyReactionMessage; strictAssert( generatedMessage, 'Story reactions must provide storyReactionmessage' diff --git a/ts/reactions/enqueueReactionForSend.ts b/ts/reactions/enqueueReactionForSend.ts index f477ffa74..c4b42bd08 100644 --- a/ts/reactions/enqueueReactionForSend.ts +++ b/ts/reactions/enqueueReactionForSend.ts @@ -1,9 +1,10 @@ // Copyright 2021 Signal Messenger, LLC // SPDX-License-Identifier: AGPL-3.0-only +import noop from 'lodash/noop'; import { v4 as generateUuid } from 'uuid'; -import { ReactionModel } from '../messageModifiers/Reactions'; +import type { ReactionAttributesType } from '../messageModifiers/Reactions'; import { ReactionSource } from './ReactionSource'; import { getMessageById } from '../messages/getMessageById'; import { getSourceServiceId, isStory } from '../messages/helpers'; @@ -98,7 +99,9 @@ export async function enqueueReactionForSend({ }) : undefined; - const reaction = new ReactionModel({ + const reaction: ReactionAttributesType = { + envelopeId: generateUuid(), + removeFromMessageReceiverCache: noop, emoji, fromId: window.ConversationController.getOurConversationIdOrThrow(), remove, @@ -107,7 +110,7 @@ export async function enqueueReactionForSend({ targetAuthorAci, targetTimestamp, timestamp, - }); + }; await message.handleReaction(reaction, { storyMessage }); } diff --git a/ts/textsecure/MessageReceiver.ts b/ts/textsecure/MessageReceiver.ts index ba7260df9..da00c9926 100644 --- a/ts/textsecure/MessageReceiver.ts +++ b/ts/textsecure/MessageReceiver.ts @@ -1675,6 +1675,7 @@ export default class MessageReceiver getEnvelopeId(envelope), new DeliveryEvent( { + envelopeId: envelope.id, timestamp: envelope.timestamp, envelopeTimestamp: envelope.timestamp, source: envelope.source, @@ -2857,6 +2858,7 @@ export default class MessageReceiver receiptMessage.timestamp.map(async rawTimestamp => { const ev = new EventClass( { + envelopeId: envelope.id, timestamp: rawTimestamp?.toNumber(), envelopeTimestamp: envelope.timestamp, source: envelope.source, @@ -3255,6 +3257,7 @@ export default class MessageReceiver const ev = new MessageRequestResponseEvent( { + envelopeId: envelope.id, threadE164: dropNull(sync.threadE164), threadAci: sync.threadAci ? normalizeAci( @@ -3393,6 +3396,7 @@ export default class MessageReceiver for (const { timestamp, sender, senderAci } of read) { const ev = new ReadSyncEvent( { + envelopeId: envelope.id, envelopeTimestamp: envelope.timestamp, timestamp: timestamp?.toNumber(), sender: dropNull(sender), @@ -3420,6 +3424,7 @@ export default class MessageReceiver viewed.map(async ({ timestamp, senderE164, senderAci }) => { const ev = new ViewSyncEvent( { + envelopeId: envelope.id, envelopeTimestamp: envelope.timestamp, timestamp: timestamp?.toNumber(), senderE164: dropNull(senderE164), diff --git a/ts/textsecure/messageReceiverEvents.ts b/ts/textsecure/messageReceiverEvents.ts index d7b64cb26..c73363df7 100644 --- a/ts/textsecure/messageReceiverEvents.ts +++ b/ts/textsecure/messageReceiverEvents.ts @@ -110,6 +110,7 @@ export class ConfirmableEvent extends Event { } export type DeliveryEventData = Readonly<{ + envelopeId: string; timestamp: number; envelopeTimestamp: number; source?: string; @@ -240,6 +241,7 @@ export class MessageEvent extends ConfirmableEvent { } export type ReadOrViewEventData = Readonly<{ + envelopeId: string; timestamp: number; envelopeTimestamp: number; source?: string; @@ -301,6 +303,7 @@ export class ViewOnceOpenSyncEvent extends ConfirmableEvent { } export type MessageRequestResponseOptions = { + envelopeId: string; threadE164?: string; threadAci?: AciString; messageRequestResponseType: Proto.SyncMessage.IMessageRequestResponse['type']; @@ -319,8 +322,11 @@ export class MessageRequestResponseEvent extends ConfirmableEvent { public readonly groupV2Id?: string; + public readonly envelopeId?: string; + constructor( { + envelopeId, threadE164, threadAci, messageRequestResponseType, @@ -331,6 +337,7 @@ export class MessageRequestResponseEvent extends ConfirmableEvent { ) { super('messageRequestResponse', confirm); + this.envelopeId = envelopeId; this.threadE164 = threadE164; this.threadAci = threadAci; this.messageRequestResponseType = messageRequestResponseType; @@ -374,6 +381,7 @@ export class StickerPackEvent extends ConfirmableEvent { } export type ReadSyncEventData = Readonly<{ + envelopeId: string; timestamp?: number; envelopeTimestamp: number; sender?: string; @@ -390,6 +398,7 @@ export class ReadSyncEvent extends ConfirmableEvent { } export type ViewSyncEventData = Readonly<{ + envelopeId: string; timestamp?: number; envelopeTimestamp: number; senderE164?: string; diff --git a/ts/util/modifyTargetMessage.ts b/ts/util/modifyTargetMessage.ts index a764aa56c..7c2dfbcf5 100644 --- a/ts/util/modifyTargetMessage.ts +++ b/ts/util/modifyTargetMessage.ts @@ -9,17 +9,14 @@ import type { SendStateByConversationId } from '../messages/MessageSendState'; import * as Edits from '../messageModifiers/Edits'; import * as log from '../logging/log'; import * as Deletes from '../messageModifiers/Deletes'; -import { - MessageReceipts, - MessageReceiptType, -} from '../messageModifiers/MessageReceipts'; -import { Reactions } from '../messageModifiers/Reactions'; +import * as MessageReceipts from '../messageModifiers/MessageReceipts'; +import * as Reactions from '../messageModifiers/Reactions'; +import * as ReadSyncs from '../messageModifiers/ReadSyncs'; +import * as ViewOnceOpenSyncs from '../messageModifiers/ViewOnceOpenSyncs'; +import * as ViewSyncs from '../messageModifiers/ViewSyncs'; import { ReadStatus } from '../messages/MessageReadStatus'; -import { ReadSyncs } from '../messageModifiers/ReadSyncs'; import { SeenStatus } from '../MessageSeenStatus'; import { SendActionType, sendStateReducer } from '../messages/MessageSendState'; -import { ViewOnceOpenSyncs } from '../messageModifiers/ViewOnceOpenSyncs'; -import { ViewSyncs } from '../messageModifiers/ViewSyncs'; import { canConversationBeUnarchived } from './canConversationBeUnarchived'; import { deleteForEveryone } from './deleteForEveryone'; import { handleEditMessage } from './handleEditMessage'; @@ -48,33 +45,31 @@ export async function modifyTargetMessage( const sourceServiceId = getSourceServiceId(message.attributes); if (type === 'outgoing' || (type === 'story' && ourAci === sourceServiceId)) { - const sendActions = MessageReceipts.getSingleton() - .forMessage(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; - case MessageReceiptType.View: - sendActionType = SendActionType.GotViewedReceipt; - break; - default: - throw missingCaseError(receiptType); - } + const sendActions = MessageReceipts.forMessage(message).map(receipt => { + let sendActionType: SendActionType; + const receiptType = receipt.type; + switch (receiptType) { + case MessageReceipts.MessageReceiptType.Delivery: + sendActionType = SendActionType.GotDeliveryReceipt; + break; + case MessageReceipts.MessageReceiptType.Read: + sendActionType = SendActionType.GotReadReceipt; + break; + case MessageReceipts.MessageReceiptType.View: + sendActionType = SendActionType.GotViewedReceipt; + break; + default: + throw missingCaseError(receiptType); + } - return { - destinationConversationId: receipt.get('sourceConversationId'), - action: { - type: sendActionType, - updatedAt: receipt.get('receiptTimestamp'), - }, - }; - }); + return { + destinationConversationId: receipt.sourceConversationId, + action: { + type: sendActionType, + updatedAt: receipt.receiptTimestamp, + }, + }; + }); const oldSendStateByConversationId = message.get('sendStateByConversationId') || {}; @@ -111,10 +106,10 @@ export async function modifyTargetMessage( if (type === 'incoming') { // In a followup (see DESKTOP-2100), we want to make `ReadSyncs#forMessage` return // an array, not an object. This array wrapping makes that future a bit easier. - const readSync = ReadSyncs.getSingleton().forMessage(message); + const readSync = ReadSyncs.forMessage(message); const readSyncs = readSync ? [readSync] : []; - const viewSyncs = ViewSyncs.getSingleton().forMessage(message); + const viewSyncs = ViewSyncs.forMessage(message); const isGroupStoryReply = isGroup(conversation.attributes) && message.get('storyId'); @@ -122,8 +117,8 @@ export async function modifyTargetMessage( if (readSyncs.length !== 0 || viewSyncs.length !== 0) { const markReadAt = Math.min( Date.now(), - ...readSyncs.map(sync => sync.get('readAt')), - ...viewSyncs.map(sync => sync.get('viewedAt')) + ...readSyncs.map(sync => sync.readAt), + ...viewSyncs.map(sync => sync.viewedAt) ); if (message.get('expireTimer')) { @@ -181,8 +176,7 @@ export async function modifyTargetMessage( // Check for out-of-order view once open syncs if (isTapToView(message.attributes)) { - const viewOnceOpenSync = - ViewOnceOpenSyncs.getSingleton().forMessage(message); + const viewOnceOpenSync = ViewOnceOpenSyncs.forMessage(message); if (viewOnceOpenSync) { await message.markViewOnceMessageViewed({ fromSync: true }); changed = true; @@ -191,7 +185,7 @@ export async function modifyTargetMessage( } if (isStory(message.attributes)) { - const viewSyncs = ViewSyncs.getSingleton().forMessage(message); + const viewSyncs = ViewSyncs.forMessage(message); if (viewSyncs.length !== 0) { message.set({ @@ -202,7 +196,7 @@ export async function modifyTargetMessage( const markReadAt = Math.min( Date.now(), - ...viewSyncs.map(sync => sync.get('viewedAt')) + ...viewSyncs.map(sync => sync.viewedAt) ); message.setPendingMarkRead( Math.min(message.getPendingMarkRead() ?? Date.now(), markReadAt) @@ -220,12 +214,12 @@ export async function modifyTargetMessage( } // Does message message have any pending, previously-received associated reactions? - const reactions = Reactions.getSingleton().forMessage(message); + const reactions = Reactions.forMessage(message); await Promise.all( reactions.map(async reaction => { if (isStory(message.attributes)) { // We don't set changed = true here, because we don't modify the original story - const generatedMessage = reaction.get('storyReactionMessage'); + const generatedMessage = reaction.storyReactionMessage; strictAssert( generatedMessage, 'Story reactions must provide storyReactionMessage'