Do not confirm messages until we have handled them

This commit is contained in:
Josh Perez 2023-08-21 16:08:27 -04:00 committed by GitHub
parent 29aa188c0f
commit 04f716986c
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
16 changed files with 990 additions and 960 deletions

View file

@ -21,10 +21,7 @@ import createTaskWithTimeout, {
resumeTasksWithTimeout, resumeTasksWithTimeout,
reportLongRunningTasks, reportLongRunningTasks,
} from './textsecure/TaskWithTimeout'; } from './textsecure/TaskWithTimeout';
import type { import type { MessageAttributesType } from './model-types.d';
MessageAttributesType,
ReactionAttributesType,
} from './model-types.d';
import * as Bytes from './Bytes'; import * as Bytes from './Bytes';
import * as Timers from './Timers'; import * as Timers from './Timers';
import * as indexedDb from './indexeddb'; import * as indexedDb from './indexeddb';
@ -116,15 +113,13 @@ import { actionCreators } from './state/actions';
import * as Deletes from './messageModifiers/Deletes'; import * as Deletes from './messageModifiers/Deletes';
import type { EditAttributesType } from './messageModifiers/Edits'; import type { EditAttributesType } from './messageModifiers/Edits';
import * as Edits from './messageModifiers/Edits'; import * as Edits from './messageModifiers/Edits';
import { import type { ReactionAttributesType } from './messageModifiers/Reactions';
MessageReceipts, import * as MessageReceipts from './messageModifiers/MessageReceipts';
MessageReceiptType, import * as MessageRequests from './messageModifiers/MessageRequests';
} from './messageModifiers/MessageReceipts'; import * as Reactions from './messageModifiers/Reactions';
import { MessageRequests } from './messageModifiers/MessageRequests'; import * as ReadSyncs from './messageModifiers/ReadSyncs';
import { Reactions } from './messageModifiers/Reactions'; import * as ViewSyncs from './messageModifiers/ViewSyncs';
import { ReadSyncs } from './messageModifiers/ReadSyncs'; import * as ViewOnceOpenSyncs from './messageModifiers/ViewOnceOpenSyncs';
import { ViewSyncs } from './messageModifiers/ViewSyncs';
import { ViewOnceOpenSyncs } from './messageModifiers/ViewOnceOpenSyncs';
import type { DeleteAttributesType } from './messageModifiers/Deletes'; import type { DeleteAttributesType } from './messageModifiers/Deletes';
import type { MessageReceiptAttributesType } from './messageModifiers/MessageReceipts'; import type { MessageReceiptAttributesType } from './messageModifiers/MessageReceipts';
import type { MessageRequestAttributesType } from './messageModifiers/MessageRequests'; import type { MessageRequestAttributesType } from './messageModifiers/MessageRequests';
@ -982,8 +977,7 @@ export async function startApp(): Promise<void> {
optimizeFTS(); optimizeFTS();
// Don't block on the following operation drop(window.Signal.Data.ensureFilePermissions());
void window.Signal.Data.ensureFilePermissions();
} }
setAppLoadingScreenMessage(window.i18n('icu:loading'), window.i18n); setAppLoadingScreenMessage(window.i18n('icu:loading'), window.i18n);
@ -2002,8 +1996,7 @@ export async function startApp(): Promise<void> {
throw new Error('Expected challenge handler to be initialized'); throw new Error('Expected challenge handler to be initialized');
} }
// Intentionally not awaiting drop(challengeHandler.onOnline());
void challengeHandler.onOnline();
reconnectBackOff.reset(); reconnectBackOff.reset();
} finally { } finally {
@ -2464,6 +2457,8 @@ export async function startApp(): Promise<void> {
log.info('Queuing incoming reaction for', reaction.targetTimestamp); log.info('Queuing incoming reaction for', reaction.targetTimestamp);
const attributes: ReactionAttributesType = { const attributes: ReactionAttributesType = {
envelopeId: data.envelopeId,
removeFromMessageReceiverCache: confirm,
emoji: reaction.emoji, emoji: reaction.emoji,
fromId: fromConversation.id, fromId: fromConversation.id,
remove: reaction.remove, remove: reaction.remove,
@ -2473,11 +2468,8 @@ export async function startApp(): Promise<void> {
targetTimestamp: reaction.targetTimestamp, targetTimestamp: reaction.targetTimestamp,
timestamp, timestamp,
}; };
const reactionModel = Reactions.getSingleton().add(attributes);
drop(Reactions.getSingleton().onReaction(reactionModel)); drop(Reactions.onReaction(attributes));
confirm();
return; return;
} }
@ -2548,7 +2540,7 @@ export async function startApp(): Promise<void> {
} }
// Don't wait for handleDataMessage, as it has its own per-conversation queueing // 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({ async function onProfileKeyUpdate({
@ -2793,12 +2785,14 @@ export async function startApp(): Promise<void> {
if (!isValidReactionEmoji(reaction.emoji)) { if (!isValidReactionEmoji(reaction.emoji)) {
log.warn('Received an invalid reaction emoji. Dropping it'); log.warn('Received an invalid reaction emoji. Dropping it');
event.confirm(); confirm();
return; return;
} }
log.info('Queuing sent reaction for', reaction.targetTimestamp); log.info('Queuing sent reaction for', reaction.targetTimestamp);
const attributes: ReactionAttributesType = { const attributes: ReactionAttributesType = {
envelopeId: data.envelopeId,
removeFromMessageReceiverCache: confirm,
emoji: reaction.emoji, emoji: reaction.emoji,
fromId: window.ConversationController.getOurConversationIdOrThrow(), fromId: window.ConversationController.getOurConversationIdOrThrow(),
remove: reaction.remove, remove: reaction.remove,
@ -2808,11 +2802,7 @@ export async function startApp(): Promise<void> {
targetTimestamp: reaction.targetTimestamp, targetTimestamp: reaction.targetTimestamp,
timestamp, timestamp,
}; };
const reactionModel = Reactions.getSingleton().add(attributes); drop(Reactions.onReaction(attributes));
drop(Reactions.getSingleton().onReaction(reactionModel));
event.confirm();
return; return;
} }
@ -2867,9 +2857,11 @@ export async function startApp(): Promise<void> {
} }
// Don't wait for handleDataMessage, as it has its own per-conversation queueing // Don't wait for handleDataMessage, as it has its own per-conversation queueing
void message.handleDataMessage(data.message, event.confirm, { drop(
data, message.handleDataMessage(data.message, event.confirm, {
}); data,
})
);
} }
type MessageDescriptor = { type MessageDescriptor = {
@ -3041,21 +3033,18 @@ export async function startApp(): Promise<void> {
} }
function onViewOnceOpenSync(ev: ViewOnceOpenSyncEvent): void { function onViewOnceOpenSync(ev: ViewOnceOpenSyncEvent): void {
ev.confirm();
const { source, sourceAci, timestamp } = ev; const { source, sourceAci, timestamp } = ev;
log.info(`view once open sync ${source} ${timestamp}`); log.info(`view once open sync ${source} ${timestamp}`);
strictAssert(sourceAci, 'ViewOnceOpen without sourceAci'); strictAssert(sourceAci, 'ViewOnceOpen without sourceAci');
strictAssert(timestamp, 'ViewOnceOpen without timestamp'); strictAssert(timestamp, 'ViewOnceOpen without timestamp');
const attributes: ViewOnceOpenSyncAttributesType = { const attributes: ViewOnceOpenSyncAttributesType = {
removeFromMessageReceiverCache: ev.confirm,
source, source,
sourceAci, sourceAci,
timestamp, timestamp,
}; };
const sync = ViewOnceOpenSyncs.getSingleton().add(attributes); drop(ViewOnceOpenSyncs.onSync(attributes));
void ViewOnceOpenSyncs.getSingleton().onSync(sync);
} }
async function onFetchLatestSync(ev: FetchLatestEvent): Promise<void> { async function onFetchLatestSync(ev: FetchLatestEvent): Promise<void> {
@ -3126,8 +3115,6 @@ export async function startApp(): Promise<void> {
} }
function onMessageRequestResponse(ev: MessageRequestResponseEvent): void { function onMessageRequestResponse(ev: MessageRequestResponseEvent): void {
ev.confirm();
const { threadE164, threadAci, groupV2Id, messageRequestResponseType } = ev; const { threadE164, threadAci, groupV2Id, messageRequestResponseType } = ev;
log.info('onMessageRequestResponse', { log.info('onMessageRequestResponse', {
@ -3142,22 +3129,24 @@ export async function startApp(): Promise<void> {
'onMessageRequestResponse: missing type' 'onMessageRequestResponse: missing type'
); );
strictAssert(ev.envelopeId, 'onMessageRequestResponse: no envelope id');
const attributes: MessageRequestAttributesType = { const attributes: MessageRequestAttributesType = {
envelopeId: ev.envelopeId,
removeFromMessageReceiverCache: ev.confirm,
threadE164, threadE164,
threadAci, threadAci,
groupV2Id, groupV2Id,
type: messageRequestResponseType, type: messageRequestResponseType,
}; };
const sync = MessageRequests.getSingleton().add(attributes); drop(MessageRequests.onResponse(attributes));
void MessageRequests.getSingleton().onResponse(sync);
} }
function onReadReceipt(event: Readonly<ReadEvent>): void { function onReadReceipt(event: Readonly<ReadEvent>): void {
onReadOrViewReceipt({ onReadOrViewReceipt({
logTitle: 'read receipt', logTitle: 'read receipt',
event, event,
type: MessageReceiptType.Read, type: MessageReceipts.MessageReceiptType.Read,
}); });
} }
@ -3165,7 +3154,7 @@ export async function startApp(): Promise<void> {
onReadOrViewReceipt({ onReadOrViewReceipt({
logTitle: 'view receipt', logTitle: 'view receipt',
event, event,
type: MessageReceiptType.View, type: MessageReceipts.MessageReceiptType.View,
}); });
} }
@ -3176,7 +3165,9 @@ export async function startApp(): Promise<void> {
}: Readonly<{ }: Readonly<{
event: ReadEvent | ViewEvent; event: ReadEvent | ViewEvent;
logTitle: string; logTitle: string;
type: MessageReceiptType.Read | MessageReceiptType.View; type:
| MessageReceipts.MessageReceiptType.Read
| MessageReceipts.MessageReceiptType.View;
}>): void { }>): void {
const { const {
envelopeTimestamp, envelopeTimestamp,
@ -3200,8 +3191,6 @@ export async function startApp(): Promise<void> {
timestamp timestamp
); );
event.confirm();
strictAssert( strictAssert(
isServiceIdString(sourceServiceId), isServiceIdString(sourceServiceId),
'onReadOrViewReceipt: Missing sourceServiceId' 'onReadOrViewReceipt: Missing sourceServiceId'
@ -3209,6 +3198,8 @@ export async function startApp(): Promise<void> {
strictAssert(sourceDevice, 'onReadOrViewReceipt: Missing sourceDevice'); strictAssert(sourceDevice, 'onReadOrViewReceipt: Missing sourceDevice');
const attributes: MessageReceiptAttributesType = { const attributes: MessageReceiptAttributesType = {
envelopeId: event.receipt.envelopeId,
removeFromMessageReceiverCache: event.confirm,
messageSentAt: timestamp, messageSentAt: timestamp,
receiptTimestamp: envelopeTimestamp, receiptTimestamp: envelopeTimestamp,
sourceConversationId: sourceConversation.id, sourceConversationId: sourceConversation.id,
@ -3217,13 +3208,10 @@ export async function startApp(): Promise<void> {
type, type,
wasSentEncrypted, wasSentEncrypted,
}; };
const receipt = MessageReceipts.getSingleton().add(attributes); drop(MessageReceipts.onReceipt(attributes));
// Note: We do not wait for completion here
void MessageReceipts.getSingleton().onReceipt(receipt);
} }
function onReadSync(ev: ReadSyncEvent): Promise<void> { async function onReadSync(ev: ReadSyncEvent): Promise<void> {
const { envelopeTimestamp, sender, senderAci, timestamp } = ev.read; const { envelopeTimestamp, sender, senderAci, timestamp } = ev.read;
const readAt = envelopeTimestamp; const readAt = envelopeTimestamp;
const { conversation: senderConversation } = const { conversation: senderConversation } =
@ -3249,22 +3237,19 @@ export async function startApp(): Promise<void> {
strictAssert(timestamp, 'onReadSync missing timestamp'); strictAssert(timestamp, 'onReadSync missing timestamp');
const attributes: ReadSyncAttributesType = { const attributes: ReadSyncAttributesType = {
envelopeId: ev.read.envelopeId,
removeFromMessageReceiverCache: ev.confirm,
senderId, senderId,
sender, sender,
senderAci, senderAci,
timestamp, timestamp,
readAt, readAt,
}; };
const receipt = ReadSyncs.getSingleton().add(attributes);
receipt.on('remove', ev.confirm); await ReadSyncs.onSync(attributes);
// Note: Here we wait, because we want read states to be in the database
// before we move on.
return ReadSyncs.getSingleton().onSync(receipt);
} }
function onViewSync(ev: ViewSyncEvent): Promise<void> { async function onViewSync(ev: ViewSyncEvent): Promise<void> {
const { envelopeTimestamp, senderE164, senderAci, timestamp } = ev.view; const { envelopeTimestamp, senderE164, senderAci, timestamp } = ev.view;
const { conversation: senderConversation } = const { conversation: senderConversation } =
window.ConversationController.maybeMergeContacts({ window.ConversationController.maybeMergeContacts({
@ -3289,19 +3274,16 @@ export async function startApp(): Promise<void> {
strictAssert(timestamp, 'onViewSync missing timestamp'); strictAssert(timestamp, 'onViewSync missing timestamp');
const attributes: ViewSyncAttributesType = { const attributes: ViewSyncAttributesType = {
envelopeId: ev.view.envelopeId,
removeFromMessageReceiverCache: ev.confirm,
senderId, senderId,
senderE164, senderE164,
senderAci, senderAci,
timestamp, timestamp,
viewedAt: envelopeTimestamp, viewedAt: envelopeTimestamp,
}; };
const receipt = ViewSyncs.getSingleton().add(attributes);
receipt.on('remove', ev.confirm); await ViewSyncs.onSync(attributes);
// Note: Here we wait, because we want viewed states to be in the database
// before we move on.
return ViewSyncs.getSingleton().onSync(receipt);
} }
function onDeliveryReceipt(ev: DeliveryEvent): void { function onDeliveryReceipt(ev: DeliveryEvent): void {
@ -3315,8 +3297,6 @@ export async function startApp(): Promise<void> {
wasSentEncrypted, wasSentEncrypted,
} = deliveryReceipt; } = deliveryReceipt;
ev.confirm();
const sourceConversation = window.ConversationController.lookupOrCreate({ const sourceConversation = window.ConversationController.lookupOrCreate({
serviceId: sourceServiceId, serviceId: sourceServiceId,
e164: source, e164: source,
@ -3343,18 +3323,18 @@ export async function startApp(): Promise<void> {
strictAssert(sourceDevice, 'onDeliveryReceipt: missing sourceDevice'); strictAssert(sourceDevice, 'onDeliveryReceipt: missing sourceDevice');
const attributes: MessageReceiptAttributesType = { const attributes: MessageReceiptAttributesType = {
envelopeId: ev.deliveryReceipt.envelopeId,
removeFromMessageReceiverCache: ev.confirm,
messageSentAt: timestamp, messageSentAt: timestamp,
receiptTimestamp: envelopeTimestamp, receiptTimestamp: envelopeTimestamp,
sourceConversationId: sourceConversation?.id, sourceConversationId: sourceConversation?.id,
sourceServiceId, sourceServiceId,
sourceDevice, sourceDevice,
type: MessageReceiptType.Delivery, type: MessageReceipts.MessageReceiptType.Delivery,
wasSentEncrypted, wasSentEncrypted,
}; };
const receipt = MessageReceipts.getSingleton().add(attributes);
// Note: We don't wait for completion here drop(MessageReceipts.onReceipt(attributes));
void MessageReceipts.getSingleton().onReceipt(receipt);
} }
} }

View file

@ -7,7 +7,6 @@ import * as log from '../logging/log';
import * as Errors from '../types/errors'; import * as Errors from '../types/errors';
import { deleteForEveryone } from '../util/deleteForEveryone'; import { deleteForEveryone } from '../util/deleteForEveryone';
import { drop } from '../util/drop'; import { drop } from '../util/drop';
import { filter, size } from '../util/iterables';
import { getMessageSentTimestampSet } from '../util/getMessageSentTimestampSet'; import { getMessageSentTimestampSet } from '../util/getMessageSentTimestampSet';
export type DeleteAttributesType = { export type DeleteAttributesType = {
@ -20,28 +19,33 @@ export type DeleteAttributesType = {
const deletes = new Map<string, DeleteAttributesType>(); const deletes = new Map<string, DeleteAttributesType>();
function remove(del: DeleteAttributesType): void {
del.removeFromMessageReceiverCache();
deletes.delete(del.envelopeId);
}
export function forMessage( export function forMessage(
messageAttributes: MessageAttributesType messageAttributes: MessageAttributesType
): Array<DeleteAttributesType> { ): Array<DeleteAttributesType> {
const sentTimestamps = getMessageSentTimestampSet(messageAttributes); const sentTimestamps = getMessageSentTimestampSet(messageAttributes);
const matchingDeletes = filter(deletes, ([_envelopeId, item]) => { const deleteValues = Array.from(deletes.values());
const matchingDeletes = deleteValues.filter(item => {
return ( return (
item.fromId === getContactId(messageAttributes) && item.fromId === getContactId(messageAttributes) &&
sentTimestamps.has(item.targetSentTimestamp) sentTimestamps.has(item.targetSentTimestamp)
); );
}); });
if (size(matchingDeletes) > 0) { if (!matchingDeletes.length) {
log.info('Found early DOE for message'); return [];
const result = Array.from(matchingDeletes);
result.forEach(([envelopeId, del]) => {
del.removeFromMessageReceiverCache();
deletes.delete(envelopeId);
});
return result.map(([_envelopeId, item]) => item);
} }
return []; log.info('Found early DOE for message');
matchingDeletes.forEach(del => {
remove(del);
});
return matchingDeletes;
} }
export async function onDelete(del: DeleteAttributesType): Promise<void> { export async function onDelete(del: DeleteAttributesType): Promise<void> {
@ -88,11 +92,11 @@ export async function onDelete(del: DeleteAttributesType): Promise<void> {
await deleteForEveryone(message, del); await deleteForEveryone(message, del);
deletes.delete(del.envelopeId); remove(del);
del.removeFromMessageReceiverCache();
}) })
); );
} catch (error) { } catch (error) {
remove(del);
log.error(`${logId}: error`, Errors.toLogFormat(error)); log.error(`${logId}: error`, Errors.toLogFormat(error));
} }
} }

View file

@ -5,7 +5,6 @@ import type { MessageAttributesType } from '../model-types.d';
import * as Errors from '../types/errors'; import * as Errors from '../types/errors';
import * as log from '../logging/log'; import * as log from '../logging/log';
import { drop } from '../util/drop'; import { drop } from '../util/drop';
import { filter, size } from '../util/iterables';
import { getContactId } from '../messages/helpers'; import { getContactId } from '../messages/helpers';
import { handleEditMessage } from '../util/handleEditMessage'; import { handleEditMessage } from '../util/handleEditMessage';
import { getMessageSentTimestamp } from '../util/getMessageSentTimestamp'; import { getMessageSentTimestamp } from '../util/getMessageSentTimestamp';
@ -22,6 +21,11 @@ export type EditAttributesType = {
const edits = new Map<string, EditAttributesType>(); const edits = new Map<string, EditAttributesType>();
function remove(edit: EditAttributesType): void {
edits.delete(edit.envelopeId);
edit.removeFromMessageReceiverCache();
}
export function forMessage( export function forMessage(
messageAttributes: Pick< messageAttributes: Pick<
MessageAttributesType, MessageAttributesType,
@ -34,22 +38,22 @@ export function forMessage(
> >
): Array<EditAttributesType> { ): Array<EditAttributesType> {
const sentAt = getMessageSentTimestamp(messageAttributes, { log }); const sentAt = getMessageSentTimestamp(messageAttributes, { log });
const matchingEdits = filter(edits, ([_envelopeId, item]) => { const editValues = Array.from(edits.values());
const matchingEdits = editValues.filter(item => {
return ( return (
item.targetSentTimestamp === sentAt && item.targetSentTimestamp === sentAt &&
item.fromId === getContactId(messageAttributes) item.fromId === getContactId(messageAttributes)
); );
}); });
if (size(matchingEdits) > 0) { if (matchingEdits.length > 0) {
const result: Array<EditAttributesType> = [];
const editsLogIds: Array<number> = []; const editsLogIds: Array<number> = [];
Array.from(matchingEdits).forEach(([envelopeId, item]) => { const result = matchingEdits.map(item => {
result.push(item);
editsLogIds.push(item.message.sent_at); editsLogIds.push(item.message.sent_at);
edits.delete(envelopeId); remove(item);
item.removeFromMessageReceiverCache(); return item;
}); });
log.info( log.info(
@ -99,7 +103,6 @@ export async function onEdit(edit: EditAttributesType): Promise<void> {
if (!targetMessage) { if (!targetMessage) {
log.info(`${logId}: No message`); log.info(`${logId}: No message`);
return; return;
} }
@ -110,11 +113,11 @@ export async function onEdit(edit: EditAttributesType): Promise<void> {
await handleEditMessage(message.attributes, edit); await handleEditMessage(message.attributes, edit);
edits.delete(edit.envelopeId); remove(edit);
edit.removeFromMessageReceiverCache();
}) })
); );
} catch (error) { } catch (error) {
remove(edit);
log.error(`${logId} error:`, Errors.toLogFormat(error)); log.error(`${logId} error:`, Errors.toLogFormat(error));
} }
} }

View file

@ -1,10 +1,7 @@
// Copyright 2016 Signal Messenger, LLC // Copyright 2016 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only // SPDX-License-Identifier: AGPL-3.0-only
/* eslint-disable max-classes-per-file */
import { isEqual } from 'lodash'; import { isEqual } from 'lodash';
import { Collection, Model } from 'backbone';
import type { MessageModel } from '../models/messages'; import type { MessageModel } from '../models/messages';
import type { MessageAttributesType } from '../model-types.d'; import type { MessageAttributesType } from '../model-types.d';
@ -26,6 +23,7 @@ import * as log from '../logging/log';
import { getSourceServiceId } from '../messages/helpers'; import { getSourceServiceId } from '../messages/helpers';
import { queueUpdateMessage } from '../util/messageBatcher'; import { queueUpdateMessage } from '../util/messageBatcher';
import { getMessageSentTimestamp } from '../util/getMessageSentTimestamp'; import { getMessageSentTimestamp } from '../util/getMessageSentTimestamp';
import { getMessageIdForLogging } from '../util/idForLogging';
const { deleteSentProtoRecipient } = dataInterface; const { deleteSentProtoRecipient } = dataInterface;
@ -36,18 +34,18 @@ export enum MessageReceiptType {
} }
export type MessageReceiptAttributesType = { export type MessageReceiptAttributesType = {
envelopeId: string;
messageSentAt: number; messageSentAt: number;
receiptTimestamp: number; receiptTimestamp: number;
sourceServiceId: ServiceIdString; removeFromMessageReceiverCache: () => unknown;
sourceConversationId: string; sourceConversationId: string;
sourceDevice: number; sourceDevice: number;
sourceServiceId: ServiceIdString;
type: MessageReceiptType; type: MessageReceiptType;
wasSentEncrypted: boolean; wasSentEncrypted: boolean;
}; };
class MessageReceiptModel extends Model<MessageReceiptAttributesType> {} const receipts = new Map<string, MessageReceiptAttributesType>();
let singleton: MessageReceipts | undefined;
const deleteSentProtoBatcher = createWaitBatcher({ const deleteSentProtoBatcher = createWaitBatcher({
name: 'deleteSentProtoBatcher', name: 'deleteSentProtoBatcher',
@ -79,6 +77,11 @@ const deleteSentProtoBatcher = createWaitBatcher({
}, },
}); });
function remove(receipt: MessageReceiptAttributesType): void {
receipts.delete(receipt.envelopeId);
receipt.removeFromMessageReceiverCache();
}
async function getTargetMessage( async function getTargetMessage(
sourceId: string, sourceId: string,
serviceId: ServiceIdString, serviceId: ServiceIdString,
@ -124,10 +127,10 @@ const wasDeliveredWithSealedSender = (
); );
const shouldDropReceipt = ( const shouldDropReceipt = (
receipt: MessageReceiptModel, receipt: MessageReceiptAttributesType,
message: MessageModel message: MessageModel
): boolean => { ): boolean => {
const type = receipt.get('type'); const { type } = receipt;
switch (type) { switch (type) {
case MessageReceiptType.Delivery: case MessageReceiptType.Delivery:
return false; return false;
@ -143,248 +146,245 @@ const shouldDropReceipt = (
} }
}; };
export class MessageReceipts extends Collection<MessageReceiptModel> { export function forMessage(
static getSingleton(): MessageReceipts { message: MessageModel
if (!singleton) { ): Array<MessageReceiptAttributesType> {
singleton = new MessageReceipts(); if (!isOutgoing(message.attributes) && !isStory(message.attributes)) {
} return [];
return singleton;
} }
forMessage(message: MessageModel): Array<MessageReceiptModel> { const logId = `MessageReceipts.forMessage(${getMessageIdForLogging(
if (!isOutgoing(message.attributes) && !isStory(message.attributes)) { message.attributes
return []; )})`;
}
const ourAci = window.textsecure.storage.user.getCheckedAci(); const ourAci = window.textsecure.storage.user.getCheckedAci();
const sourceServiceId = getSourceServiceId(message.attributes); const sourceServiceId = getSourceServiceId(message.attributes);
if (ourAci !== sourceServiceId) { if (ourAci !== sourceServiceId) {
return []; return [];
} }
const sentAt = getMessageSentTimestamp(message.attributes, { log }); const receiptValues = Array.from(receipts.values());
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;
}
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( return result.filter(receipt => {
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<void> {
const messageSentAt = receipt.get('messageSentAt');
if (shouldDropReceipt(receipt, message)) { if (shouldDropReceipt(receipt, message)) {
log.info( log.info(
`MessageReceipts: Dropping a receipt ${receipt.get('type')} ` + `${logId}: Dropping an early receipt ${receipt.type} for message ${sentAt}`
`for message ${messageSentAt}`
); );
return; return false;
} }
let hasChanges = false; return true;
});
}
const editHistory = message.get('editHistory') ?? []; function getNewSendStateByConversationId(
const newEditHistory = editHistory?.map(edit => { oldSendStateByConversationId: SendStateByConversationId,
if (messageSentAt !== edit.timestamp) { receipt: MessageReceiptAttributesType
return edit; ): SendStateByConversationId {
} const { receiptTimestamp, sourceConversationId, type } = receipt;
const oldSendStateByConversationId = edit.sendStateByConversationId ?? {}; const oldSendState = getOwn(
const newSendStateByConversationId = this.getNewSendStateByConversationId( oldSendStateByConversationId,
oldSendStateByConversationId, sourceConversationId
receipt ) ?? { status: SendStatus.Sent, updatedAt: undefined };
);
return { let sendActionType: SendActionType;
...edit, switch (type) {
sendStateByConversationId: newSendStateByConversationId, case MessageReceiptType.Delivery:
}; sendActionType = SendActionType.GotDeliveryReceipt;
}); break;
if (!isEqual(newEditHistory, editHistory)) { case MessageReceiptType.Read:
message.set('editHistory', newEditHistory); 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<void> {
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; 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<void> { if (hasChanges) {
const messageSentAt = receipt.get('messageSentAt'); queueUpdateMessage(message.attributes);
const sourceConversationId = receipt.get('sourceConversationId');
const sourceServiceId = receipt.get('sourceServiceId');
const type = receipt.get('type');
try { // notify frontend listeners
const messages = await window.Signal.Data.getMessagesBySentAt( const conversation = window.ConversationController.get(
messageSentAt 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<void> {
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));
}
}

View file

@ -1,112 +1,115 @@
// Copyright 2020 Signal Messenger, LLC // Copyright 2020 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only // 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 { 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 = { export type MessageRequestAttributesType = {
threadE164?: string; envelopeId: string;
threadAci?: AciString;
groupV2Id?: string; groupV2Id?: string;
removeFromMessageReceiverCache: () => unknown;
threadAci?: AciString;
threadE164?: string;
type: number; type: number;
}; };
class MessageRequestModel extends Model<MessageRequestAttributesType> {} const messageRequests = new Map<string, MessageRequestAttributesType>();
let singleton: MessageRequests | undefined; function remove(sync: MessageRequestAttributesType): void {
messageRequests.delete(sync.envelopeId);
sync.removeFromMessageReceiverCache();
}
export class MessageRequests extends Collection<MessageRequestModel> { export function forConversation(
static getSingleton(): MessageRequests { conversation: ConversationModel
if (!singleton) { ): MessageRequestAttributesType | null {
singleton = new MessageRequests(); 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.getServiceId()) {
if (conversation.get('e164')) { const syncByServiceId = messageRequestValues.find(
const syncByE164 = this.findWhere({ item => item.threadAci === conversation.getServiceId()
threadE164: conversation.get('e164'), );
}); if (syncByServiceId) {
if (syncByE164) { log.info(`${logId}: Found early message request response for serviceId`);
log.info( remove(syncByServiceId);
`Found early message request response for E164 ${conversation.idForLogging()}` return syncByServiceId;
);
this.remove(syncByE164);
return syncByE164;
}
} }
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<void> { // V2 group
try { if (conversation.get('groupId')) {
const threadE164 = sync.get('threadE164'); const syncByGroupId = messageRequestValues.find(
const threadAci = sync.get('threadAci'); item => item.groupV2Id === conversation.get('groupId')
const groupV2Id = sync.get('groupV2Id'); );
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 export async function onResponse(
if (groupV2Id) { sync: MessageRequestAttributesType
conversation = window.ConversationController.get(groupV2Id); ): Promise<void> {
} messageRequests.set(sync.envelopeId, sync);
if (!conversation && (threadE164 || threadAci)) { const { threadE164, threadAci, groupV2Id } = sync;
conversation = window.ConversationController.lookupOrCreate({
e164: threadE164,
serviceId: threadAci,
reason: 'MessageRequests.onResponse',
});
}
if (!conversation) { const logId = `MessageRequests.onResponse(groupv2(${groupV2Id}) ${threadAci} ${threadE164})`;
log.warn(
`Received message request response for unknown conversation: groupv2(${groupV2Id}) ${threadAci} ${threadE164}`
);
return;
}
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, fromSync: true,
}); })
);
this.remove(sync); remove(sync);
} catch (error) { } catch (error) {
log.error('MessageRequests.onResponse error:', Errors.toLogFormat(error)); remove(sync);
} log.error(`${logId} error:`, Errors.toLogFormat(error));
} }
} }

View file

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

View file

@ -1,50 +1,52 @@
// Copyright 2017 Signal Messenger, LLC // Copyright 2017 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only // SPDX-License-Identifier: AGPL-3.0-only
/* eslint-disable max-classes-per-file */ import type { AciString } from '../types/ServiceId';
import { Collection, Model } from 'backbone';
import type { MessageModel } from '../models/messages'; 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 { isIncoming } from '../state/selectors/message';
import { isMessageUnread } from '../util/isMessageUnread'; import { isMessageUnread } from '../util/isMessageUnread';
import { notificationService } from '../services/notifications'; 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 { queueUpdateMessage } from '../util/messageBatcher';
import { getMessageSentTimestamp } from '../util/getMessageSentTimestamp';
export type ReadSyncAttributesType = { export type ReadSyncAttributesType = {
senderId: string; envelopeId: string;
readAt: number;
removeFromMessageReceiverCache: () => unknown;
sender?: string; sender?: string;
senderAci: AciString; senderAci: AciString;
senderId: string;
timestamp: number; timestamp: number;
readAt: number;
}; };
class ReadSyncModel extends Model<ReadSyncAttributesType> {} const readSyncs = new Map<string, ReadSyncAttributesType>();
let singleton: ReadSyncs | undefined; function remove(sync: ReadSyncAttributesType): void {
readSyncs.delete(sync.envelopeId);
sync.removeFromMessageReceiverCache();
}
async function maybeItIsAReactionReadSync(
sync: ReadSyncAttributesType
): Promise<void> {
const logId = `ReadSyncs.onSync(timestamp=${sync.timestamp})`;
async function maybeItIsAReactionReadSync(sync: ReadSyncModel): Promise<void> {
const readReaction = await window.Signal.Data.markReactionAsRead( const readReaction = await window.Signal.Data.markReactionAsRead(
sync.get('senderAci'), sync.senderAci,
Number(sync.get('timestamp')) Number(sync.timestamp)
); );
if (!readReaction) { if (!readReaction) {
log.info( log.info(`${logId} not found:`, sync.senderId, sync.sender, sync.senderAci);
'Nothing found for read sync',
sync.get('senderId'),
sync.get('sender'),
sync.get('senderAci'),
sync.get('timestamp')
);
return; return;
} }
remove(sync);
notificationService.removeBy({ notificationService.removeBy({
conversationId: readReaction.conversationId, conversationId: readReaction.conversationId,
emoji: readReaction.emoji, emoji: readReaction.emoji,
@ -53,109 +55,110 @@ async function maybeItIsAReactionReadSync(sync: ReadSyncModel): Promise<void> {
}); });
} }
export class ReadSyncs extends Collection { export function forMessage(
static getSingleton(): ReadSyncs { message: MessageModel
if (!singleton) { ): ReadSyncAttributesType | null {
singleton = new ReadSyncs(); 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 { return 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; export async function onSync(sync: ReadSyncAttributesType): Promise<void> {
} readSyncs.set(sync.envelopeId, sync);
async onSync(sync: ReadSyncModel): Promise<void> { const logId = `ReadSyncs.onSync(timestamp=${sync.timestamp})`;
try {
const messages = await window.Signal.Data.getMessagesBySentAt(
sync.get('timestamp')
);
const found = messages.find(item => { try {
const sender = window.ConversationController.lookupOrCreate({ const messages = await window.Signal.Data.getMessagesBySentAt(
e164: item.source, sync.timestamp
serviceId: item.sourceServiceId, );
reason: 'ReadSyncs.onSync',
});
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) { return isIncoming(item) && sender?.id === sync.senderId;
await maybeItIsAReactionReadSync(sync); });
return;
}
notificationService.removeBy({ messageId: found.id }); if (!found) {
await maybeItIsAReactionReadSync(sync);
return;
}
const message = window.MessageController.register(found.id, found); notificationService.removeBy({ messageId: found.id });
const readAt = Math.min(sync.get('readAt'), Date.now());
// If message is unread, we mark it read. Otherwise, we update the expiration const message = window.MessageController.register(found.id, found);
// timer to the time specified by the read sync if it's earlier than const readAt = Math.min(sync.readAt, Date.now());
// the previous read time.
if (isMessageUnread(message.attributes)) {
// TODO DESKTOP-1509: use MessageUpdater.markRead once this is TS
message.markRead(readAt, { skipSave: true });
const updateConversation = async () => { // If message is unread, we mark it read. Otherwise, we update the expiration
// onReadMessage may result in messages older than this one being // timer to the time specified by the read sync if it's earlier than
// marked read. We want those messages to have the same expire timer // the previous read time.
// start time as this one, so we pass the readAt value through. if (isMessageUnread(message.attributes)) {
void message.getConversation()?.onReadMessage(message, readAt); // TODO DESKTOP-1509: use MessageUpdater.markRead once this is TS
}; message.markRead(readAt, { skipSave: true });
// only available during initialization const updateConversation = async () => {
if (StartupQueue.isAvailable()) { // onReadMessage may result in messages older than this one being
const conversation = message.getConversation(); // marked read. We want those messages to have the same expire timer
if (conversation) { // start time as this one, so we pass the readAt value through.
StartupQueue.add( void message.getConversation()?.onReadMessage(message, readAt);
conversation.get('id'), };
message.get('sent_at'),
updateConversation // only available during initialization
); if (StartupQueue.isAvailable()) {
} const conversation = message.getConversation();
} else { if (conversation) {
// not awaiting since we don't want to block work happening in the StartupQueue.add(
// eventHandlerQueue conversation.get('id'),
void updateConversation(); message.get('sent_at'),
updateConversation
);
} }
} else { } else {
const now = Date.now(); // not awaiting since we don't want to block work happening in the
const existingTimestamp = message.get('expirationStartTimestamp'); // eventHandlerQueue
const expirationStartTimestamp = Math.min( void updateConversation();
now,
Math.min(existingTimestamp || now, readAt || now)
);
message.set({ expirationStartTimestamp });
} }
} else {
queueUpdateMessage(message.attributes); const now = Date.now();
const existingTimestamp = message.get('expirationStartTimestamp');
this.remove(sync); const expirationStartTimestamp = Math.min(
} catch (error) { now,
log.error('ReadSyncs.onSync error:', Errors.toLogFormat(error)); 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));
} }
} }

View file

@ -1,100 +1,108 @@
// Copyright 2019 Signal Messenger, LLC // Copyright 2019 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only // 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 { 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 = { export type ViewOnceOpenSyncAttributesType = {
removeFromMessageReceiverCache: () => unknown;
source?: string; source?: string;
sourceAci: AciString; sourceAci: AciString;
timestamp: number; timestamp: number;
}; };
class ViewOnceOpenSyncModel extends Model<ViewOnceOpenSyncAttributesType> {} const viewOnceSyncs = new Map<number, ViewOnceOpenSyncAttributesType>();
let singleton: ViewOnceOpenSyncs | undefined; function remove(sync: ViewOnceOpenSyncAttributesType): void {
viewOnceSyncs.delete(sync.timestamp);
sync.removeFromMessageReceiverCache();
}
export class ViewOnceOpenSyncs extends Collection<ViewOnceOpenSyncModel> { export function forMessage(
static getSingleton(): ViewOnceOpenSyncs { message: MessageModel
if (!singleton) { ): ViewOnceOpenSyncAttributesType | null {
singleton = new ViewOnceOpenSyncs(); 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 syncBySource = viewOnceSyncValues.find(item => {
const syncBySourceAci = this.find(item => { return (
return ( item.source === message.get('source') &&
item.get('sourceAci') === message.get('sourceServiceId') && item.timestamp === message.get('sent_at')
item.get('timestamp') === message.get('sent_at') );
); });
}); if (syncBySource) {
if (syncBySourceAci) { log.info(`${logId}: Found early view once open sync for message`);
log.info('Found early view once open sync for message'); remove(syncBySource);
this.remove(syncBySourceAci); return syncBySource;
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;
} }
async onSync(sync: ViewOnceOpenSyncModel): Promise<void> { return null;
try { }
const messages = await window.Signal.Data.getMessagesBySentAt(
sync.get('timestamp') export async function onSync(
sync: ViewOnceOpenSyncAttributesType
): Promise<void> {
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 syncSource = sync.source;
const itemSourceAci = item.sourceServiceId; const syncSourceAci = sync.sourceAci;
const syncSourceAci = sync.get('sourceAci'); const syncTimestamp = sync.timestamp;
const itemSource = item.source; const wasMessageFound = Boolean(found);
const syncSource = sync.get('source'); log.info(`${logId} receive:`, {
syncSource,
syncSourceAci,
syncTimestamp,
wasMessageFound,
});
return Boolean( if (!found) {
(itemSourceAci && syncSourceAci && itemSourceAci === syncSourceAci) || return;
(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));
} }
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));
} }
} }

View file

@ -1,136 +1,142 @@
// Copyright 2021 Signal Messenger, LLC // Copyright 2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only // 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 { AciString } from '../types/ServiceId';
import type { MessageModel } from '../models/messages';
import * as Errors from '../types/errors'; 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 * as log from '../logging/log';
import { GiftBadgeStates } from '../components/conversation/Message'; 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 { 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 = { export type ViewSyncAttributesType = {
senderId: string; envelopeId: string;
senderE164?: string; removeFromMessageReceiverCache: () => unknown;
senderAci: AciString; senderAci: AciString;
senderE164?: string;
senderId: string;
timestamp: number; timestamp: number;
viewedAt: number; viewedAt: number;
}; };
class ViewSyncModel extends Model<ViewSyncAttributesType> {} const viewSyncs = new Map<string, ViewSyncAttributesType>();
let singleton: ViewSyncs | undefined; function remove(sync: ViewSyncAttributesType): void {
viewSyncs.delete(sync.envelopeId);
sync.removeFromMessageReceiverCache();
}
export class ViewSyncs extends Collection { export function forMessage(
static getSingleton(): ViewSyncs { message: MessageModel
if (!singleton) { ): Array<ViewSyncAttributesType> {
singleton = new ViewSyncs(); 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<ViewSyncModel> { return matchingSyncs;
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;
}
async onSync(sync: ViewSyncModel): Promise<void> { export async function onSync(sync: ViewSyncAttributesType): Promise<void> {
try { viewSyncs.set(sync.envelopeId, sync);
const messages = await window.Signal.Data.getMessagesBySentAt(
sync.get('timestamp')
);
const found = messages.find(item => { const logId = `ViewSyncs.onSync(timestamp=${sync.timestamp})`;
const sender = window.ConversationController.lookupOrCreate({
e164: item.source,
serviceId: item.sourceServiceId,
reason: 'ViewSyncs.onSync',
});
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) { return sender?.id === sync.senderId;
log.info( });
'Nothing found for view sync',
sync.get('senderId'), if (!found) {
sync.get('senderE164'), log.info(
sync.get('senderAci'), `${logId}: nothing found`,
sync.get('timestamp') 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; if (updatedFields) {
} message.set(updatedFields);
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);
}
} }
} }
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));
} }
} }

14
ts/model-types.d.ts vendored
View file

@ -23,7 +23,6 @@ import { SignalService as Proto } from './protobuf';
import type { AvatarDataType } from './types/Avatar'; import type { AvatarDataType } from './types/Avatar';
import type { AciString, PniString, ServiceIdString } from './types/ServiceId'; import type { AciString, PniString, ServiceIdString } from './types/ServiceId';
import type { StoryDistributionIdString } from './types/StoryDistributionId'; import type { StoryDistributionIdString } from './types/StoryDistributionId';
import type { ReactionSource } from './reactions/ReactionSource';
import type { SeenStatus } from './MessageSeenStatus'; import type { SeenStatus } from './MessageSeenStatus';
import type { GiftBadgeStates } from './components/conversation/Message'; import type { GiftBadgeStates } from './components/conversation/Message';
import type { LinkPreviewType } from './types/message/LinkPreviews'; 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<MessageModel> {} export declare class MessageModelCollectionType extends Backbone.Collection<MessageModel> {}
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;
};

View file

@ -129,7 +129,7 @@ import {
conversationJobQueue, conversationJobQueue,
conversationQueueJobEnum, conversationQueueJobEnum,
} from '../jobs/conversationJobQueue'; } from '../jobs/conversationJobQueue';
import type { ReactionModel } from '../messageModifiers/Reactions'; import type { ReactionAttributesType } from '../messageModifiers/Reactions';
import { isAnnouncementGroupReady } from '../util/isAnnouncementGroupReady'; import { isAnnouncementGroupReady } from '../util/isAnnouncementGroupReady';
import { getProfile } from '../util/getProfile'; import { getProfile } from '../util/getProfile';
import { SEALED_SENDER } from '../types/SealedSender'; import { SEALED_SENDER } from '../types/SealedSender';
@ -4900,7 +4900,7 @@ export class ConversationModel extends window.Backbone
async notify( async notify(
message: Readonly<MessageModel>, message: Readonly<MessageModel>,
reaction?: Readonly<ReactionModel> reaction?: Readonly<ReactionAttributesType>
): Promise<void> { ): Promise<void> {
// As a performance optimization don't perform any work if notifications are // As a performance optimization don't perform any work if notifications are
// disabled. // disabled.
@ -4938,7 +4938,7 @@ export class ConversationModel extends window.Backbone
const isMessageInDirectConversation = isDirectConversation(this.attributes); const isMessageInDirectConversation = isDirectConversation(this.attributes);
const sender = reaction const sender = reaction
? window.ConversationController.get(reaction.get('fromId')) ? window.ConversationController.get(reaction.fromId)
: getContact(message.attributes); : getContact(message.attributes);
const senderName = sender const senderName = sender
? sender.getTitle() ? sender.getTitle()
@ -4967,7 +4967,13 @@ export class ConversationModel extends window.Backbone
isExpiringMessage, isExpiringMessage,
message: message.getNotificationText(), message: message.getNotificationText(),
messageId, messageId,
reaction: reaction ? reaction.toJSON() : null, reaction: reaction
? {
emoji: reaction.emoji,
targetAuthorAci: reaction.targetAuthorAci,
targetTimestamp: reaction.targetTimestamp,
}
: undefined,
sentAt: message.get('timestamp'), sentAt: message.get('timestamp'),
type: reaction ? NotificationType.Reaction : NotificationType.Message, type: reaction ? NotificationType.Reaction : NotificationType.Message,
}); });

View file

@ -112,7 +112,7 @@ import {
getCallSelector, getCallSelector,
getActiveCall, getActiveCall,
} from '../state/selectors/calling'; } from '../state/selectors/calling';
import type { ReactionModel } from '../messageModifiers/Reactions'; import type { ReactionAttributesType } from '../messageModifiers/Reactions';
import { ReactionSource } from '../reactions/ReactionSource'; import { ReactionSource } from '../reactions/ReactionSource';
import * as LinkPreview from '../types/LinkPreview'; import * as LinkPreview from '../types/LinkPreview';
import { SignalService as Proto } from '../protobuf'; import { SignalService as Proto } from '../protobuf';
@ -2922,7 +2922,7 @@ export class MessageModel extends window.Backbone.Model<MessageAttributesType> {
} }
async handleReaction( async handleReaction(
reaction: ReactionModel, reaction: ReactionAttributesType,
{ {
storyMessage, storyMessage,
shouldPersist = true, shouldPersist = true,
@ -2955,22 +2955,21 @@ export class MessageModel extends window.Backbone.Model<MessageAttributesType> {
return; return;
} }
const isFromThisDevice = const isFromThisDevice = reaction.source === ReactionSource.FromThisDevice;
reaction.get('source') === ReactionSource.FromThisDevice; const isFromSync = reaction.source === ReactionSource.FromSync;
const isFromSync = reaction.get('source') === ReactionSource.FromSync;
const isFromSomeoneElse = const isFromSomeoneElse =
reaction.get('source') === ReactionSource.FromSomeoneElse; reaction.source === ReactionSource.FromSomeoneElse;
strictAssert( strictAssert(
isFromThisDevice || isFromSync || isFromSomeoneElse, isFromThisDevice || isFromSync || isFromSomeoneElse,
'Reaction can only be from this device, from sync, or from someone else' 'Reaction can only be from this device, from sync, or from someone else'
); );
const newReaction: MessageReactionType = { const newReaction: MessageReactionType = {
emoji: reaction.get('remove') ? undefined : reaction.get('emoji'), emoji: reaction.remove ? undefined : reaction.emoji,
fromId: reaction.get('fromId'), fromId: reaction.fromId,
targetAuthorAci: reaction.get('targetAuthorAci'), targetAuthorAci: reaction.targetAuthorAci,
targetTimestamp: reaction.get('targetTimestamp'), targetTimestamp: reaction.targetTimestamp,
timestamp: reaction.get('timestamp'), timestamp: reaction.timestamp,
isSentByConversationId: isFromThisDevice isSentByConversationId: isFromThisDevice
? zipObject(conversation.getMemberConversationIds(), repeat(false)) ? zipObject(conversation.getMemberConversationIds(), repeat(false))
: undefined, : undefined,
@ -2997,7 +2996,7 @@ export class MessageModel extends window.Backbone.Model<MessageAttributesType> {
); );
} }
const generatedMessage = reaction.get('storyReactionMessage'); const generatedMessage = reaction.storyReactionMessage;
strictAssert( strictAssert(
generatedMessage, generatedMessage,
'Story reactions must provide storyReactionMessage' 'Story reactions must provide storyReactionMessage'
@ -3016,9 +3015,9 @@ export class MessageModel extends window.Backbone.Model<MessageAttributesType> {
: undefined, : undefined,
storyId: storyMessage.id, storyId: storyMessage.id,
storyReaction: { storyReaction: {
emoji: reaction.get('emoji'), emoji: reaction.emoji,
targetAuthorAci: reaction.get('targetAuthorAci'), targetAuthorAci: reaction.targetAuthorAci,
targetTimestamp: reaction.get('targetTimestamp'), targetTimestamp: reaction.targetTimestamp,
}, },
}); });
@ -3036,8 +3035,8 @@ export class MessageModel extends window.Backbone.Model<MessageAttributesType> {
generatedMessage.attributes generatedMessage.attributes
), ),
storyId: getMessageIdForLogging(storyMessage), storyId: getMessageIdForLogging(storyMessage),
targetTimestamp: reaction.get('targetTimestamp'), targetTimestamp: reaction.targetTimestamp,
timestamp: reaction.get('timestamp'), timestamp: reaction.timestamp,
}); });
const messageToAdd = window.MessageController.register( const messageToAdd = window.MessageController.register(
@ -3091,7 +3090,7 @@ export class MessageModel extends window.Backbone.Model<MessageAttributesType> {
this.clearNotifications(oldReaction); this.clearNotifications(oldReaction);
} }
if (reaction.get('remove')) { if (reaction.remove) {
log.info( log.info(
'handleReaction: removing reaction for message', 'handleReaction: removing reaction for message',
this.idForLogging() this.idForLogging()
@ -3101,7 +3100,7 @@ export class MessageModel extends window.Backbone.Model<MessageAttributesType> {
reactions = oldReactions.filter( reactions = oldReactions.filter(
re => re =>
!isNewReactionReplacingPrevious(re, newReaction) || !isNewReactionReplacingPrevious(re, newReaction) ||
re.timestamp > reaction.get('timestamp') re.timestamp > reaction.timestamp
); );
} else { } else {
reactions = oldReactions.filter( reactions = oldReactions.filter(
@ -3111,10 +3110,10 @@ export class MessageModel extends window.Backbone.Model<MessageAttributesType> {
this.set({ reactions }); this.set({ reactions });
await window.Signal.Data.removeReactionFromConversation({ await window.Signal.Data.removeReactionFromConversation({
emoji: reaction.get('emoji'), emoji: reaction.emoji,
fromId: reaction.get('fromId'), fromId: reaction.fromId,
targetAuthorServiceId: reaction.get('targetAuthorAci'), targetAuthorServiceId: reaction.targetAuthorAci,
targetTimestamp: reaction.get('targetTimestamp'), targetTimestamp: reaction.targetTimestamp,
}); });
} else { } else {
log.info( log.info(
@ -3126,9 +3125,7 @@ export class MessageModel extends window.Backbone.Model<MessageAttributesType> {
if (isFromSync) { if (isFromSync) {
const ourReactions = [ const ourReactions = [
newReaction, newReaction,
...oldReactions.filter( ...oldReactions.filter(re => re.fromId === reaction.fromId),
re => re.fromId === reaction.get('fromId')
),
]; ];
reactionToAdd = maxBy(ourReactions, 'timestamp') || newReaction; reactionToAdd = maxBy(ourReactions, 'timestamp') || newReaction;
} else { } else {
@ -3136,7 +3133,7 @@ export class MessageModel extends window.Backbone.Model<MessageAttributesType> {
} }
reactions = oldReactions.filter( reactions = oldReactions.filter(
re => !isNewReactionReplacingPrevious(re, reaction.attributes) re => !isNewReactionReplacingPrevious(re, reaction)
); );
reactions.push(reactionToAdd); reactions.push(reactionToAdd);
this.set({ reactions }); this.set({ reactions });
@ -3147,12 +3144,12 @@ export class MessageModel extends window.Backbone.Model<MessageAttributesType> {
await window.Signal.Data.addReaction({ await window.Signal.Data.addReaction({
conversationId: this.get('conversationId'), conversationId: this.get('conversationId'),
emoji: reaction.get('emoji'), emoji: reaction.emoji,
fromId: reaction.get('fromId'), fromId: reaction.fromId,
messageId: this.id, messageId: this.id,
messageReceivedAt: this.get('received_at'), messageReceivedAt: this.get('received_at'),
targetAuthorAci: reaction.get('targetAuthorAci'), targetAuthorAci: reaction.targetAuthorAci,
targetTimestamp: reaction.get('targetTimestamp'), targetTimestamp: reaction.targetTimestamp,
}); });
} }
} }
@ -3173,7 +3170,7 @@ export class MessageModel extends window.Backbone.Model<MessageAttributesType> {
'New story reaction must have an emoji' 'New story reaction must have an emoji'
); );
const generatedMessage = reaction.get('storyReactionMessage'); const generatedMessage = reaction.storyReactionMessage;
strictAssert( strictAssert(
generatedMessage, generatedMessage,
'Story reactions must provide storyReactionmessage' 'Story reactions must provide storyReactionmessage'

View file

@ -1,9 +1,10 @@
// Copyright 2021 Signal Messenger, LLC // Copyright 2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only // SPDX-License-Identifier: AGPL-3.0-only
import noop from 'lodash/noop';
import { v4 as generateUuid } from 'uuid'; import { v4 as generateUuid } from 'uuid';
import { ReactionModel } from '../messageModifiers/Reactions'; import type { ReactionAttributesType } from '../messageModifiers/Reactions';
import { ReactionSource } from './ReactionSource'; import { ReactionSource } from './ReactionSource';
import { getMessageById } from '../messages/getMessageById'; import { getMessageById } from '../messages/getMessageById';
import { getSourceServiceId, isStory } from '../messages/helpers'; import { getSourceServiceId, isStory } from '../messages/helpers';
@ -98,7 +99,9 @@ export async function enqueueReactionForSend({
}) })
: undefined; : undefined;
const reaction = new ReactionModel({ const reaction: ReactionAttributesType = {
envelopeId: generateUuid(),
removeFromMessageReceiverCache: noop,
emoji, emoji,
fromId: window.ConversationController.getOurConversationIdOrThrow(), fromId: window.ConversationController.getOurConversationIdOrThrow(),
remove, remove,
@ -107,7 +110,7 @@ export async function enqueueReactionForSend({
targetAuthorAci, targetAuthorAci,
targetTimestamp, targetTimestamp,
timestamp, timestamp,
}); };
await message.handleReaction(reaction, { storyMessage }); await message.handleReaction(reaction, { storyMessage });
} }

View file

@ -1675,6 +1675,7 @@ export default class MessageReceiver
getEnvelopeId(envelope), getEnvelopeId(envelope),
new DeliveryEvent( new DeliveryEvent(
{ {
envelopeId: envelope.id,
timestamp: envelope.timestamp, timestamp: envelope.timestamp,
envelopeTimestamp: envelope.timestamp, envelopeTimestamp: envelope.timestamp,
source: envelope.source, source: envelope.source,
@ -2857,6 +2858,7 @@ export default class MessageReceiver
receiptMessage.timestamp.map(async rawTimestamp => { receiptMessage.timestamp.map(async rawTimestamp => {
const ev = new EventClass( const ev = new EventClass(
{ {
envelopeId: envelope.id,
timestamp: rawTimestamp?.toNumber(), timestamp: rawTimestamp?.toNumber(),
envelopeTimestamp: envelope.timestamp, envelopeTimestamp: envelope.timestamp,
source: envelope.source, source: envelope.source,
@ -3255,6 +3257,7 @@ export default class MessageReceiver
const ev = new MessageRequestResponseEvent( const ev = new MessageRequestResponseEvent(
{ {
envelopeId: envelope.id,
threadE164: dropNull(sync.threadE164), threadE164: dropNull(sync.threadE164),
threadAci: sync.threadAci threadAci: sync.threadAci
? normalizeAci( ? normalizeAci(
@ -3393,6 +3396,7 @@ export default class MessageReceiver
for (const { timestamp, sender, senderAci } of read) { for (const { timestamp, sender, senderAci } of read) {
const ev = new ReadSyncEvent( const ev = new ReadSyncEvent(
{ {
envelopeId: envelope.id,
envelopeTimestamp: envelope.timestamp, envelopeTimestamp: envelope.timestamp,
timestamp: timestamp?.toNumber(), timestamp: timestamp?.toNumber(),
sender: dropNull(sender), sender: dropNull(sender),
@ -3420,6 +3424,7 @@ export default class MessageReceiver
viewed.map(async ({ timestamp, senderE164, senderAci }) => { viewed.map(async ({ timestamp, senderE164, senderAci }) => {
const ev = new ViewSyncEvent( const ev = new ViewSyncEvent(
{ {
envelopeId: envelope.id,
envelopeTimestamp: envelope.timestamp, envelopeTimestamp: envelope.timestamp,
timestamp: timestamp?.toNumber(), timestamp: timestamp?.toNumber(),
senderE164: dropNull(senderE164), senderE164: dropNull(senderE164),

View file

@ -110,6 +110,7 @@ export class ConfirmableEvent extends Event {
} }
export type DeliveryEventData = Readonly<{ export type DeliveryEventData = Readonly<{
envelopeId: string;
timestamp: number; timestamp: number;
envelopeTimestamp: number; envelopeTimestamp: number;
source?: string; source?: string;
@ -240,6 +241,7 @@ export class MessageEvent extends ConfirmableEvent {
} }
export type ReadOrViewEventData = Readonly<{ export type ReadOrViewEventData = Readonly<{
envelopeId: string;
timestamp: number; timestamp: number;
envelopeTimestamp: number; envelopeTimestamp: number;
source?: string; source?: string;
@ -301,6 +303,7 @@ export class ViewOnceOpenSyncEvent extends ConfirmableEvent {
} }
export type MessageRequestResponseOptions = { export type MessageRequestResponseOptions = {
envelopeId: string;
threadE164?: string; threadE164?: string;
threadAci?: AciString; threadAci?: AciString;
messageRequestResponseType: Proto.SyncMessage.IMessageRequestResponse['type']; messageRequestResponseType: Proto.SyncMessage.IMessageRequestResponse['type'];
@ -319,8 +322,11 @@ export class MessageRequestResponseEvent extends ConfirmableEvent {
public readonly groupV2Id?: string; public readonly groupV2Id?: string;
public readonly envelopeId?: string;
constructor( constructor(
{ {
envelopeId,
threadE164, threadE164,
threadAci, threadAci,
messageRequestResponseType, messageRequestResponseType,
@ -331,6 +337,7 @@ export class MessageRequestResponseEvent extends ConfirmableEvent {
) { ) {
super('messageRequestResponse', confirm); super('messageRequestResponse', confirm);
this.envelopeId = envelopeId;
this.threadE164 = threadE164; this.threadE164 = threadE164;
this.threadAci = threadAci; this.threadAci = threadAci;
this.messageRequestResponseType = messageRequestResponseType; this.messageRequestResponseType = messageRequestResponseType;
@ -374,6 +381,7 @@ export class StickerPackEvent extends ConfirmableEvent {
} }
export type ReadSyncEventData = Readonly<{ export type ReadSyncEventData = Readonly<{
envelopeId: string;
timestamp?: number; timestamp?: number;
envelopeTimestamp: number; envelopeTimestamp: number;
sender?: string; sender?: string;
@ -390,6 +398,7 @@ export class ReadSyncEvent extends ConfirmableEvent {
} }
export type ViewSyncEventData = Readonly<{ export type ViewSyncEventData = Readonly<{
envelopeId: string;
timestamp?: number; timestamp?: number;
envelopeTimestamp: number; envelopeTimestamp: number;
senderE164?: string; senderE164?: string;

View file

@ -9,17 +9,14 @@ import type { SendStateByConversationId } from '../messages/MessageSendState';
import * as Edits from '../messageModifiers/Edits'; import * as Edits from '../messageModifiers/Edits';
import * as log from '../logging/log'; import * as log from '../logging/log';
import * as Deletes from '../messageModifiers/Deletes'; import * as Deletes from '../messageModifiers/Deletes';
import { import * as MessageReceipts from '../messageModifiers/MessageReceipts';
MessageReceipts, import * as Reactions from '../messageModifiers/Reactions';
MessageReceiptType, import * as ReadSyncs from '../messageModifiers/ReadSyncs';
} from '../messageModifiers/MessageReceipts'; import * as ViewOnceOpenSyncs from '../messageModifiers/ViewOnceOpenSyncs';
import { Reactions } from '../messageModifiers/Reactions'; import * as ViewSyncs from '../messageModifiers/ViewSyncs';
import { ReadStatus } from '../messages/MessageReadStatus'; import { ReadStatus } from '../messages/MessageReadStatus';
import { ReadSyncs } from '../messageModifiers/ReadSyncs';
import { SeenStatus } from '../MessageSeenStatus'; import { SeenStatus } from '../MessageSeenStatus';
import { SendActionType, sendStateReducer } from '../messages/MessageSendState'; import { SendActionType, sendStateReducer } from '../messages/MessageSendState';
import { ViewOnceOpenSyncs } from '../messageModifiers/ViewOnceOpenSyncs';
import { ViewSyncs } from '../messageModifiers/ViewSyncs';
import { canConversationBeUnarchived } from './canConversationBeUnarchived'; import { canConversationBeUnarchived } from './canConversationBeUnarchived';
import { deleteForEveryone } from './deleteForEveryone'; import { deleteForEveryone } from './deleteForEveryone';
import { handleEditMessage } from './handleEditMessage'; import { handleEditMessage } from './handleEditMessage';
@ -48,33 +45,31 @@ export async function modifyTargetMessage(
const sourceServiceId = getSourceServiceId(message.attributes); const sourceServiceId = getSourceServiceId(message.attributes);
if (type === 'outgoing' || (type === 'story' && ourAci === sourceServiceId)) { if (type === 'outgoing' || (type === 'story' && ourAci === sourceServiceId)) {
const sendActions = MessageReceipts.getSingleton() const sendActions = MessageReceipts.forMessage(message).map(receipt => {
.forMessage(message) let sendActionType: SendActionType;
.map(receipt => { const receiptType = receipt.type;
let sendActionType: SendActionType; switch (receiptType) {
const receiptType = receipt.get('type'); case MessageReceipts.MessageReceiptType.Delivery:
switch (receiptType) { sendActionType = SendActionType.GotDeliveryReceipt;
case MessageReceiptType.Delivery: break;
sendActionType = SendActionType.GotDeliveryReceipt; case MessageReceipts.MessageReceiptType.Read:
break; sendActionType = SendActionType.GotReadReceipt;
case MessageReceiptType.Read: break;
sendActionType = SendActionType.GotReadReceipt; case MessageReceipts.MessageReceiptType.View:
break; sendActionType = SendActionType.GotViewedReceipt;
case MessageReceiptType.View: break;
sendActionType = SendActionType.GotViewedReceipt; default:
break; throw missingCaseError(receiptType);
default: }
throw missingCaseError(receiptType);
}
return { return {
destinationConversationId: receipt.get('sourceConversationId'), destinationConversationId: receipt.sourceConversationId,
action: { action: {
type: sendActionType, type: sendActionType,
updatedAt: receipt.get('receiptTimestamp'), updatedAt: receipt.receiptTimestamp,
}, },
}; };
}); });
const oldSendStateByConversationId = const oldSendStateByConversationId =
message.get('sendStateByConversationId') || {}; message.get('sendStateByConversationId') || {};
@ -111,10 +106,10 @@ export async function modifyTargetMessage(
if (type === 'incoming') { if (type === 'incoming') {
// In a followup (see DESKTOP-2100), we want to make `ReadSyncs#forMessage` return // 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. // 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 readSyncs = readSync ? [readSync] : [];
const viewSyncs = ViewSyncs.getSingleton().forMessage(message); const viewSyncs = ViewSyncs.forMessage(message);
const isGroupStoryReply = const isGroupStoryReply =
isGroup(conversation.attributes) && message.get('storyId'); isGroup(conversation.attributes) && message.get('storyId');
@ -122,8 +117,8 @@ export async function modifyTargetMessage(
if (readSyncs.length !== 0 || viewSyncs.length !== 0) { if (readSyncs.length !== 0 || viewSyncs.length !== 0) {
const markReadAt = Math.min( const markReadAt = Math.min(
Date.now(), Date.now(),
...readSyncs.map(sync => sync.get('readAt')), ...readSyncs.map(sync => sync.readAt),
...viewSyncs.map(sync => sync.get('viewedAt')) ...viewSyncs.map(sync => sync.viewedAt)
); );
if (message.get('expireTimer')) { if (message.get('expireTimer')) {
@ -181,8 +176,7 @@ export async function modifyTargetMessage(
// Check for out-of-order view once open syncs // Check for out-of-order view once open syncs
if (isTapToView(message.attributes)) { if (isTapToView(message.attributes)) {
const viewOnceOpenSync = const viewOnceOpenSync = ViewOnceOpenSyncs.forMessage(message);
ViewOnceOpenSyncs.getSingleton().forMessage(message);
if (viewOnceOpenSync) { if (viewOnceOpenSync) {
await message.markViewOnceMessageViewed({ fromSync: true }); await message.markViewOnceMessageViewed({ fromSync: true });
changed = true; changed = true;
@ -191,7 +185,7 @@ export async function modifyTargetMessage(
} }
if (isStory(message.attributes)) { if (isStory(message.attributes)) {
const viewSyncs = ViewSyncs.getSingleton().forMessage(message); const viewSyncs = ViewSyncs.forMessage(message);
if (viewSyncs.length !== 0) { if (viewSyncs.length !== 0) {
message.set({ message.set({
@ -202,7 +196,7 @@ export async function modifyTargetMessage(
const markReadAt = Math.min( const markReadAt = Math.min(
Date.now(), Date.now(),
...viewSyncs.map(sync => sync.get('viewedAt')) ...viewSyncs.map(sync => sync.viewedAt)
); );
message.setPendingMarkRead( message.setPendingMarkRead(
Math.min(message.getPendingMarkRead() ?? Date.now(), markReadAt) 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? // Does message message have any pending, previously-received associated reactions?
const reactions = Reactions.getSingleton().forMessage(message); const reactions = Reactions.forMessage(message);
await Promise.all( await Promise.all(
reactions.map(async reaction => { reactions.map(async reaction => {
if (isStory(message.attributes)) { if (isStory(message.attributes)) {
// We don't set changed = true here, because we don't modify the original story // 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( strictAssert(
generatedMessage, generatedMessage,
'Story reactions must provide storyReactionMessage' 'Story reactions must provide storyReactionMessage'