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

View file

@ -7,7 +7,6 @@ import * as log from '../logging/log';
import * as Errors from '../types/errors';
import { deleteForEveryone } from '../util/deleteForEveryone';
import { drop } from '../util/drop';
import { filter, size } from '../util/iterables';
import { getMessageSentTimestampSet } from '../util/getMessageSentTimestampSet';
export type DeleteAttributesType = {
@ -20,28 +19,33 @@ export type DeleteAttributesType = {
const deletes = new Map<string, DeleteAttributesType>();
function remove(del: DeleteAttributesType): void {
del.removeFromMessageReceiverCache();
deletes.delete(del.envelopeId);
}
export function forMessage(
messageAttributes: MessageAttributesType
): Array<DeleteAttributesType> {
const sentTimestamps = getMessageSentTimestampSet(messageAttributes);
const matchingDeletes = filter(deletes, ([_envelopeId, item]) => {
const deleteValues = Array.from(deletes.values());
const matchingDeletes = deleteValues.filter(item => {
return (
item.fromId === getContactId(messageAttributes) &&
sentTimestamps.has(item.targetSentTimestamp)
);
});
if (size(matchingDeletes) > 0) {
log.info('Found early DOE for message');
const result = Array.from(matchingDeletes);
result.forEach(([envelopeId, del]) => {
del.removeFromMessageReceiverCache();
deletes.delete(envelopeId);
});
return result.map(([_envelopeId, item]) => item);
if (!matchingDeletes.length) {
return [];
}
return [];
log.info('Found early DOE for message');
matchingDeletes.forEach(del => {
remove(del);
});
return matchingDeletes;
}
export async function onDelete(del: DeleteAttributesType): Promise<void> {
@ -88,11 +92,11 @@ export async function onDelete(del: DeleteAttributesType): Promise<void> {
await deleteForEveryone(message, del);
deletes.delete(del.envelopeId);
del.removeFromMessageReceiverCache();
remove(del);
})
);
} catch (error) {
remove(del);
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 log from '../logging/log';
import { drop } from '../util/drop';
import { filter, size } from '../util/iterables';
import { getContactId } from '../messages/helpers';
import { handleEditMessage } from '../util/handleEditMessage';
import { getMessageSentTimestamp } from '../util/getMessageSentTimestamp';
@ -22,6 +21,11 @@ export type EditAttributesType = {
const edits = new Map<string, EditAttributesType>();
function remove(edit: EditAttributesType): void {
edits.delete(edit.envelopeId);
edit.removeFromMessageReceiverCache();
}
export function forMessage(
messageAttributes: Pick<
MessageAttributesType,
@ -34,22 +38,22 @@ export function forMessage(
>
): Array<EditAttributesType> {
const sentAt = getMessageSentTimestamp(messageAttributes, { log });
const matchingEdits = filter(edits, ([_envelopeId, item]) => {
const editValues = Array.from(edits.values());
const matchingEdits = editValues.filter(item => {
return (
item.targetSentTimestamp === sentAt &&
item.fromId === getContactId(messageAttributes)
);
});
if (size(matchingEdits) > 0) {
const result: Array<EditAttributesType> = [];
if (matchingEdits.length > 0) {
const editsLogIds: Array<number> = [];
Array.from(matchingEdits).forEach(([envelopeId, item]) => {
result.push(item);
const result = matchingEdits.map(item => {
editsLogIds.push(item.message.sent_at);
edits.delete(envelopeId);
item.removeFromMessageReceiverCache();
remove(item);
return item;
});
log.info(
@ -99,7 +103,6 @@ export async function onEdit(edit: EditAttributesType): Promise<void> {
if (!targetMessage) {
log.info(`${logId}: No message`);
return;
}
@ -110,11 +113,11 @@ export async function onEdit(edit: EditAttributesType): Promise<void> {
await handleEditMessage(message.attributes, edit);
edits.delete(edit.envelopeId);
edit.removeFromMessageReceiverCache();
remove(edit);
})
);
} catch (error) {
remove(edit);
log.error(`${logId} error:`, Errors.toLogFormat(error));
}
}

View file

@ -1,10 +1,7 @@
// Copyright 2016 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
/* eslint-disable max-classes-per-file */
import { isEqual } from 'lodash';
import { Collection, Model } from 'backbone';
import type { MessageModel } from '../models/messages';
import type { MessageAttributesType } from '../model-types.d';
@ -26,6 +23,7 @@ import * as log from '../logging/log';
import { getSourceServiceId } from '../messages/helpers';
import { queueUpdateMessage } from '../util/messageBatcher';
import { getMessageSentTimestamp } from '../util/getMessageSentTimestamp';
import { getMessageIdForLogging } from '../util/idForLogging';
const { deleteSentProtoRecipient } = dataInterface;
@ -36,18 +34,18 @@ export enum MessageReceiptType {
}
export type MessageReceiptAttributesType = {
envelopeId: string;
messageSentAt: number;
receiptTimestamp: number;
sourceServiceId: ServiceIdString;
removeFromMessageReceiverCache: () => unknown;
sourceConversationId: string;
sourceDevice: number;
sourceServiceId: ServiceIdString;
type: MessageReceiptType;
wasSentEncrypted: boolean;
};
class MessageReceiptModel extends Model<MessageReceiptAttributesType> {}
let singleton: MessageReceipts | undefined;
const receipts = new Map<string, MessageReceiptAttributesType>();
const deleteSentProtoBatcher = createWaitBatcher({
name: 'deleteSentProtoBatcher',
@ -79,6 +77,11 @@ const deleteSentProtoBatcher = createWaitBatcher({
},
});
function remove(receipt: MessageReceiptAttributesType): void {
receipts.delete(receipt.envelopeId);
receipt.removeFromMessageReceiverCache();
}
async function getTargetMessage(
sourceId: string,
serviceId: ServiceIdString,
@ -124,10 +127,10 @@ const wasDeliveredWithSealedSender = (
);
const shouldDropReceipt = (
receipt: MessageReceiptModel,
receipt: MessageReceiptAttributesType,
message: MessageModel
): boolean => {
const type = receipt.get('type');
const { type } = receipt;
switch (type) {
case MessageReceiptType.Delivery:
return false;
@ -143,248 +146,245 @@ const shouldDropReceipt = (
}
};
export class MessageReceipts extends Collection<MessageReceiptModel> {
static getSingleton(): MessageReceipts {
if (!singleton) {
singleton = new MessageReceipts();
}
return singleton;
export function forMessage(
message: MessageModel
): Array<MessageReceiptAttributesType> {
if (!isOutgoing(message.attributes) && !isStory(message.attributes)) {
return [];
}
forMessage(message: MessageModel): Array<MessageReceiptModel> {
if (!isOutgoing(message.attributes) && !isStory(message.attributes)) {
return [];
}
const logId = `MessageReceipts.forMessage(${getMessageIdForLogging(
message.attributes
)})`;
const ourAci = window.textsecure.storage.user.getCheckedAci();
const sourceServiceId = getSourceServiceId(message.attributes);
if (ourAci !== sourceServiceId) {
return [];
}
const ourAci = window.textsecure.storage.user.getCheckedAci();
const sourceServiceId = getSourceServiceId(message.attributes);
if (ourAci !== sourceServiceId) {
return [];
}
const sentAt = getMessageSentTimestamp(message.attributes, { log });
const receipts = this.filter(
receipt => receipt.get('messageSentAt') === sentAt
);
if (receipts.length) {
log.info(`MessageReceipts: found early receipts for message ${sentAt}`);
this.remove(receipts);
}
return receipts.filter(receipt => {
if (shouldDropReceipt(receipt, message)) {
log.info(
`MessageReceipts: Dropping an early receipt ${receipt.get('type')} ` +
`for message ${sentAt}`
);
return false;
}
const receiptValues = Array.from(receipts.values());
return true;
const sentAt = getMessageSentTimestamp(message.attributes, { log });
const result = receiptValues.filter(item => item.messageSentAt === sentAt);
if (result.length > 0) {
log.info(`${logId}: found early receipts for message ${sentAt}`);
result.forEach(receipt => {
remove(receipt);
});
}
private getNewSendStateByConversationId(
oldSendStateByConversationId: SendStateByConversationId,
receipt: MessageReceiptModel
): SendStateByConversationId {
const receiptTimestamp = receipt.get('receiptTimestamp');
const sourceConversationId = receipt.get('sourceConversationId');
const type = receipt.get('type');
const oldSendState = getOwn(
oldSendStateByConversationId,
sourceConversationId
) ?? { status: SendStatus.Sent, updatedAt: undefined };
let sendActionType: SendActionType;
switch (type) {
case MessageReceiptType.Delivery:
sendActionType = SendActionType.GotDeliveryReceipt;
break;
case MessageReceiptType.Read:
sendActionType = SendActionType.GotReadReceipt;
break;
case MessageReceiptType.View:
sendActionType = SendActionType.GotViewedReceipt;
break;
default:
throw missingCaseError(type);
}
const newSendState = sendStateReducer(oldSendState, {
type: sendActionType,
updatedAt: receiptTimestamp,
});
return {
...oldSendStateByConversationId,
[sourceConversationId]: newSendState,
};
}
private async updateMessageSendState(
receipt: MessageReceiptModel,
message: MessageModel
): Promise<void> {
const messageSentAt = receipt.get('messageSentAt');
return result.filter(receipt => {
if (shouldDropReceipt(receipt, message)) {
log.info(
`MessageReceipts: Dropping a receipt ${receipt.get('type')} ` +
`for message ${messageSentAt}`
`${logId}: Dropping an early receipt ${receipt.type} for message ${sentAt}`
);
return;
return false;
}
let hasChanges = false;
return true;
});
}
const editHistory = message.get('editHistory') ?? [];
const newEditHistory = editHistory?.map(edit => {
if (messageSentAt !== edit.timestamp) {
return edit;
}
function getNewSendStateByConversationId(
oldSendStateByConversationId: SendStateByConversationId,
receipt: MessageReceiptAttributesType
): SendStateByConversationId {
const { receiptTimestamp, sourceConversationId, type } = receipt;
const oldSendStateByConversationId = edit.sendStateByConversationId ?? {};
const newSendStateByConversationId = this.getNewSendStateByConversationId(
oldSendStateByConversationId,
receipt
);
const oldSendState = getOwn(
oldSendStateByConversationId,
sourceConversationId
) ?? { status: SendStatus.Sent, updatedAt: undefined };
return {
...edit,
sendStateByConversationId: newSendStateByConversationId,
};
});
if (!isEqual(newEditHistory, editHistory)) {
message.set('editHistory', newEditHistory);
let sendActionType: SendActionType;
switch (type) {
case MessageReceiptType.Delivery:
sendActionType = SendActionType.GotDeliveryReceipt;
break;
case MessageReceiptType.Read:
sendActionType = SendActionType.GotReadReceipt;
break;
case MessageReceiptType.View:
sendActionType = SendActionType.GotViewedReceipt;
break;
default:
throw missingCaseError(type);
}
const newSendState = sendStateReducer(oldSendState, {
type: sendActionType,
updatedAt: receiptTimestamp,
});
return {
...oldSendStateByConversationId,
[sourceConversationId]: newSendState,
};
}
async function updateMessageSendState(
receipt: MessageReceiptAttributesType,
message: MessageModel
): Promise<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;
}
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> {
const messageSentAt = receipt.get('messageSentAt');
const sourceConversationId = receipt.get('sourceConversationId');
const sourceServiceId = receipt.get('sourceServiceId');
const type = receipt.get('type');
if (hasChanges) {
queueUpdateMessage(message.attributes);
try {
const messages = await window.Signal.Data.getMessagesBySentAt(
messageSentAt
// notify frontend listeners
const conversation = window.ConversationController.get(
message.get('conversationId')
);
const updateLeftPane = conversation
? conversation.debouncedUpdateLastMessage
: undefined;
if (updateLeftPane) {
updateLeftPane();
}
}
const { sourceConversationId, type } = receipt;
if (
(type === MessageReceiptType.Delivery &&
wasDeliveredWithSealedSender(sourceConversationId, message) &&
receipt.wasSentEncrypted) ||
type === MessageReceiptType.Read
) {
const recipient = window.ConversationController.get(sourceConversationId);
const recipientServiceId = recipient?.getServiceId();
const deviceId = receipt.sourceDevice;
if (recipientServiceId && deviceId) {
await Promise.all([
deleteSentProtoBatcher.add({
timestamp: messageSentAt,
recipientServiceId,
deviceId,
}),
// We want the above call to not be delayed when testing with
// CI.
window.SignalCI
? deleteSentProtoBatcher.flushAndWait()
: Promise.resolve(),
]);
} else {
log.warn(
`${logId}: Missing serviceId or deviceId for deliveredTo ${sourceConversationId}`
);
const message = await getTargetMessage(
sourceConversationId,
sourceServiceId,
messages
);
if (message) {
await this.updateMessageSendState(receipt, message);
} else {
// We didn't find any messages but maybe it's a story sent message
const targetMessages = messages.filter(
item =>
item.storyDistributionListId &&
item.sendStateByConversationId &&
!item.deletedForEveryone &&
Boolean(item.sendStateByConversationId[sourceConversationId])
);
// Nope, no target message was found
if (!targetMessages.length) {
log.info(
'MessageReceipts: No message for receipt',
type,
sourceConversationId,
sourceServiceId,
messageSentAt
);
return;
}
await Promise.all(
targetMessages.map(msg => {
const model = window.MessageController.register(msg.id, msg);
return this.updateMessageSendState(receipt, model);
})
);
}
this.remove(receipt);
} catch (error) {
log.error('MessageReceipts.onReceipt error:', Errors.toLogFormat(error));
}
}
}
export async function onReceipt(
receipt: MessageReceiptAttributesType
): Promise<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
// SPDX-License-Identifier: AGPL-3.0-only
/* eslint-disable max-classes-per-file */
import { Collection, Model } from 'backbone';
import type { ConversationModel } from '../models/conversations';
import * as log from '../logging/log';
import * as Errors from '../types/errors';
import type { AciString } from '../types/ServiceId';
import type { ConversationModel } from '../models/conversations';
import * as Errors from '../types/errors';
import * as log from '../logging/log';
import { drop } from '../util/drop';
import { getConversationIdForLogging } from '../util/idForLogging';
export type MessageRequestAttributesType = {
threadE164?: string;
threadAci?: AciString;
envelopeId: string;
groupV2Id?: string;
removeFromMessageReceiverCache: () => unknown;
threadAci?: AciString;
threadE164?: string;
type: number;
};
class MessageRequestModel extends Model<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> {
static getSingleton(): MessageRequests {
if (!singleton) {
singleton = new MessageRequests();
export function forConversation(
conversation: ConversationModel
): MessageRequestAttributesType | null {
const logId = `MessageRequests.forConversation(${getConversationIdForLogging(
conversation.attributes
)})`;
const messageRequestValues = Array.from(messageRequests.values());
if (conversation.get('e164')) {
const syncByE164 = messageRequestValues.find(
item => item.threadE164 === conversation.get('e164')
);
if (syncByE164) {
log.info(`${logId}: Found early message request response for E164`);
remove(syncByE164);
return syncByE164;
}
return singleton;
}
forConversation(conversation: ConversationModel): MessageRequestModel | null {
if (conversation.get('e164')) {
const syncByE164 = this.findWhere({
threadE164: conversation.get('e164'),
});
if (syncByE164) {
log.info(
`Found early message request response for E164 ${conversation.idForLogging()}`
);
this.remove(syncByE164);
return syncByE164;
}
if (conversation.getServiceId()) {
const syncByServiceId = messageRequestValues.find(
item => item.threadAci === conversation.getServiceId()
);
if (syncByServiceId) {
log.info(`${logId}: Found early message request response for serviceId`);
remove(syncByServiceId);
return syncByServiceId;
}
if (conversation.getServiceId()) {
const syncByAci = this.findWhere({
threadAci: conversation.getServiceId(),
});
if (syncByAci) {
log.info(
`Found early message request response for aci ${conversation.idForLogging()}`
);
this.remove(syncByAci);
return syncByAci;
}
}
// V2 group
if (conversation.get('groupId')) {
const syncByGroupId = this.findWhere({
groupV2Id: conversation.get('groupId'),
});
if (syncByGroupId) {
log.info(
`Found early message request response for group v2 ID ${conversation.idForLogging()}`
);
this.remove(syncByGroupId);
return syncByGroupId;
}
}
return null;
}
async onResponse(sync: MessageRequestModel): Promise<void> {
try {
const threadE164 = sync.get('threadE164');
const threadAci = sync.get('threadAci');
const groupV2Id = sync.get('groupV2Id');
// V2 group
if (conversation.get('groupId')) {
const syncByGroupId = messageRequestValues.find(
item => item.groupV2Id === conversation.get('groupId')
);
if (syncByGroupId) {
log.info(`${logId}: Found early message request response for gv2`);
remove(syncByGroupId);
return syncByGroupId;
}
}
let conversation;
return null;
}
// We multiplex between GV1/GV2 groups here, but we don't kick off migrations
if (groupV2Id) {
conversation = window.ConversationController.get(groupV2Id);
}
if (!conversation && (threadE164 || threadAci)) {
conversation = window.ConversationController.lookupOrCreate({
e164: threadE164,
serviceId: threadAci,
reason: 'MessageRequests.onResponse',
});
}
export async function onResponse(
sync: MessageRequestAttributesType
): Promise<void> {
messageRequests.set(sync.envelopeId, sync);
const { threadE164, threadAci, groupV2Id } = sync;
if (!conversation) {
log.warn(
`Received message request response for unknown conversation: groupv2(${groupV2Id}) ${threadAci} ${threadE164}`
);
return;
}
const logId = `MessageRequests.onResponse(groupv2(${groupV2Id}) ${threadAci} ${threadE164})`;
void conversation.applyMessageRequestResponse(sync.get('type'), {
try {
let conversation;
// We multiplex between GV1/GV2 groups here, but we don't kick off migrations
if (groupV2Id) {
conversation = window.ConversationController.get(groupV2Id);
}
if (!conversation && (threadE164 || threadAci)) {
conversation = window.ConversationController.lookupOrCreate({
e164: threadE164,
serviceId: threadAci,
reason: logId,
});
}
if (!conversation) {
log.warn(
`${logId}: received message request response for unknown conversation`
);
remove(sync);
return;
}
drop(
conversation.applyMessageRequestResponse(sync.type, {
fromSync: true,
});
})
);
this.remove(sync);
} catch (error) {
log.error('MessageRequests.onResponse error:', Errors.toLogFormat(error));
}
remove(sync);
} catch (error) {
remove(sync);
log.error(`${logId} error:`, Errors.toLogFormat(error));
}
}

View file

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

View file

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

View file

@ -1,100 +1,108 @@
// Copyright 2019 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
/* eslint-disable max-classes-per-file */
import { Collection, Model } from 'backbone';
import type { MessageModel } from '../models/messages';
import * as log from '../logging/log';
import * as Errors from '../types/errors';
import type { AciString } from '../types/ServiceId';
import type { MessageModel } from '../models/messages';
import * as Errors from '../types/errors';
import * as log from '../logging/log';
import { getMessageIdForLogging } from '../util/idForLogging';
export type ViewOnceOpenSyncAttributesType = {
removeFromMessageReceiverCache: () => unknown;
source?: string;
sourceAci: AciString;
timestamp: number;
};
class ViewOnceOpenSyncModel extends Model<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> {
static getSingleton(): ViewOnceOpenSyncs {
if (!singleton) {
singleton = new ViewOnceOpenSyncs();
}
export function forMessage(
message: MessageModel
): ViewOnceOpenSyncAttributesType | null {
const logId = `ViewOnceOpenSyncs.forMessage(${getMessageIdForLogging(
message.attributes
)})`;
return singleton;
const viewOnceSyncValues = Array.from(viewOnceSyncs.values());
const syncBySourceServiceId = viewOnceSyncValues.find(item => {
return (
item.sourceAci === message.get('sourceServiceId') &&
item.timestamp === message.get('sent_at')
);
});
if (syncBySourceServiceId) {
log.info(`${logId}: Found early view once open sync for message`);
remove(syncBySourceServiceId);
return syncBySourceServiceId;
}
forMessage(message: MessageModel): ViewOnceOpenSyncModel | null {
const syncBySourceAci = this.find(item => {
return (
item.get('sourceAci') === message.get('sourceServiceId') &&
item.get('timestamp') === message.get('sent_at')
);
});
if (syncBySourceAci) {
log.info('Found early view once open sync for message');
this.remove(syncBySourceAci);
return syncBySourceAci;
}
const syncBySource = this.find(item => {
return (
item.get('source') === message.get('source') &&
item.get('timestamp') === message.get('sent_at')
);
});
if (syncBySource) {
log.info('Found early view once open sync for message');
this.remove(syncBySource);
return syncBySource;
}
return null;
const syncBySource = viewOnceSyncValues.find(item => {
return (
item.source === message.get('source') &&
item.timestamp === message.get('sent_at')
);
});
if (syncBySource) {
log.info(`${logId}: Found early view once open sync for message`);
remove(syncBySource);
return syncBySource;
}
async onSync(sync: ViewOnceOpenSyncModel): Promise<void> {
try {
const messages = await window.Signal.Data.getMessagesBySentAt(
sync.get('timestamp')
return null;
}
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 itemSourceAci = item.sourceServiceId;
const syncSourceAci = sync.get('sourceAci');
const itemSource = item.source;
const syncSource = sync.get('source');
const syncSource = sync.source;
const syncSourceAci = sync.sourceAci;
const syncTimestamp = sync.timestamp;
const wasMessageFound = Boolean(found);
log.info(`${logId} receive:`, {
syncSource,
syncSourceAci,
syncTimestamp,
wasMessageFound,
});
return Boolean(
(itemSourceAci && syncSourceAci && itemSourceAci === syncSourceAci) ||
(itemSource && syncSource && itemSource === syncSource)
);
});
const syncSource = sync.get('source');
const syncSourceAci = sync.get('sourceAci');
const syncTimestamp = sync.get('timestamp');
const wasMessageFound = Boolean(found);
log.info('Receive view once open sync:', {
syncSource,
syncSourceAci,
syncTimestamp,
wasMessageFound,
});
if (!found) {
return;
}
const message = window.MessageController.register(found.id, found);
await message.markViewOnceMessageViewed({ fromSync: true });
this.remove(sync);
} catch (error) {
log.error('ViewOnceOpenSyncs.onSync error:', Errors.toLogFormat(error));
if (!found) {
return;
}
const message = window.MessageController.register(found.id, found);
await message.markViewOnceMessageViewed({ fromSync: true });
viewOnceSyncs.delete(sync.timestamp);
sync.removeFromMessageReceiverCache();
} catch (error) {
log.error(`${logId} error:`, Errors.toLogFormat(error));
}
}

View file

@ -1,136 +1,142 @@
// Copyright 2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
/* eslint-disable max-classes-per-file */
import { Collection, Model } from 'backbone';
import type { MessageModel } from '../models/messages';
import { ReadStatus } from '../messages/MessageReadStatus';
import { markViewed } from '../services/MessageUpdater';
import { isDownloaded } from '../types/Attachment';
import type { AciString } from '../types/ServiceId';
import type { MessageModel } from '../models/messages';
import * as Errors from '../types/errors';
import { isIncoming } from '../state/selectors/message';
import { notificationService } from '../services/notifications';
import { queueAttachmentDownloads } from '../util/queueAttachmentDownloads';
import * as log from '../logging/log';
import { GiftBadgeStates } from '../components/conversation/Message';
import { queueUpdateMessage } from '../util/messageBatcher';
import { ReadStatus } from '../messages/MessageReadStatus';
import { getMessageIdForLogging } from '../util/idForLogging';
import { getMessageSentTimestamp } from '../util/getMessageSentTimestamp';
import { isDownloaded } from '../types/Attachment';
import { isIncoming } from '../state/selectors/message';
import { markViewed } from '../services/MessageUpdater';
import { notificationService } from '../services/notifications';
import { queueAttachmentDownloads } from '../util/queueAttachmentDownloads';
import { queueUpdateMessage } from '../util/messageBatcher';
export type ViewSyncAttributesType = {
senderId: string;
senderE164?: string;
envelopeId: string;
removeFromMessageReceiverCache: () => unknown;
senderAci: AciString;
senderE164?: string;
senderId: string;
timestamp: number;
viewedAt: number;
};
class ViewSyncModel extends Model<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 {
static getSingleton(): ViewSyncs {
if (!singleton) {
singleton = new ViewSyncs();
}
export function forMessage(
message: MessageModel
): Array<ViewSyncAttributesType> {
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> {
const sender = window.ConversationController.lookupOrCreate({
e164: message.get('source'),
serviceId: message.get('sourceServiceId'),
reason: 'ViewSyncs.forMessage',
});
const messageTimestamp = getMessageSentTimestamp(message.attributes, {
log,
});
const syncs = this.filter(item => {
return (
item.get('senderId') === sender?.id &&
item.get('timestamp') === messageTimestamp
);
});
if (syncs.length) {
log.info(
`Found ${syncs.length} early view sync(s) for message ${messageTimestamp}`
);
this.remove(syncs);
}
return syncs;
}
return matchingSyncs;
}
async onSync(sync: ViewSyncModel): Promise<void> {
try {
const messages = await window.Signal.Data.getMessagesBySentAt(
sync.get('timestamp')
);
export async function onSync(sync: ViewSyncAttributesType): Promise<void> {
viewSyncs.set(sync.envelopeId, sync);
const found = messages.find(item => {
const sender = window.ConversationController.lookupOrCreate({
e164: item.source,
serviceId: item.sourceServiceId,
reason: 'ViewSyncs.onSync',
});
const logId = `ViewSyncs.onSync(timestamp=${sync.timestamp})`;
return sender?.id === sync.get('senderId');
try {
const messages = await window.Signal.Data.getMessagesBySentAt(
sync.timestamp
);
const found = messages.find(item => {
const sender = window.ConversationController.lookupOrCreate({
e164: item.source,
serviceId: item.sourceServiceId,
reason: logId,
});
if (!found) {
log.info(
'Nothing found for view sync',
sync.get('senderId'),
sync.get('senderE164'),
sync.get('senderAci'),
sync.get('timestamp')
return sender?.id === sync.senderId;
});
if (!found) {
log.info(
`${logId}: nothing found`,
sync.senderId,
sync.senderE164,
sync.senderAci
);
return;
}
notificationService.removeBy({ messageId: found.id });
const message = window.MessageController.register(found.id, found);
let didChangeMessage = false;
if (message.get('readStatus') !== ReadStatus.Viewed) {
didChangeMessage = true;
message.set(markViewed(message.attributes, sync.viewedAt));
const attachments = message.get('attachments');
if (!attachments?.every(isDownloaded)) {
const updatedFields = await queueAttachmentDownloads(
message.attributes
);
return;
}
notificationService.removeBy({ messageId: found.id });
const message = window.MessageController.register(found.id, found);
let didChangeMessage = false;
if (message.get('readStatus') !== ReadStatus.Viewed) {
didChangeMessage = true;
message.set(markViewed(message.attributes, sync.get('viewedAt')));
const attachments = message.get('attachments');
if (!attachments?.every(isDownloaded)) {
const updatedFields = await queueAttachmentDownloads(
message.attributes
);
if (updatedFields) {
message.set(updatedFields);
}
if (updatedFields) {
message.set(updatedFields);
}
}
const giftBadge = message.get('giftBadge');
if (giftBadge) {
didChangeMessage = true;
message.set({
giftBadge: {
...giftBadge,
state: isIncoming(message.attributes)
? GiftBadgeStates.Redeemed
: GiftBadgeStates.Opened,
},
});
}
if (didChangeMessage) {
queueUpdateMessage(message.attributes);
}
this.remove(sync);
} catch (error) {
log.error('ViewSyncs.onSync error:', Errors.toLogFormat(error));
}
const giftBadge = message.get('giftBadge');
if (giftBadge) {
didChangeMessage = true;
message.set({
giftBadge: {
...giftBadge,
state: isIncoming(message.attributes)
? GiftBadgeStates.Redeemed
: GiftBadgeStates.Opened,
},
});
}
if (didChangeMessage) {
queueUpdateMessage(message.attributes);
}
remove(sync);
} catch (error) {
remove(sync);
log.error(`${logId} error:`, Errors.toLogFormat(error));
}
}

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 { AciString, PniString, ServiceIdString } from './types/ServiceId';
import type { StoryDistributionIdString } from './types/StoryDistributionId';
import type { ReactionSource } from './reactions/ReactionSource';
import type { SeenStatus } from './MessageSeenStatus';
import type { GiftBadgeStates } from './components/conversation/Message';
import type { LinkPreviewType } from './types/message/LinkPreviews';
@ -507,16 +506,3 @@ export declare class ConversationModelCollectionType extends Backbone.Collection
}
export declare class MessageModelCollectionType extends Backbone.Collection<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,
conversationQueueJobEnum,
} from '../jobs/conversationJobQueue';
import type { ReactionModel } from '../messageModifiers/Reactions';
import type { ReactionAttributesType } from '../messageModifiers/Reactions';
import { isAnnouncementGroupReady } from '../util/isAnnouncementGroupReady';
import { getProfile } from '../util/getProfile';
import { SEALED_SENDER } from '../types/SealedSender';
@ -4900,7 +4900,7 @@ export class ConversationModel extends window.Backbone
async notify(
message: Readonly<MessageModel>,
reaction?: Readonly<ReactionModel>
reaction?: Readonly<ReactionAttributesType>
): Promise<void> {
// As a performance optimization don't perform any work if notifications are
// disabled.
@ -4938,7 +4938,7 @@ export class ConversationModel extends window.Backbone
const isMessageInDirectConversation = isDirectConversation(this.attributes);
const sender = reaction
? window.ConversationController.get(reaction.get('fromId'))
? window.ConversationController.get(reaction.fromId)
: getContact(message.attributes);
const senderName = sender
? sender.getTitle()
@ -4967,7 +4967,13 @@ export class ConversationModel extends window.Backbone
isExpiringMessage,
message: message.getNotificationText(),
messageId,
reaction: reaction ? reaction.toJSON() : null,
reaction: reaction
? {
emoji: reaction.emoji,
targetAuthorAci: reaction.targetAuthorAci,
targetTimestamp: reaction.targetTimestamp,
}
: undefined,
sentAt: message.get('timestamp'),
type: reaction ? NotificationType.Reaction : NotificationType.Message,
});

View file

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

View file

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

View file

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

View file

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

View file

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