Do not confirm messages until we have handled them
This commit is contained in:
parent
29aa188c0f
commit
04f716986c
16 changed files with 990 additions and 960 deletions
120
ts/background.ts
120
ts/background.ts
|
@ -21,10 +21,7 @@ import createTaskWithTimeout, {
|
|||
resumeTasksWithTimeout,
|
||||
reportLongRunningTasks,
|
||||
} from './textsecure/TaskWithTimeout';
|
||||
import type {
|
||||
MessageAttributesType,
|
||||
ReactionAttributesType,
|
||||
} from './model-types.d';
|
||||
import type { MessageAttributesType } from './model-types.d';
|
||||
import * as Bytes from './Bytes';
|
||||
import * as Timers from './Timers';
|
||||
import * as indexedDb from './indexeddb';
|
||||
|
@ -116,15 +113,13 @@ import { actionCreators } from './state/actions';
|
|||
import * as Deletes from './messageModifiers/Deletes';
|
||||
import type { EditAttributesType } from './messageModifiers/Edits';
|
||||
import * as Edits from './messageModifiers/Edits';
|
||||
import {
|
||||
MessageReceipts,
|
||||
MessageReceiptType,
|
||||
} from './messageModifiers/MessageReceipts';
|
||||
import { MessageRequests } from './messageModifiers/MessageRequests';
|
||||
import { Reactions } from './messageModifiers/Reactions';
|
||||
import { ReadSyncs } from './messageModifiers/ReadSyncs';
|
||||
import { ViewSyncs } from './messageModifiers/ViewSyncs';
|
||||
import { ViewOnceOpenSyncs } from './messageModifiers/ViewOnceOpenSyncs';
|
||||
import type { ReactionAttributesType } from './messageModifiers/Reactions';
|
||||
import * as MessageReceipts from './messageModifiers/MessageReceipts';
|
||||
import * as MessageRequests from './messageModifiers/MessageRequests';
|
||||
import * as Reactions from './messageModifiers/Reactions';
|
||||
import * as ReadSyncs from './messageModifiers/ReadSyncs';
|
||||
import * as ViewSyncs from './messageModifiers/ViewSyncs';
|
||||
import * as ViewOnceOpenSyncs from './messageModifiers/ViewOnceOpenSyncs';
|
||||
import type { DeleteAttributesType } from './messageModifiers/Deletes';
|
||||
import type { MessageReceiptAttributesType } from './messageModifiers/MessageReceipts';
|
||||
import type { MessageRequestAttributesType } from './messageModifiers/MessageRequests';
|
||||
|
@ -982,8 +977,7 @@ export async function startApp(): Promise<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));
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -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));
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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));
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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));
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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));
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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));
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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));
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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));
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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
14
ts/model-types.d.ts
vendored
|
@ -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;
|
||||
};
|
||||
|
|
|
@ -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,
|
||||
});
|
||||
|
|
|
@ -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'
|
||||
|
|
|
@ -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 });
|
||||
}
|
||||
|
|
|
@ -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),
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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'
|
||||
|
|
Loading…
Reference in a new issue