Move receipts and view/read syncs to new syncTasks system

This commit is contained in:
Scott Nonnenberg 2024-06-17 12:24:39 -07:00 committed by GitHub
parent 1a263e63da
commit 75c32e86f0
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
33 changed files with 1242 additions and 612 deletions

View file

@ -636,7 +636,7 @@ message SyncMessage {
message DeleteForMe { message DeleteForMe {
message ConversationIdentifier { message ConversationIdentifier {
oneof identifier { oneof identifier {
string threadAci = 1; string threadServiceId = 1;
bytes threadGroupId = 2; bytes threadGroupId = 2;
string threadE164 = 3; string threadE164 = 3;
} }
@ -644,7 +644,7 @@ message SyncMessage {
message AddressableMessage { message AddressableMessage {
oneof author { oneof author {
string authorAci = 1; string authorServiceId = 1;
string authorE164 = 2; string authorE164 = 2;
} }
optional uint64 sentTimestamp = 3; optional uint64 sentTimestamp = 3;

View file

@ -42,7 +42,10 @@ import { isNotNil } from './util/isNotNil';
import { isBackupEnabled } from './util/isBackupEnabled'; import { isBackupEnabled } from './util/isBackupEnabled';
import { setAppLoadingScreenMessage } from './setAppLoadingScreenMessage'; import { setAppLoadingScreenMessage } from './setAppLoadingScreenMessage';
import { IdleDetector } from './IdleDetector'; import { IdleDetector } from './IdleDetector';
import { expiringMessagesDeletionService } from './services/expiringMessagesDeletion'; import {
initialize as initializeExpiringMessageService,
update as updateExpiringMessagesService,
} from './services/expiringMessagesDeletion';
import { tapToViewMessagesDeletionService } from './services/tapToViewMessagesDeletionService'; import { tapToViewMessagesDeletionService } from './services/tapToViewMessagesDeletionService';
import { getStoriesForRedux, loadStories } from './services/storyLoader'; import { getStoriesForRedux, loadStories } from './services/storyLoader';
import { import {
@ -116,17 +119,12 @@ import * as Edits from './messageModifiers/Edits';
import * as MessageReceipts from './messageModifiers/MessageReceipts'; import * as MessageReceipts from './messageModifiers/MessageReceipts';
import * as MessageRequests from './messageModifiers/MessageRequests'; import * as MessageRequests from './messageModifiers/MessageRequests';
import * as Reactions from './messageModifiers/Reactions'; import * as Reactions from './messageModifiers/Reactions';
import * as ReadSyncs from './messageModifiers/ReadSyncs';
import * as ViewOnceOpenSyncs from './messageModifiers/ViewOnceOpenSyncs'; import * as ViewOnceOpenSyncs from './messageModifiers/ViewOnceOpenSyncs';
import * as ViewSyncs from './messageModifiers/ViewSyncs';
import type { DeleteAttributesType } from './messageModifiers/Deletes'; import type { DeleteAttributesType } from './messageModifiers/Deletes';
import type { EditAttributesType } from './messageModifiers/Edits'; import type { EditAttributesType } from './messageModifiers/Edits';
import type { MessageReceiptAttributesType } from './messageModifiers/MessageReceipts';
import type { MessageRequestAttributesType } from './messageModifiers/MessageRequests'; import type { MessageRequestAttributesType } from './messageModifiers/MessageRequests';
import type { ReactionAttributesType } from './messageModifiers/Reactions'; import type { ReactionAttributesType } from './messageModifiers/Reactions';
import type { ReadSyncAttributesType } from './messageModifiers/ReadSyncs';
import type { ViewOnceOpenSyncAttributesType } from './messageModifiers/ViewOnceOpenSyncs'; import type { ViewOnceOpenSyncAttributesType } from './messageModifiers/ViewOnceOpenSyncs';
import type { ViewSyncAttributesType } from './messageModifiers/ViewSyncs';
import { ReadStatus } from './messages/MessageReadStatus'; import { ReadStatus } from './messages/MessageReadStatus';
import type { SendStateByConversationId } from './messages/MessageSendState'; import type { SendStateByConversationId } from './messages/MessageSendState';
import { SendStatus } from './messages/MessageSendState'; import { SendStatus } from './messages/MessageSendState';
@ -202,7 +200,11 @@ import { getThemeType } from './util/getThemeType';
import { AttachmentDownloadManager } from './jobs/AttachmentDownloadManager'; import { AttachmentDownloadManager } from './jobs/AttachmentDownloadManager';
import { onCallLinkUpdateSync } from './util/onCallLinkUpdateSync'; import { onCallLinkUpdateSync } from './util/onCallLinkUpdateSync';
import { CallMode } from './types/Calling'; import { CallMode } from './types/Calling';
import type { SyncTaskType } from './util/syncTasks';
import { queueSyncTasks } from './util/syncTasks'; import { queueSyncTasks } from './util/syncTasks';
import type { ViewSyncTaskType } from './messageModifiers/ViewSyncs';
import type { ReceiptSyncTaskType } from './messageModifiers/MessageReceipts';
import type { ReadSyncTaskType } from './messageModifiers/ReadSyncs';
import { isEnabled } from './RemoteConfig'; import { isEnabled } from './RemoteConfig';
import { AttachmentBackupManager } from './jobs/AttachmentBackupManager'; import { AttachmentBackupManager } from './jobs/AttachmentBackupManager';
import { getConversationIdForLogging } from './util/idForLogging'; import { getConversationIdForLogging } from './util/idForLogging';
@ -1498,10 +1500,12 @@ export async function startApp(): Promise<void> {
window.Whisper.events.trigger('timetravel'); window.Whisper.events.trigger('timetravel');
}); });
void expiringMessagesDeletionService.update(); initializeExpiringMessageService(singleProtoJobQueue);
void updateExpiringMessagesService();
void tapToViewMessagesDeletionService.update(); void tapToViewMessagesDeletionService.update();
window.Whisper.events.on('timetravel', () => { window.Whisper.events.on('timetravel', () => {
void expiringMessagesDeletionService.update(); void updateExpiringMessagesService();
void tapToViewMessagesDeletionService.update(); void tapToViewMessagesDeletionService.update();
}); });
@ -1833,7 +1837,9 @@ export async function startApp(): Promise<void> {
try { try {
// Note: we always have to register our capabilities all at once, so we do this // Note: we always have to register our capabilities all at once, so we do this
// after connect on every startup // after connect on every startup
await server.registerCapabilities({}); await server.registerCapabilities({
deleteSync: true,
});
} catch (error) { } catch (error) {
log.error( log.error(
'Error: Unable to register our capabilities.', 'Error: Unable to register our capabilities.',
@ -3221,47 +3227,51 @@ export async function startApp(): Promise<void> {
drop(MessageRequests.onResponse(attributes)); drop(MessageRequests.onResponse(attributes));
} }
function onReadReceipt(event: Readonly<ReadEvent>): void { async function onReadReceipt(event: Readonly<ReadEvent>): Promise<void> {
onReadOrViewReceipt({ return onReadOrViewReceipt({
logTitle: 'read receipt', logTitle: 'read receipt',
event, event,
type: MessageReceipts.MessageReceiptType.Read, type: MessageReceipts.messageReceiptTypeSchema.enum.Read,
}); });
} }
function onViewReceipt(event: Readonly<ViewEvent>): void { async function onViewReceipt(event: Readonly<ViewEvent>): Promise<void> {
onReadOrViewReceipt({ return onReadOrViewReceipt({
logTitle: 'view receipt', logTitle: 'view receipt',
event, event,
type: MessageReceipts.MessageReceiptType.View, type: MessageReceipts.messageReceiptTypeSchema.enum.View,
}); });
} }
function onReadOrViewReceipt({ async function onReadOrViewReceipt({
event, event,
logTitle, logTitle,
type, type,
}: Readonly<{ }: Readonly<{
event: ReadEvent | ViewEvent; event: ReadEvent | ViewEvent;
logTitle: string; logTitle: string;
type: type: 'Read' | 'View';
| MessageReceipts.MessageReceiptType.Read }>): Promise<void> {
| MessageReceipts.MessageReceiptType.View; const { receipts, envelopeId, envelopeTimestamp, confirm } = event;
}>): void { const logId = `onReadOrViewReceipt(type=${type}, envelope=${envelopeTimestamp}, envelopeId=${envelopeId})`;
const syncTasks = receipts
.map((receipt): SyncTaskType | undefined => {
const { const {
envelopeTimestamp,
timestamp, timestamp,
source, source,
sourceServiceId, sourceServiceId,
sourceDevice, sourceDevice,
wasSentEncrypted, wasSentEncrypted,
} = event.receipt; } = receipt;
const sourceConversation = window.ConversationController.lookupOrCreate({ const sourceConversation = window.ConversationController.lookupOrCreate(
{
serviceId: sourceServiceId, serviceId: sourceServiceId,
e164: source, e164: source,
reason: `onReadOrViewReceipt(${envelopeTimestamp})`, reason: `onReadOrViewReceipt(${envelopeTimestamp})`,
}); }
strictAssert(sourceConversation, 'Failed to create conversation'); );
log.info( log.info(
logTitle, logTitle,
`${sourceServiceId || source}.${sourceDevice}`, `${sourceServiceId || source}.${sourceDevice}`,
@ -3270,15 +3280,20 @@ export async function startApp(): Promise<void> {
timestamp timestamp
); );
strictAssert( if (!sourceConversation) {
isServiceIdString(sourceServiceId), log.error(`${logId}: Failed to create conversation`);
'onReadOrViewReceipt: Missing sourceServiceId' return undefined;
); }
strictAssert(sourceDevice, 'onReadOrViewReceipt: Missing sourceDevice'); if (!isServiceIdString(sourceServiceId)) {
log.error(`${logId}: Missing sourceServiceId`);
return undefined;
}
if (!sourceDevice) {
log.error(`${logId}: Missing sourceDevice`);
return undefined;
}
const attributes: MessageReceiptAttributesType = { const data: ReceiptSyncTaskType = {
envelopeId: event.receipt.envelopeId,
removeFromMessageReceiverCache: event.confirm,
messageSentAt: timestamp, messageSentAt: timestamp,
receiptTimestamp: envelopeTimestamp, receiptTimestamp: envelopeTimestamp,
sourceConversationId: sourceConversation.id, sourceConversationId: sourceConversation.id,
@ -3287,11 +3302,38 @@ export async function startApp(): Promise<void> {
type, type,
wasSentEncrypted, wasSentEncrypted,
}; };
drop(MessageReceipts.onReceipt(attributes)); return {
id: generateUuid(),
attempts: 1,
createdAt: Date.now(),
data,
envelopeId,
sentAt: envelopeTimestamp,
type,
};
})
.filter(isNotNil);
log.info(`${logId}: Saving ${syncTasks.length} sync tasks`);
await window.Signal.Data.saveSyncTasks(syncTasks);
confirm();
log.info(`${logId}: Queuing ${syncTasks.length} sync tasks`);
await queueSyncTasks(syncTasks, window.Signal.Data.removeSyncTaskById);
log.info(`${logId}: Done`);
} }
async function onReadSync(ev: ReadSyncEvent): Promise<void> { async function onReadSync(ev: ReadSyncEvent): Promise<void> {
const { envelopeTimestamp, sender, senderAci, timestamp } = ev.read; const { reads, envelopeTimestamp, envelopeId, confirm } = ev;
const logId = `onReadSync(envelope=${envelopeTimestamp}, envelopeId=${envelopeId})`;
const syncTasks = reads
.map((read): SyncTaskType | undefined => {
const { sender, senderAci, timestamp } = read;
const readAt = envelopeTimestamp; const readAt = envelopeTimestamp;
const { conversation: senderConversation } = const { conversation: senderConversation } =
window.ConversationController.maybeMergeContacts({ window.ConversationController.maybeMergeContacts({
@ -3311,25 +3353,60 @@ export async function startApp(): Promise<void> {
timestamp timestamp
); );
strictAssert(senderId, 'onReadSync missing senderId'); if (!senderId) {
strictAssert(senderAci, 'onReadSync missing senderAci'); log.error(`${logId}: missing senderId`);
strictAssert(timestamp, 'onReadSync missing timestamp'); return undefined;
}
if (!senderAci) {
log.error(`${logId}: missing senderAci`);
return undefined;
}
if (!timestamp) {
log.error(`${logId}: missing timestamp`);
return undefined;
}
const attributes: ReadSyncAttributesType = { const data: ReadSyncTaskType = {
envelopeId: ev.read.envelopeId, type: 'ReadSync',
removeFromMessageReceiverCache: ev.confirm,
senderId, senderId,
sender, sender,
senderAci, senderAci,
timestamp, timestamp,
readAt, readAt,
}; };
return {
id: generateUuid(),
attempts: 1,
createdAt: Date.now(),
data,
envelopeId,
sentAt: envelopeTimestamp,
type: 'ReadSync',
};
})
.filter(isNotNil);
await ReadSyncs.onSync(attributes); log.info(`${logId}: Saving ${syncTasks.length} sync tasks`);
await window.Signal.Data.saveSyncTasks(syncTasks);
confirm();
log.info(`${logId}: Queuing ${syncTasks.length} sync tasks`);
await queueSyncTasks(syncTasks, window.Signal.Data.removeSyncTaskById);
log.info(`${logId}: Done`);
} }
async function onViewSync(ev: ViewSyncEvent): Promise<void> { async function onViewSync(ev: ViewSyncEvent): Promise<void> {
const { envelopeTimestamp, senderE164, senderAci, timestamp } = ev.view; const { envelopeTimestamp, envelopeId, views, confirm } = ev;
const logId = `onViewSync=(envelope=${envelopeTimestamp}, envelopeId=${envelopeId})`;
const syncTasks = views
.map((view): SyncTaskType | undefined => {
const { senderAci, senderE164, timestamp } = view;
const { conversation: senderConversation } = const { conversation: senderConversation } =
window.ConversationController.maybeMergeContacts({ window.ConversationController.maybeMergeContacts({
e164: senderE164, e164: senderE164,
@ -3348,27 +3425,62 @@ export async function startApp(): Promise<void> {
timestamp timestamp
); );
strictAssert(senderId, 'onViewSync missing senderId'); if (!senderId) {
strictAssert(senderAci, 'onViewSync missing senderAci'); log.error(`${logId}: missing senderId`);
strictAssert(timestamp, 'onViewSync missing timestamp'); return undefined;
}
if (!senderAci) {
log.error(`${logId}: missing senderAci`);
return undefined;
}
if (!timestamp) {
log.error(`${logId}: missing timestamp`);
return undefined;
}
const attributes: ViewSyncAttributesType = { const data: ViewSyncTaskType = {
envelopeId: ev.view.envelopeId, type: 'ViewSync',
removeFromMessageReceiverCache: ev.confirm,
senderId, senderId,
senderE164, senderE164,
senderAci, senderAci,
timestamp, timestamp,
viewedAt: envelopeTimestamp, viewedAt: envelopeTimestamp,
}; };
return {
id: generateUuid(),
attempts: 1,
createdAt: Date.now(),
data,
envelopeId,
sentAt: envelopeTimestamp,
type: 'ViewSync',
};
})
.filter(isNotNil);
await ViewSyncs.onSync(attributes); log.info(`${logId}: Saving ${syncTasks.length} sync tasks`);
await window.Signal.Data.saveSyncTasks(syncTasks);
confirm();
log.info(`${logId}: Queuing ${syncTasks.length} sync tasks`);
await queueSyncTasks(syncTasks, window.Signal.Data.removeSyncTaskById);
log.info(`${logId}: Done`);
} }
function onDeliveryReceipt(ev: DeliveryEvent): void { async function onDeliveryReceipt(ev: DeliveryEvent): Promise<void> {
const { deliveryReceipt } = ev; const { deliveryReceipts, envelopeId, envelopeTimestamp, confirm } = ev;
const logId = `onDeliveryReceipt(envelope=${envelopeTimestamp}, envelopeId=${envelopeId})`;
strictAssert(envelopeTimestamp, `${logId}: missing envelopeTimestamp`);
strictAssert(envelopeTimestamp, `${logId}: missing envelopeId`);
const syncTasks = deliveryReceipts
.map((deliveryReceipt): SyncTaskType | undefined => {
const { const {
envelopeTimestamp,
sourceServiceId, sourceServiceId,
source, source,
sourceDevice, sourceDevice,
@ -3376,11 +3488,13 @@ export async function startApp(): Promise<void> {
wasSentEncrypted, wasSentEncrypted,
} = deliveryReceipt; } = deliveryReceipt;
const sourceConversation = window.ConversationController.lookupOrCreate({ const sourceConversation = window.ConversationController.lookupOrCreate(
{
serviceId: sourceServiceId, serviceId: sourceServiceId,
e164: source, e164: source,
reason: `onDeliveryReceipt(${envelopeTimestamp})`, reason: `onDeliveryReceipt(${envelopeTimestamp})`,
}); }
);
log.info( log.info(
'delivery receipt from', 'delivery receipt from',
@ -3391,30 +3505,51 @@ export async function startApp(): Promise<void> {
`wasSentEncrypted=${wasSentEncrypted}` `wasSentEncrypted=${wasSentEncrypted}`
); );
strictAssert( if (!isServiceIdString(sourceServiceId)) {
envelopeTimestamp, log.error(`${logId}: missing valid sourceServiceId`);
'onDeliveryReceipt: missing envelopeTimestamp' return undefined;
); }
strictAssert( if (!sourceDevice) {
isServiceIdString(sourceServiceId), log.error(`${logId}: missing sourceDevice`);
'onDeliveryReceipt: missing valid sourceServiceId' return undefined;
); }
strictAssert(sourceDevice, 'onDeliveryReceipt: missing sourceDevice'); if (!sourceConversation) {
strictAssert(sourceConversation, 'onDeliveryReceipt: missing conversation'); log.error(`${logId}: missing conversation`);
return undefined;
}
const attributes: MessageReceiptAttributesType = { const data: ReceiptSyncTaskType = {
envelopeId: ev.deliveryReceipt.envelopeId,
removeFromMessageReceiverCache: ev.confirm,
messageSentAt: timestamp, messageSentAt: timestamp,
receiptTimestamp: envelopeTimestamp, receiptTimestamp: envelopeTimestamp,
sourceConversationId: sourceConversation.id, sourceConversationId: sourceConversation.id,
sourceServiceId, sourceServiceId,
sourceDevice, sourceDevice,
type: MessageReceipts.MessageReceiptType.Delivery, type: MessageReceipts.messageReceiptTypeSchema.enum.Delivery,
wasSentEncrypted, wasSentEncrypted,
}; };
return {
id: generateUuid(),
attempts: 1,
createdAt: Date.now(),
data,
envelopeId,
sentAt: envelopeTimestamp,
type: 'Delivery',
};
})
.filter(isNotNil);
drop(MessageReceipts.onReceipt(attributes)); log.info(`${logId}: Saving ${syncTasks.length} sync tasks`);
await window.Signal.Data.saveSyncTasks(syncTasks);
confirm();
log.info(`${logId}: Queuing ${syncTasks.length} sync tasks`);
await queueSyncTasks(syncTasks, window.Signal.Data.removeSyncTaskById);
log.info(`${logId}: Done`);
} }
async function onDeleteForMeSync(ev: DeleteForMeSyncEvent) { async function onDeleteForMeSync(ev: DeleteForMeSyncEvent) {

View file

@ -1,6 +1,7 @@
// Copyright 2016 Signal Messenger, LLC // Copyright 2016 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only // SPDX-License-Identifier: AGPL-3.0-only
import { z } from 'zod';
import { groupBy } from 'lodash'; import { groupBy } from 'lodash';
import type { MessageModel } from '../models/messages'; import type { MessageModel } from '../models/messages';
@ -10,7 +11,7 @@ import { isOutgoing, isStory } from '../state/selectors/message';
import { getOwn } from '../util/getOwn'; import { getOwn } from '../util/getOwn';
import { missingCaseError } from '../util/missingCaseError'; import { missingCaseError } from '../util/missingCaseError';
import { createWaitBatcher } from '../util/waitBatcher'; import { createWaitBatcher } from '../util/waitBatcher';
import type { ServiceIdString } from '../types/ServiceId'; import { isServiceIdString } from '../types/ServiceId';
import { import {
SendActionType, SendActionType,
SendStatus, SendStatus,
@ -23,7 +24,6 @@ import * as log from '../logging/log';
import { getSourceServiceId } from '../messages/helpers'; import { getSourceServiceId } from '../messages/helpers';
import { getMessageSentTimestamp } from '../util/getMessageSentTimestamp'; import { getMessageSentTimestamp } from '../util/getMessageSentTimestamp';
import { getMessageIdForLogging } from '../util/idForLogging'; import { getMessageIdForLogging } from '../util/idForLogging';
import { generateCacheKey } from './generateCacheKey';
import { getPropForTimestamp } from '../util/editHelpers'; import { getPropForTimestamp } from '../util/editHelpers';
import { import {
DELETE_SENT_PROTO_BATCHER_WAIT_MS, DELETE_SENT_PROTO_BATCHER_WAIT_MS,
@ -31,34 +31,30 @@ import {
} from '../types/Receipt'; } from '../types/Receipt';
import { drop } from '../util/drop'; import { drop } from '../util/drop';
const { deleteSentProtoRecipient } = dataInterface; const { deleteSentProtoRecipient, removeSyncTaskById } = dataInterface;
export enum MessageReceiptType { export const messageReceiptTypeSchema = z.enum(['Delivery', 'Read', 'View']);
Delivery = 'Delivery',
Read = 'Read', export type MessageReceiptType = z.infer<typeof messageReceiptTypeSchema>;
View = 'View',
} export const receiptSyncTaskSchema = z.object({
messageSentAt: z.number(),
receiptTimestamp: z.number(),
sourceConversationId: z.string(),
sourceDevice: z.number(),
sourceServiceId: z.string().refine(isServiceIdString),
type: messageReceiptTypeSchema,
wasSentEncrypted: z.boolean(),
});
export type ReceiptSyncTaskType = z.infer<typeof receiptSyncTaskSchema>;
export type MessageReceiptAttributesType = { export type MessageReceiptAttributesType = {
envelopeId: string; envelopeId: string;
messageSentAt: number; syncTaskId: string;
receiptTimestamp: number; receiptSync: ReceiptSyncTaskType;
removeFromMessageReceiverCache: () => void;
sourceConversationId: string;
sourceDevice: number;
sourceServiceId: ServiceIdString;
type: MessageReceiptType;
wasSentEncrypted: boolean;
}; };
function getReceiptCacheKey(receipt: MessageReceiptAttributesType): string {
return generateCacheKey({
sender: receipt.sourceServiceId,
timestamp: receipt.messageSentAt,
type: receipt.type,
});
}
const cachedReceipts = new Map<string, MessageReceiptAttributesType>(); const cachedReceipts = new Map<string, MessageReceiptAttributesType>();
const processReceiptBatcher = createWaitBatcher({ const processReceiptBatcher = createWaitBatcher({
@ -69,7 +65,7 @@ const processReceiptBatcher = createWaitBatcher({
// First group by sentAt, so that we can find the target message // First group by sentAt, so that we can find the target message
const receiptsByMessageSentAt = groupBy( const receiptsByMessageSentAt = groupBy(
receipts, receipts,
receipt => receipt.messageSentAt receipt => receipt.receiptSync.messageSentAt
); );
// Once we find the message, we'll group them by messageId to process // Once we find the message, we'll group them by messageId to process
@ -99,7 +95,7 @@ const processReceiptBatcher = createWaitBatcher({
continue; continue;
} }
// All receipts have the same sentAt, so we can grab it from the first // All receipts have the same sentAt, so we can grab it from the first
const sentAt = receiptsForMessageSentAt[0].messageSentAt; const sentAt = receiptsForMessageSentAt[0].receiptSync.messageSentAt;
const messagesMatchingTimestamp = const messagesMatchingTimestamp =
// eslint-disable-next-line no-await-in-loop // eslint-disable-next-line no-await-in-loop
@ -114,14 +110,16 @@ const processReceiptBatcher = createWaitBatcher({
if (reaction) { if (reaction) {
for (const receipt of receiptsForMessageSentAt) { for (const receipt of receiptsForMessageSentAt) {
const { receiptSync } = receipt;
log.info( log.info(
'MesageReceipts.processReceiptBatcher: Got receipt for reaction', 'MesageReceipts.processReceiptBatcher: Got receipt for reaction',
receipt.messageSentAt, receiptSync.messageSentAt,
receipt.type, receiptSync.type,
receipt.sourceConversationId, receiptSync.sourceConversationId,
receipt.sourceServiceId receiptSync.sourceServiceId
); );
remove(receipt); // eslint-disable-next-line no-await-in-loop
await remove(receipt);
} }
continue; continue;
} }
@ -129,7 +127,7 @@ const processReceiptBatcher = createWaitBatcher({
for (const receipt of receiptsForMessageSentAt) { for (const receipt of receiptsForMessageSentAt) {
const targetMessage = getTargetMessage({ const targetMessage = getTargetMessage({
sourceConversationId: receipt.sourceConversationId, sourceConversationId: receipt.receiptSync.sourceConversationId,
targetTimestamp: sentAt, targetTimestamp: sentAt,
messagesMatchingTimestamp, messagesMatchingTimestamp,
}); });
@ -144,7 +142,9 @@ const processReceiptBatcher = createWaitBatcher({
item.sendStateByConversationId && item.sendStateByConversationId &&
!item.deletedForEveryone && !item.deletedForEveryone &&
Boolean( Boolean(
item.sendStateByConversationId[receipt.sourceConversationId] item.sendStateByConversationId[
receipt.receiptSync.sourceConversationId
]
) )
); );
@ -154,12 +154,13 @@ const processReceiptBatcher = createWaitBatcher({
); );
} else { } else {
// Nope, no target message was found // Nope, no target message was found
const { receiptSync } = receipt;
log.info( log.info(
'MessageReceipts.processReceiptBatcher: No message for receipt', 'MessageReceipts.processReceiptBatcher: No message for receipt',
receipt.messageSentAt, receiptSync.messageSentAt,
receipt.type, receiptSync.type,
receipt.sourceConversationId, receiptSync.sourceConversationId,
receipt.sourceServiceId receiptSync.sourceServiceId
); );
} }
} }
@ -190,7 +191,7 @@ async function processReceiptsForMessage(
messageId messageId
); );
const { updatedMessage, validReceipts } = updateMessageWithReceipts( const { updatedMessage, validReceipts } = await updateMessageWithReceipts(
message, message,
receipts receipts
); );
@ -204,7 +205,8 @@ async function processReceiptsForMessage(
// Confirm/remove receipts, and delete sent protos // Confirm/remove receipts, and delete sent protos
for (const receipt of validReceipts) { for (const receipt of validReceipts) {
remove(receipt); // eslint-disable-next-line no-await-in-loop
await remove(receipt);
drop(addToDeleteSentProtoBatcher(receipt, updatedMessage)); drop(addToDeleteSentProtoBatcher(receipt, updatedMessage));
} }
@ -215,25 +217,27 @@ async function processReceiptsForMessage(
conversation?.debouncedUpdateLastMessage?.(); conversation?.debouncedUpdateLastMessage?.();
} }
function updateMessageWithReceipts( async function updateMessageWithReceipts(
message: MessageAttributesType, message: MessageAttributesType,
receipts: Array<MessageReceiptAttributesType> receipts: Array<MessageReceiptAttributesType>
): { ): Promise<{
updatedMessage: MessageAttributesType; updatedMessage: MessageAttributesType;
validReceipts: Array<MessageReceiptAttributesType>; validReceipts: Array<MessageReceiptAttributesType>;
} { }> {
const logId = `updateMessageWithReceipts(timestamp=${message.timestamp})`; const logId = `updateMessageWithReceipts(timestamp=${message.timestamp})`;
const toRemove: Array<MessageReceiptAttributesType> = [];
const receiptsToProcess = receipts.filter(receipt => { const receiptsToProcess = receipts.filter(receipt => {
if (shouldDropReceipt(receipt, message)) { if (shouldDropReceipt(receipt, message)) {
const { receiptSync } = receipt;
log.info( log.info(
`${logId}: Dropping a receipt ${receipt.type} for sentAt=${receipt.messageSentAt}` `${logId}: Dropping a receipt ${receiptSync.type} for sentAt=${receiptSync.messageSentAt}`
); );
remove(receipt); toRemove.push(receipt);
return false; return false;
} }
if (!cachedReceipts.has(getReceiptCacheKey(receipt))) { if (!cachedReceipts.has(receipt.syncTaskId)) {
// Between the time it was received and now, this receipt has already been handled! // Between the time it was received and now, this receipt has already been handled!
return false; return false;
} }
@ -241,6 +245,8 @@ function updateMessageWithReceipts(
return true; return true;
}); });
await Promise.all(toRemove.map(remove));
log.info( log.info(
`${logId}: batch processing ${receipts.length}` + `${logId}: batch processing ${receipts.length}` +
` receipt${receipts.length === 1 ? '' : 's'}` ` receipt${receipts.length === 1 ? '' : 's'}`
@ -287,9 +293,10 @@ const deleteSentProtoBatcher = createWaitBatcher({
}, },
}); });
function remove(receipt: MessageReceiptAttributesType): void { async function remove(receipt: MessageReceiptAttributesType): Promise<void> {
cachedReceipts.delete(getReceiptCacheKey(receipt)); const { syncTaskId } = receipt;
receipt.removeFromMessageReceiverCache(); cachedReceipts.delete(syncTaskId);
await removeSyncTaskById(syncTaskId);
} }
function getTargetMessage({ function getTargetMessage({
@ -372,13 +379,13 @@ const shouldDropReceipt = (
receipt: MessageReceiptAttributesType, receipt: MessageReceiptAttributesType,
message: MessageAttributesType message: MessageAttributesType
): boolean => { ): boolean => {
const { type } = receipt; const { type } = receipt.receiptSync;
switch (type) { switch (type) {
case MessageReceiptType.Delivery: case messageReceiptTypeSchema.Enum.Delivery:
return false; return false;
case MessageReceiptType.Read: case messageReceiptTypeSchema.Enum.Read:
return !window.storage.get('read-receipt-setting'); return !window.storage.get('read-receipt-setting');
case MessageReceiptType.View: case messageReceiptTypeSchema.Enum.View:
if (isStory(message)) { if (isStory(message)) {
return !window.Events.getStoryViewReceiptsEnabled(); return !window.Events.getStoryViewReceiptsEnabled();
} }
@ -388,9 +395,9 @@ const shouldDropReceipt = (
} }
}; };
export function forMessage( export async function forMessage(
message: MessageModel message: MessageModel
): Array<MessageReceiptAttributesType> { ): Promise<Array<MessageReceiptAttributesType>> {
if (!isOutgoing(message.attributes) && !isStory(message.attributes)) { if (!isOutgoing(message.attributes) && !isStory(message.attributes)) {
return []; return [];
} }
@ -408,20 +415,23 @@ export function forMessage(
const receiptValues = Array.from(cachedReceipts.values()); const receiptValues = Array.from(cachedReceipts.values());
const sentAt = getMessageSentTimestamp(message.attributes, { log }); const sentAt = getMessageSentTimestamp(message.attributes, { log });
const result = receiptValues.filter(item => item.messageSentAt === sentAt); const result = receiptValues.filter(
item => item.receiptSync.messageSentAt === sentAt
);
if (result.length > 0) { if (result.length > 0) {
log.info(`${logId}: found early receipts for message ${sentAt}`); log.info(`${logId}: found early receipts for message ${sentAt}`);
result.forEach(receipt => { await Promise.all(
remove(receipt); result.map(async receipt => {
}); await remove(receipt);
})
);
} }
return result.filter(receipt => { return result.filter(receipt => {
if (shouldDropReceipt(receipt, message.attributes)) { if (shouldDropReceipt(receipt, message.attributes)) {
log.info( log.info(
`${logId}: Dropping an early receipt ${receipt.type} for message ${sentAt}` `${logId}: Dropping an early receipt ${receipt.receiptSync.type} for message ${sentAt}`
); );
remove(receipt);
return false; return false;
} }
@ -433,7 +443,7 @@ function getNewSendStateByConversationId(
oldSendStateByConversationId: SendStateByConversationId, oldSendStateByConversationId: SendStateByConversationId,
receipt: MessageReceiptAttributesType receipt: MessageReceiptAttributesType
): SendStateByConversationId { ): SendStateByConversationId {
const { receiptTimestamp, sourceConversationId, type } = receipt; const { receiptTimestamp, sourceConversationId, type } = receipt.receiptSync;
const oldSendState = getOwn( const oldSendState = getOwn(
oldSendStateByConversationId, oldSendStateByConversationId,
sourceConversationId sourceConversationId
@ -441,13 +451,13 @@ function getNewSendStateByConversationId(
let sendActionType: SendActionType; let sendActionType: SendActionType;
switch (type) { switch (type) {
case MessageReceiptType.Delivery: case messageReceiptTypeSchema.enum.Delivery:
sendActionType = SendActionType.GotDeliveryReceipt; sendActionType = SendActionType.GotDeliveryReceipt;
break; break;
case MessageReceiptType.Read: case messageReceiptTypeSchema.enum.Read:
sendActionType = SendActionType.GotReadReceipt; sendActionType = SendActionType.GotReadReceipt;
break; break;
case MessageReceiptType.View: case messageReceiptTypeSchema.enum.View:
sendActionType = SendActionType.GotViewedReceipt; sendActionType = SendActionType.GotViewedReceipt;
break; break;
default: default:
@ -467,7 +477,7 @@ function updateMessageSendStateWithReceipt(
message: MessageAttributesType, message: MessageAttributesType,
receipt: MessageReceiptAttributesType receipt: MessageReceiptAttributesType
): Partial<MessageAttributesType> { ): Partial<MessageAttributesType> {
const { messageSentAt } = receipt; const { messageSentAt } = receipt.receiptSync;
const newAttributes: Partial<MessageAttributesType> = {}; const newAttributes: Partial<MessageAttributesType> = {};
@ -510,27 +520,34 @@ async function addToDeleteSentProtoBatcher(
receipt: MessageReceiptAttributesType, receipt: MessageReceiptAttributesType,
message: MessageAttributesType message: MessageAttributesType
) { ) {
const { sourceConversationId, type } = receipt; const { receiptSync } = receipt;
const {
sourceConversationId,
type,
wasSentEncrypted,
messageSentAt,
sourceDevice,
} = receiptSync;
if ( if (
(type === MessageReceiptType.Delivery && (type === messageReceiptTypeSchema.enum.Delivery &&
wasDeliveredWithSealedSender(sourceConversationId, message) && wasDeliveredWithSealedSender(sourceConversationId, message) &&
receipt.wasSentEncrypted) || wasSentEncrypted) ||
type === MessageReceiptType.Read type === messageReceiptTypeSchema.enum.Read
) { ) {
const recipient = window.ConversationController.get(sourceConversationId); const recipient = window.ConversationController.get(sourceConversationId);
const recipientServiceId = recipient?.getServiceId(); const recipientServiceId = recipient?.getServiceId();
const deviceId = receipt.sourceDevice; const deviceId = sourceDevice;
if (recipientServiceId && deviceId) { if (recipientServiceId && deviceId) {
await deleteSentProtoBatcher.add({ await deleteSentProtoBatcher.add({
timestamp: receipt.messageSentAt, timestamp: messageSentAt,
recipientServiceId, recipientServiceId,
deviceId, deviceId,
}); });
} else { } else {
log.warn( log.warn(
`MessageReceipts.deleteSentProto(sentAt=${receipt.messageSentAt}): ` + `MessageReceipts.deleteSentProto(sentAt=${messageSentAt}): ` +
`Missing serviceId or deviceId for deliveredTo ${sourceConversationId}` `Missing serviceId or deviceId for deliveredTo ${sourceConversationId}`
); );
} }
@ -540,6 +557,6 @@ async function addToDeleteSentProtoBatcher(
export async function onReceipt( export async function onReceipt(
receipt: MessageReceiptAttributesType receipt: MessageReceiptAttributesType
): Promise<void> { ): Promise<void> {
cachedReceipts.set(getReceiptCacheKey(receipt), receipt); cachedReceipts.set(receipt.syncTaskId, receipt);
await processReceiptBatcher.add(receipt); await processReceiptBatcher.add(receipt);
} }

View file

@ -1,7 +1,8 @@
// Copyright 2017 Signal Messenger, LLC // Copyright 2017 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only // SPDX-License-Identifier: AGPL-3.0-only
import type { AciString } from '../types/ServiceId'; import { z } from 'zod';
import type { MessageModel } from '../models/messages'; import type { MessageModel } from '../models/messages';
import * as Errors from '../types/errors'; import * as Errors from '../types/errors';
import * as log from '../logging/log'; import * as log from '../logging/log';
@ -14,58 +15,69 @@ import { isMessageUnread } from '../util/isMessageUnread';
import { notificationService } from '../services/notifications'; import { notificationService } from '../services/notifications';
import { queueUpdateMessage } from '../util/messageBatcher'; import { queueUpdateMessage } from '../util/messageBatcher';
import { strictAssert } from '../util/assert'; import { strictAssert } from '../util/assert';
import { generateCacheKey } from './generateCacheKey'; import { isAciString } from '../util/isAciString';
import dataInterface from '../sql/Client';
const { removeSyncTaskById } = dataInterface;
export const readSyncTaskSchema = z.object({
type: z.literal('ReadSync').readonly(),
readAt: z.number(),
sender: z.string().optional(),
senderAci: z.string().refine(isAciString),
senderId: z.string(),
timestamp: z.number(),
});
export type ReadSyncTaskType = z.infer<typeof readSyncTaskSchema>;
export type ReadSyncAttributesType = { export type ReadSyncAttributesType = {
envelopeId: string; envelopeId: string;
readAt: number; syncTaskId: string;
removeFromMessageReceiverCache: () => unknown; readSync: ReadSyncTaskType;
sender?: string;
senderAci: AciString;
senderId: string;
timestamp: number;
}; };
const readSyncs = new Map<string, ReadSyncAttributesType>(); const readSyncs = new Map<string, ReadSyncAttributesType>();
function remove(sync: ReadSyncAttributesType): void { async function remove(sync: ReadSyncAttributesType): Promise<void> {
readSyncs.delete( const { syncTaskId } = sync;
generateCacheKey({ readSyncs.delete(syncTaskId);
sender: sync.senderId, await removeSyncTaskById(syncTaskId);
timestamp: sync.timestamp,
type: 'readsync',
})
);
sync.removeFromMessageReceiverCache();
} }
async function maybeItIsAReactionReadSync( async function maybeItIsAReactionReadSync(
sync: ReadSyncAttributesType sync: ReadSyncAttributesType
): Promise<void> { ): Promise<void> {
const logId = `ReadSyncs.onSync(timestamp=${sync.timestamp})`; const { readSync } = sync;
const logId = `ReadSyncs.onSync(timestamp=${readSync.timestamp})`;
const readReaction = await window.Signal.Data.markReactionAsRead( const readReaction = await window.Signal.Data.markReactionAsRead(
sync.senderAci, readSync.senderAci,
Number(sync.timestamp) Number(readSync.timestamp)
); );
if ( if (
!readReaction || !readReaction ||
readReaction?.targetAuthorAci !== window.storage.user.getCheckedAci() readReaction?.targetAuthorAci !== window.storage.user.getCheckedAci()
) { ) {
log.info(`${logId} not found:`, sync.senderId, sync.sender, sync.senderAci); log.info(
`${logId} not found:`,
readSync.senderId,
readSync.sender,
readSync.senderAci
);
return; return;
} }
log.info( log.info(
`${logId} read reaction sync found:`, `${logId} read reaction sync found:`,
readReaction.conversationId, readReaction.conversationId,
sync.senderId, readSync.senderId,
sync.sender, readSync.sender,
sync.senderAci readSync.senderAci
); );
remove(sync); await remove(sync);
notificationService.removeBy({ notificationService.removeBy({
conversationId: readReaction.conversationId, conversationId: readReaction.conversationId,
@ -75,9 +87,9 @@ async function maybeItIsAReactionReadSync(
}); });
} }
export function forMessage( export async function forMessage(
message: MessageModel message: MessageModel
): ReadSyncAttributesType | null { ): Promise<ReadSyncAttributesType | null> {
const logId = `ReadSyncs.forMessage(${getMessageIdForLogging( const logId = `ReadSyncs.forMessage(${getMessageIdForLogging(
message.attributes message.attributes
)})`; )})`;
@ -92,13 +104,17 @@ export function forMessage(
}); });
const readSyncValues = Array.from(readSyncs.values()); const readSyncValues = Array.from(readSyncs.values());
const foundSync = readSyncValues.find(item => { const foundSync = readSyncValues.find(item => {
return item.senderId === sender?.id && item.timestamp === messageTimestamp; const { readSync } = item;
return (
readSync.senderId === sender?.id &&
readSync.timestamp === messageTimestamp
);
}); });
if (foundSync) { if (foundSync) {
log.info( log.info(
`${logId}: Found early read sync for message ${foundSync.timestamp}` `${logId}: Found early read sync for message ${foundSync.readSync.timestamp}`
); );
remove(foundSync); await remove(foundSync);
return foundSync; return foundSync;
} }
@ -106,20 +122,15 @@ export function forMessage(
} }
export async function onSync(sync: ReadSyncAttributesType): Promise<void> { export async function onSync(sync: ReadSyncAttributesType): Promise<void> {
readSyncs.set( const { readSync, syncTaskId } = sync;
generateCacheKey({
sender: sync.senderId,
timestamp: sync.timestamp,
type: 'readsync',
}),
sync
);
const logId = `ReadSyncs.onSync(timestamp=${sync.timestamp})`; readSyncs.set(syncTaskId, sync);
const logId = `ReadSyncs.onSync(timestamp=${readSync.timestamp})`;
try { try {
const messages = await window.Signal.Data.getMessagesBySentAt( const messages = await window.Signal.Data.getMessagesBySentAt(
sync.timestamp readSync.timestamp
); );
const found = messages.find(item => { const found = messages.find(item => {
@ -129,7 +140,7 @@ export async function onSync(sync: ReadSyncAttributesType): Promise<void> {
reason: logId, reason: logId,
}); });
return isIncoming(item) && sender?.id === sync.senderId; return isIncoming(item) && sender?.id === readSync.senderId;
}); });
if (!found) { if (!found) {
@ -144,8 +155,8 @@ export async function onSync(sync: ReadSyncAttributesType): Promise<void> {
found, found,
'ReadSyncs.onSync' 'ReadSyncs.onSync'
); );
const readAt = Math.min(sync.readAt, Date.now()); const readAt = Math.min(readSync.readAt, Date.now());
const newestSentAt = sync.timestamp; const newestSentAt = readSync.timestamp;
// If message is unread, we mark it read. Otherwise, we update the expiration // 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 // timer to the time specified by the read sync if it's earlier than
@ -193,9 +204,9 @@ export async function onSync(sync: ReadSyncAttributesType): Promise<void> {
queueUpdateMessage(message.attributes); queueUpdateMessage(message.attributes);
remove(sync); await remove(sync);
} catch (error) { } catch (error) {
remove(sync);
log.error(`${logId} error:`, Errors.toLogFormat(error)); log.error(`${logId} error:`, Errors.toLogFormat(error));
await remove(sync);
} }
} }

View file

@ -1,7 +1,8 @@
// Copyright 2021 Signal Messenger, LLC // Copyright 2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only // SPDX-License-Identifier: AGPL-3.0-only
import type { AciString } from '../types/ServiceId'; import { z } from 'zod';
import type { MessageModel } from '../models/messages'; import type { MessageModel } from '../models/messages';
import * as Errors from '../types/errors'; import * as Errors from '../types/errors';
import * as log from '../logging/log'; import * as log from '../logging/log';
@ -15,35 +16,38 @@ import { markViewed } from '../services/MessageUpdater';
import { notificationService } from '../services/notifications'; import { notificationService } from '../services/notifications';
import { queueAttachmentDownloads } from '../util/queueAttachmentDownloads'; import { queueAttachmentDownloads } from '../util/queueAttachmentDownloads';
import { queueUpdateMessage } from '../util/messageBatcher'; import { queueUpdateMessage } from '../util/messageBatcher';
import { generateCacheKey } from './generateCacheKey';
import { AttachmentDownloadUrgency } from '../jobs/AttachmentDownloadManager'; import { AttachmentDownloadUrgency } from '../jobs/AttachmentDownloadManager';
import { isAciString } from '../util/isAciString';
import dataInterface from '../sql/Client';
const { removeSyncTaskById } = dataInterface;
export const viewSyncTaskSchema = z.object({
type: z.literal('ViewSync').readonly(),
senderAci: z.string().refine(isAciString),
senderE164: z.string().optional(),
senderId: z.string(),
timestamp: z.number(),
viewedAt: z.number(),
});
export type ViewSyncTaskType = z.infer<typeof viewSyncTaskSchema>;
export type ViewSyncAttributesType = { export type ViewSyncAttributesType = {
envelopeId: string; envelopeId: string;
removeFromMessageReceiverCache: () => unknown; syncTaskId: string;
senderAci: AciString; viewSync: ViewSyncTaskType;
senderE164?: string;
senderId: string;
timestamp: number;
viewedAt: number;
}; };
const viewSyncs = new Map<string, ViewSyncAttributesType>(); const viewSyncs = new Map<string, ViewSyncAttributesType>();
function remove(sync: ViewSyncAttributesType): void { async function remove(sync: ViewSyncAttributesType): Promise<void> {
viewSyncs.delete( await removeSyncTaskById(sync.syncTaskId);
generateCacheKey({
sender: sync.senderId,
timestamp: sync.timestamp,
type: 'viewsync',
})
);
sync.removeFromMessageReceiverCache();
} }
export function forMessage( export async function forMessage(
message: MessageModel message: MessageModel
): Array<ViewSyncAttributesType> { ): Promise<Array<ViewSyncAttributesType>> {
const logId = `ViewSyncs.forMessage(${getMessageIdForLogging( const logId = `ViewSyncs.forMessage(${getMessageIdForLogging(
message.attributes message.attributes
)})`; )})`;
@ -60,7 +64,11 @@ export function forMessage(
const viewSyncValues = Array.from(viewSyncs.values()); const viewSyncValues = Array.from(viewSyncs.values());
const matchingSyncs = viewSyncValues.filter(item => { const matchingSyncs = viewSyncValues.filter(item => {
return item.senderId === sender?.id && item.timestamp === messageTimestamp; const { viewSync } = item;
return (
viewSync.senderId === sender?.id &&
viewSync.timestamp === messageTimestamp
);
}); });
if (matchingSyncs.length > 0) { if (matchingSyncs.length > 0) {
@ -68,28 +76,24 @@ export function forMessage(
`${logId}: Found ${matchingSyncs.length} early view sync(s) for message ${messageTimestamp}` `${logId}: Found ${matchingSyncs.length} early view sync(s) for message ${messageTimestamp}`
); );
} }
matchingSyncs.forEach(sync => { await Promise.all(
remove(sync); matchingSyncs.map(async sync => {
}); await remove(sync);
})
);
return matchingSyncs; return matchingSyncs;
} }
export async function onSync(sync: ViewSyncAttributesType): Promise<void> { export async function onSync(sync: ViewSyncAttributesType): Promise<void> {
viewSyncs.set( viewSyncs.set(sync.syncTaskId, sync);
generateCacheKey({ const { viewSync } = sync;
sender: sync.senderId,
timestamp: sync.timestamp,
type: 'viewsync',
}),
sync
);
const logId = `ViewSyncs.onSync(timestamp=${sync.timestamp})`; const logId = `ViewSyncs.onSync(timestamp=${viewSync.timestamp})`;
try { try {
const messages = await window.Signal.Data.getMessagesBySentAt( const messages = await window.Signal.Data.getMessagesBySentAt(
sync.timestamp viewSync.timestamp
); );
const found = messages.find(item => { const found = messages.find(item => {
@ -99,15 +103,15 @@ export async function onSync(sync: ViewSyncAttributesType): Promise<void> {
reason: logId, reason: logId,
}); });
return sender?.id === sync.senderId; return sender?.id === viewSync.senderId;
}); });
if (!found) { if (!found) {
log.info( log.info(
`${logId}: nothing found`, `${logId}: nothing found`,
sync.senderId, viewSync.senderId,
sync.senderE164, viewSync.senderE164,
sync.senderAci viewSync.senderAci
); );
return; return;
} }
@ -123,7 +127,7 @@ export async function onSync(sync: ViewSyncAttributesType): Promise<void> {
if (message.get('readStatus') !== ReadStatus.Viewed) { if (message.get('readStatus') !== ReadStatus.Viewed) {
didChangeMessage = true; didChangeMessage = true;
message.set(markViewed(message.attributes, sync.viewedAt)); message.set(markViewed(message.attributes, viewSync.viewedAt));
const attachments = message.get('attachments'); const attachments = message.get('attachments');
if (!attachments?.every(isDownloaded)) { if (!attachments?.every(isDownloaded)) {
@ -154,9 +158,9 @@ export async function onSync(sync: ViewSyncAttributesType): Promise<void> {
queueUpdateMessage(message.attributes); queueUpdateMessage(message.attributes);
} }
remove(sync); await remove(sync);
} catch (error) { } catch (error) {
remove(sync);
log.error(`${logId} error:`, Errors.toLogFormat(error)); log.error(`${logId} error:`, Errors.toLogFormat(error));
await remove(sync);
} }
} }

View file

@ -2945,6 +2945,13 @@ export class ConversationModel extends window.Backbone
senderAci, senderAci,
}); });
if (!this.get('active_at')) {
log.warn(
`addDeliveryIssue: ${this.idForLogging()} has no active_at, dropping delivery issue instead of adding`
);
return;
}
const message = { const message = {
conversationId: this.id, conversationId: this.id,
type: 'delivery-issue', type: 'delivery-issue',
@ -3363,7 +3370,9 @@ export class ConversationModel extends window.Backbone
const message = window.MessageCache.__DEPRECATED$getById(notificationId); const message = window.MessageCache.__DEPRECATED$getById(notificationId);
if (message) { if (message) {
await window.Signal.Data.removeMessage(message.id); await window.Signal.Data.removeMessage(message.id, {
singleProtoJobQueue,
});
} }
return true; return true;
} }
@ -3404,7 +3413,9 @@ export class ConversationModel extends window.Backbone
const message = window.MessageCache.__DEPRECATED$getById(notificationId); const message = window.MessageCache.__DEPRECATED$getById(notificationId);
if (message) { if (message) {
await window.Signal.Data.removeMessage(message.id); await window.Signal.Data.removeMessage(message.id, {
singleProtoJobQueue,
});
} }
return true; return true;
@ -5003,7 +5014,14 @@ export class ConversationModel extends window.Backbone
}); });
window.Signal.Data.updateConversation(this.attributes); window.Signal.Data.updateConversation(this.attributes);
if (source === 'local-delete' && isEnabled('desktop.deleteSync.send')) { const ourConversation =
window.ConversationController.getOurConversationOrThrow();
const capable = Boolean(ourConversation.get('capabilities')?.deleteSync);
if (
source === 'local-delete' &&
capable &&
isEnabled('desktop.deleteSync.send')
) {
log.info(`${logId}: Preparing sync message`); log.info(`${logId}: Preparing sync message`);
const timestamp = Date.now(); const timestamp = Date.now();
@ -5044,7 +5062,9 @@ export class ConversationModel extends window.Backbone
log.info(`${logId}: Starting delete`); log.info(`${logId}: Starting delete`);
await window.Signal.Data.removeMessagesInConversation(this.id, { await window.Signal.Data.removeMessagesInConversation(this.id, {
fromSync: source !== 'local-delete-sync',
logId: this.idForLogging(), logId: this.idForLogging(),
singleProtoJobQueue,
}); });
log.info(`${logId}: Delete complete`); log.info(`${logId}: Delete complete`);
} }

View file

@ -109,7 +109,7 @@ import {
} from '../services/notifications'; } from '../services/notifications';
import type { LinkPreviewType } from '../types/message/LinkPreviews'; import type { LinkPreviewType } from '../types/message/LinkPreviews';
import * as log from '../logging/log'; import * as log from '../logging/log';
import { cleanupMessage, deleteMessageData } from '../util/cleanup'; import { deleteMessageData } from '../util/cleanup';
import { import {
getSource, getSource,
getSourceServiceId, getSourceServiceId,
@ -315,10 +315,6 @@ export class MessageModel extends window.Backbone.Model<MessageAttributesType> {
this.set(attributes); this.set(attributes);
} }
async cleanup(): Promise<void> {
await cleanupMessage(this.attributes);
}
async deleteData(): Promise<void> { async deleteData(): Promise<void> {
await deleteMessageData(this.attributes); await deleteMessageData(this.attributes);
} }

View file

@ -4,18 +4,21 @@
import { batch } from 'react-redux'; import { batch } from 'react-redux';
import { debounce } from 'lodash'; import { debounce } from 'lodash';
import type { MessageModel } from '../models/messages';
import { clearTimeoutIfNecessary } from '../util/clearTimeoutIfNecessary'; import { clearTimeoutIfNecessary } from '../util/clearTimeoutIfNecessary';
import { sleep } from '../util/sleep'; import { sleep } from '../util/sleep';
import { SECOND } from '../util/durations'; import { SECOND } from '../util/durations';
import * as Errors from '../types/errors'; import * as Errors from '../types/errors';
import * as log from '../logging/log';
import type { MessageModel } from '../models/messages';
import type { SingleProtoJobQueue } from '../jobs/singleProtoJobQueue';
class ExpiringMessagesDeletionService { class ExpiringMessagesDeletionService {
public update: typeof this.checkExpiringMessages; public update: typeof this.checkExpiringMessages;
private timeout?: ReturnType<typeof setTimeout>; private timeout?: ReturnType<typeof setTimeout>;
constructor() { constructor(private readonly singleProtoJobQueue: SingleProtoJobQueue) {
this.update = debounce(this.checkExpiringMessages, 1000); this.update = debounce(this.checkExpiringMessages, 1000);
} }
@ -42,7 +45,9 @@ class ExpiringMessagesDeletionService {
inMemoryMessages.push(message); inMemoryMessages.push(message);
}); });
await window.Signal.Data.removeMessages(messageIds); await window.Signal.Data.removeMessages(messageIds, {
singleProtoJobQueue: this.singleProtoJobQueue,
});
batch(() => { batch(() => {
inMemoryMessages.forEach(message => { inMemoryMessages.forEach(message => {
@ -108,5 +113,21 @@ class ExpiringMessagesDeletionService {
} }
} }
export const expiringMessagesDeletionService = // Because this service is used inside of Client.ts, it can't directly reference
new ExpiringMessagesDeletionService(); // SingleProtoJobQueue. Instead of direct access, it is provided once on startup.
export function initialize(singleProtoJobQueue: SingleProtoJobQueue): void {
if (instance) {
log.warn('Expiring Messages Deletion service is already initialized!');
return;
}
instance = new ExpiringMessagesDeletionService(singleProtoJobQueue);
}
export async function update(): Promise<void> {
if (!instance) {
throw new Error('Expiring Messages Deletion service not yet initialized!');
}
await instance.update();
}
let instance: ExpiringMessagesDeletionService;

View file

@ -2,13 +2,11 @@
// SPDX-License-Identifier: AGPL-3.0-only // SPDX-License-Identifier: AGPL-3.0-only
import { ipcRenderer as ipc } from 'electron'; import { ipcRenderer as ipc } from 'electron';
import PQueue from 'p-queue';
import { batch } from 'react-redux';
import { has, get, groupBy, isTypedArray, last, map, omit } from 'lodash'; import { has, get, groupBy, isTypedArray, last, map, omit } from 'lodash';
import { deleteExternalFiles } from '../types/Conversation'; import { deleteExternalFiles } from '../types/Conversation';
import { expiringMessagesDeletionService } from '../services/expiringMessagesDeletion'; import { update as updateExpiringMessagesService } from '../services/expiringMessagesDeletion';
import { tapToViewMessagesDeletionService } from '../services/tapToViewMessagesDeletionService'; import { tapToViewMessagesDeletionService } from '../services/tapToViewMessagesDeletionService';
import * as Bytes from '../Bytes'; import * as Bytes from '../Bytes';
import { createBatcher } from '../util/batcher'; import { createBatcher } from '../util/batcher';
@ -24,12 +22,7 @@ import * as Errors from '../types/errors';
import type { StoredJob } from '../jobs/types'; import type { StoredJob } from '../jobs/types';
import { formatJobForInsert } from '../jobs/formatJobForInsert'; import { formatJobForInsert } from '../jobs/formatJobForInsert';
import { import { cleanupMessages } from '../util/cleanup';
cleanupMessage,
cleanupMessageFromMemory,
deleteMessageData,
} from '../util/cleanup';
import { drop } from '../util/drop';
import { ipcInvoke, doShutdown } from './channels'; import { ipcInvoke, doShutdown } from './channels';
import type { import type {
@ -60,12 +53,12 @@ import type {
KyberPreKeyType, KyberPreKeyType,
StoredKyberPreKeyType, StoredKyberPreKeyType,
} from './Interface'; } from './Interface';
import { MINUTE } from '../util/durations';
import { getMessageIdForLogging } from '../util/idForLogging'; import { getMessageIdForLogging } from '../util/idForLogging';
import type { MessageAttributesType } from '../model-types'; import type { MessageAttributesType } from '../model-types';
import { incrementMessageCounter } from '../util/incrementMessageCounter'; import { incrementMessageCounter } from '../util/incrementMessageCounter';
import { generateSnippetAroundMention } from '../util/search'; import { generateSnippetAroundMention } from '../util/search';
import type { AttachmentDownloadJobType } from '../types/AttachmentDownload'; import type { AttachmentDownloadJobType } from '../types/AttachmentDownload';
import type { SingleProtoJobQueue } from '../jobs/singleProtoJobQueue';
const ERASE_SQL_KEY = 'erase-sql-key'; const ERASE_SQL_KEY = 'erase-sql-key';
const ERASE_ATTACHMENTS_KEY = 'erase-attachments'; const ERASE_ATTACHMENTS_KEY = 'erase-attachments';
@ -104,6 +97,8 @@ const exclusiveInterface: ClientExclusiveInterface = {
removeConversation, removeConversation,
searchMessages, searchMessages,
removeMessage,
removeMessages,
getRecentStoryReplies, getRecentStoryReplies,
getOlderMessagesByConversation, getOlderMessagesByConversation,
@ -125,8 +120,6 @@ const exclusiveInterface: ClientExclusiveInterface = {
type ClientOverridesType = ClientExclusiveInterface & type ClientOverridesType = ClientExclusiveInterface &
Pick< Pick<
ServerInterface, ServerInterface,
| 'removeMessage'
| 'removeMessages'
| 'saveAttachmentDownloadJob' | 'saveAttachmentDownloadJob'
| 'saveMessage' | 'saveMessage'
| 'saveMessages' | 'saveMessages'
@ -142,8 +135,6 @@ const channels: ServerInterface = new Proxy({} as ServerInterface, {
const clientExclusiveOverrides: ClientOverridesType = { const clientExclusiveOverrides: ClientOverridesType = {
...exclusiveInterface, ...exclusiveInterface,
removeMessage,
removeMessages,
saveAttachmentDownloadJob, saveAttachmentDownloadJob,
saveMessage, saveMessage,
saveMessages, saveMessages,
@ -562,7 +553,7 @@ async function saveMessage(
softAssert(isValidUuid(id), 'saveMessage: messageId is not a UUID'); softAssert(isValidUuid(id), 'saveMessage: messageId is not a UUID');
void expiringMessagesDeletionService.update(); void updateExpiringMessagesService();
void tapToViewMessagesDeletionService.update(); void tapToViewMessagesDeletionService.update();
return id; return id;
@ -577,26 +568,39 @@ async function saveMessages(
options options
); );
void expiringMessagesDeletionService.update(); void updateExpiringMessagesService();
void tapToViewMessagesDeletionService.update(); void tapToViewMessagesDeletionService.update();
return result; return result;
} }
async function removeMessage(id: string): Promise<void> { async function removeMessage(
id: string,
options: {
singleProtoJobQueue: SingleProtoJobQueue;
fromSync?: boolean;
}
): Promise<void> {
const message = await channels.getMessageById(id); const message = await channels.getMessageById(id);
// Note: It's important to have a fully database-hydrated model to delete here because // Note: It's important to have a fully database-hydrated model to delete here because
// it needs to delete all associated on-disk files along with the database delete. // it needs to delete all associated on-disk files along with the database delete.
if (message) { if (message) {
await channels.removeMessage(id); await channels.removeMessage(id);
await cleanupMessage(message); await cleanupMessages([message], {
...options,
markCallHistoryDeleted: dataInterface.markCallHistoryDeleted,
});
} }
} }
export async function deleteAndCleanup( export async function deleteAndCleanup(
messages: Array<MessageAttributesType>, messages: Array<MessageAttributesType>,
logId: string logId: string,
options: {
fromSync?: boolean;
singleProtoJobQueue: SingleProtoJobQueue;
}
): Promise<void> { ): Promise<void> {
const ids = messages.map(message => message.id); const ids = messages.map(message => message.id);
@ -604,37 +608,26 @@ export async function deleteAndCleanup(
await channels.removeMessages(ids); await channels.removeMessages(ids);
log.info(`deleteAndCleanup/${logId}: Cleanup for ${ids.length} messages...`); log.info(`deleteAndCleanup/${logId}: Cleanup for ${ids.length} messages...`);
await _cleanupMessages(messages); await cleanupMessages(messages, {
...options,
markCallHistoryDeleted: dataInterface.markCallHistoryDeleted,
});
log.info(`deleteAndCleanup/${logId}: Complete`); log.info(`deleteAndCleanup/${logId}: Complete`);
} }
async function _cleanupMessages(
messages: ReadonlyArray<MessageAttributesType>
): Promise<void> {
// First, remove messages from memory, so we can batch the updates in redux
batch(() => {
messages.forEach(message => cleanupMessageFromMemory(message));
});
// Then, handle any asynchronous actions (e.g. deleting data from disk)
const queue = new PQueue({ concurrency: 3, timeout: MINUTE * 30 });
drop(
queue.addAll(
messages.map(
(message: MessageAttributesType) => async () =>
deleteMessageData(message)
)
)
);
await queue.onIdle();
}
async function removeMessages( async function removeMessages(
messageIds: ReadonlyArray<string> messageIds: ReadonlyArray<string>,
options: {
fromSync?: boolean;
singleProtoJobQueue: SingleProtoJobQueue;
}
): Promise<void> { ): Promise<void> {
const messages = await channels.getMessagesById(messageIds); const messages = await channels.getMessagesById(messageIds);
await _cleanupMessages(messages); await cleanupMessages(messages, {
...options,
markCallHistoryDeleted: dataInterface.markCallHistoryDeleted,
});
await channels.removeMessages(messageIds); await channels.removeMessages(messageIds);
} }
@ -686,9 +679,13 @@ async function removeMessagesInConversation(
{ {
logId, logId,
receivedAt, receivedAt,
singleProtoJobQueue,
fromSync,
}: { }: {
fromSync?: boolean;
logId: string; logId: string;
receivedAt?: number; receivedAt?: number;
singleProtoJobQueue: SingleProtoJobQueue;
} }
): Promise<void> { ): Promise<void> {
let messages; let messages;
@ -713,7 +710,7 @@ async function removeMessagesInConversation(
} }
// eslint-disable-next-line no-await-in-loop // eslint-disable-next-line no-await-in-loop
await deleteAndCleanup(messages, logId); await deleteAndCleanup(messages, logId, { fromSync, singleProtoJobQueue });
} while (messages.length > 0); } while (messages.length > 0);
} }

View file

@ -34,6 +34,7 @@ import type { AttachmentDownloadJobType } from '../types/AttachmentDownload';
import type { GroupSendEndorsementsData } from '../types/GroupSendEndorsements'; import type { GroupSendEndorsementsData } from '../types/GroupSendEndorsements';
import type { SyncTaskType } from '../util/syncTasks'; import type { SyncTaskType } from '../util/syncTasks';
import type { AttachmentBackupJobType } from '../types/AttachmentBackup'; import type { AttachmentBackupJobType } from '../types/AttachmentBackup';
import type { SingleProtoJobQueue } from '../jobs/singleProtoJobQueue';
export type AdjacentMessagesByConversationOptionsType = Readonly<{ export type AdjacentMessagesByConversationOptionsType = Readonly<{
conversationId: string; conversationId: string;
@ -557,8 +558,6 @@ export type DataInterface = {
arrayOfMessages: ReadonlyArray<MessageType>, arrayOfMessages: ReadonlyArray<MessageType>,
options: { forceSave?: boolean; ourAci: AciString } options: { forceSave?: boolean; ourAci: AciString }
) => Promise<Array<string>>; ) => Promise<Array<string>>;
removeMessage: (id: string) => Promise<void>;
removeMessages: (ids: ReadonlyArray<string>) => Promise<void>;
pageMessages: ( pageMessages: (
cursor?: PageMessagesCursorType cursor?: PageMessagesCursorType
) => Promise<PageMessagesResultType>; ) => Promise<PageMessagesResultType>;
@ -667,6 +666,7 @@ export type DataInterface = {
conversationId: string; conversationId: string;
}): Promise<MessageType | undefined>; }): Promise<MessageType | undefined>;
getAllCallHistory: () => Promise<ReadonlyArray<CallHistoryDetails>>; getAllCallHistory: () => Promise<ReadonlyArray<CallHistoryDetails>>;
markCallHistoryDeleted: (callId: string) => Promise<void>;
clearCallHistory: (beforeTimestamp: number) => Promise<Array<string>>; clearCallHistory: (beforeTimestamp: number) => Promise<Array<string>>;
cleanupCallHistoryMessages: () => Promise<void>; cleanupCallHistoryMessages: () => Promise<void>;
getCallHistoryUnreadCount(): Promise<number>; getCallHistoryUnreadCount(): Promise<number>;
@ -929,6 +929,8 @@ export type ServerInterface = DataInterface & {
options?: { limit?: number }; options?: { limit?: number };
contactServiceIdsMatchingQuery?: Array<ServiceIdString>; contactServiceIdsMatchingQuery?: Array<ServiceIdString>;
}) => Promise<Array<ServerSearchResultMessageType>>; }) => Promise<Array<ServerSearchResultMessageType>>;
removeMessage: (id: string) => Promise<void>;
removeMessages: (ids: ReadonlyArray<string>) => Promise<void>;
getRecentStoryReplies( getRecentStoryReplies(
storyId: string, storyId: string,
@ -1022,6 +1024,20 @@ export type ClientExclusiveInterface = {
removeConversation: (id: string) => Promise<void>; removeConversation: (id: string) => Promise<void>;
flushUpdateConversationBatcher: () => Promise<void>; flushUpdateConversationBatcher: () => Promise<void>;
removeMessage: (
id: string,
options: {
fromSync?: boolean;
singleProtoJobQueue: SingleProtoJobQueue;
}
) => Promise<void>;
removeMessages: (
ids: ReadonlyArray<string>,
options: {
fromSync?: boolean;
singleProtoJobQueue: SingleProtoJobQueue;
}
) => Promise<void>;
searchMessages: ({ searchMessages: ({
query, query,
conversationId, conversationId,
@ -1084,8 +1100,10 @@ export type ClientExclusiveInterface = {
removeMessagesInConversation: ( removeMessagesInConversation: (
conversationId: string, conversationId: string,
options: { options: {
fromSync?: boolean;
logId: string; logId: string;
receivedAt?: number; receivedAt?: number;
singleProtoJobQueue: SingleProtoJobQueue;
} }
) => Promise<void>; ) => Promise<void>;
removeOtherData: () => Promise<void>; removeOtherData: () => Promise<void>;

View file

@ -347,6 +347,7 @@ const dataInterface: ServerInterface = {
getLastConversationMessage, getLastConversationMessage,
getAllCallHistory, getAllCallHistory,
clearCallHistory, clearCallHistory,
markCallHistoryDeleted,
cleanupCallHistoryMessages, cleanupCallHistoryMessages,
getCallHistoryUnreadCount, getCallHistoryUnreadCount,
markCallHistoryRead, markCallHistoryRead,
@ -3635,6 +3636,19 @@ async function clearCallHistory(
})(); })();
} }
async function markCallHistoryDeleted(callId: string): Promise<void> {
const db = await getWritableInstance();
const [query, params] = sql`
UPDATE callsHistory
SET
status = ${DirectCallStatus.Deleted},
timestamp = ${Date.now()}
WHERE callId = ${callId};
`;
db.prepare(query).run(params);
}
async function cleanupCallHistoryMessages(): Promise<void> { async function cleanupCallHistoryMessages(): Promise<void> {
const db = await getWritableInstance(); const db = await getWritableInstance();
return db return db

View file

@ -195,6 +195,7 @@ import {
} from '../../util/deleteForMe'; } from '../../util/deleteForMe';
import { MAX_MESSAGE_COUNT } from '../../util/deleteForMe.types'; import { MAX_MESSAGE_COUNT } from '../../util/deleteForMe.types';
import { isEnabled } from '../../RemoteConfig'; import { isEnabled } from '../../RemoteConfig';
import type { CapabilitiesType } from '../../textsecure/WebAPI';
// State // State
@ -265,6 +266,7 @@ export type ConversationType = ReadonlyDeep<
firstName?: string; firstName?: string;
profileName?: string; profileName?: string;
profileLastUpdatedAt?: number; profileLastUpdatedAt?: number;
capabilities?: CapabilitiesType;
username?: string; username?: string;
about?: string; about?: string;
aboutText?: string; aboutText?: string;
@ -1753,7 +1755,9 @@ function deleteMessages({
} }
} }
await window.Signal.Data.removeMessages(messageIds); await window.Signal.Data.removeMessages(messageIds, {
singleProtoJobQueue,
});
popPanelForConversation()(dispatch, getState, undefined); popPanelForConversation()(dispatch, getState, undefined);
@ -1761,7 +1765,11 @@ function deleteMessages({
dispatch(scrollToMessage(conversationId, nearbyMessageId)); dispatch(scrollToMessage(conversationId, nearbyMessageId));
} }
if (!isEnabled('desktop.deleteSync.send')) { const ourConversation =
window.ConversationController.getOurConversationOrThrow();
const capable = Boolean(ourConversation.get('capabilities')?.deleteSync);
if (!capable || !isEnabled('desktop.deleteSync.send')) {
return; return;
} }
if (messages.length === 0) { if (messages.length === 0) {

View file

@ -69,6 +69,7 @@ import {
conversationQueueJobEnum, conversationQueueJobEnum,
} from '../../jobs/conversationJobQueue'; } from '../../jobs/conversationJobQueue';
import { ReceiptType } from '../../types/Receipt'; import { ReceiptType } from '../../types/Receipt';
import { singleProtoJobQueue } from '../../jobs/singleProtoJobQueue';
export type StoryDataType = ReadonlyDeep< export type StoryDataType = ReadonlyDeep<
{ {
@ -284,7 +285,7 @@ function deleteGroupStoryReply(
messageId: string messageId: string
): ThunkAction<void, RootStateType, unknown, StoryReplyDeletedActionType> { ): ThunkAction<void, RootStateType, unknown, StoryReplyDeletedActionType> {
return async dispatch => { return async dispatch => {
await window.Signal.Data.removeMessage(messageId); await window.Signal.Data.removeMessage(messageId, { singleProtoJobQueue });
dispatch({ dispatch({
type: STORY_REPLY_DELETED, type: STORY_REPLY_DELETED,
payload: messageId, payload: messageId,
@ -1408,10 +1409,7 @@ function removeAllContactStories(
log.info(`${logId}: removing ${messages.length} stories`); log.info(`${logId}: removing ${messages.length} stories`);
await Promise.all([ await dataInterface.removeMessages(messageIds, { singleProtoJobQueue });
messages.map(m => m.cleanup()),
await dataInterface.removeMessages(messageIds),
]);
dispatch({ dispatch({
type: 'NOOP', type: 'NOOP',

View file

@ -0,0 +1,38 @@
// Copyright 2019 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import { createSelector } from 'reselect';
import { getUserACI } from './user';
import { getConversationSelector } from './conversations';
import { getRemoteConfig, isRemoteConfigFlagEnabled } from './items';
import type { AciString } from '../../types/ServiceId';
import type { ConfigMapType } from '../../RemoteConfig';
import type { GetConversationByIdType } from './conversations';
export const getDeleteSyncSendEnabled = createSelector(
getUserACI,
getConversationSelector,
getRemoteConfig,
(
aci: AciString | undefined,
conversationSelector: GetConversationByIdType,
remoteConfig: ConfigMapType
): boolean => {
if (!aci) {
return false;
}
const ourConversation = conversationSelector(aci);
if (!ourConversation) {
return false;
}
const { capabilities } = ourConversation;
if (!capabilities || !capabilities.deleteSync) {
return false;
}
return isRemoteConfigFlagEnabled(remoteConfig, 'desktop.deleteSync.send');
}
);

View file

@ -127,13 +127,6 @@ export const isInternalUser = createSelector(
} }
); );
export const getDeleteSyncSendEnabled = createSelector(
getRemoteConfig,
(remoteConfig: ConfigMapType): boolean => {
return isRemoteConfigFlagEnabled(remoteConfig, 'desktop.deleteSync.send');
}
);
// Note: ts/util/stories is the other place this check is done // Note: ts/util/stories is the other place this check is done
export const getStoriesEnabled = createSelector( export const getStoriesEnabled = createSelector(
getItems, getItems,

View file

@ -41,10 +41,8 @@ import {
import { getHasStoriesSelector } from '../selectors/stories2'; import { getHasStoriesSelector } from '../selectors/stories2';
import { getIntl, getTheme, getUserACI } from '../selectors/user'; import { getIntl, getTheme, getUserACI } from '../selectors/user';
import { useItemsActions } from '../ducks/items'; import { useItemsActions } from '../ducks/items';
import { import { getLocalDeleteWarningShown } from '../selectors/items';
getDeleteSyncSendEnabled, import { getDeleteSyncSendEnabled } from '../selectors/items-extra';
getLocalDeleteWarningShown,
} from '../selectors/items';
export type OwnProps = { export type OwnProps = {
id: string; id: string;

View file

@ -17,10 +17,8 @@ import {
} from '../selectors/conversations'; } from '../selectors/conversations';
import { getDeleteMessagesProps } from '../selectors/globalModals'; import { getDeleteMessagesProps } from '../selectors/globalModals';
import { useItemsActions } from '../ducks/items'; import { useItemsActions } from '../ducks/items';
import { import { getLocalDeleteWarningShown } from '../selectors/items';
getLocalDeleteWarningShown, import { getDeleteSyncSendEnabled } from '../selectors/items-extra';
getDeleteSyncSendEnabled,
} from '../selectors/items';
import { LocalDeleteWarningModal } from '../../components/LocalDeleteWarningModal'; import { LocalDeleteWarningModal } from '../../components/LocalDeleteWarningModal';
export const SmartDeleteMessagesModal = memo( export const SmartDeleteMessagesModal = memo(

View file

@ -59,9 +59,9 @@ describe('filterAndSortConversations', () => {
check({ check({
searchTerm: '9876', searchTerm: '9876',
input: [ input: [
{ title: 'no' }, { title: 'no', e164: undefined },
{ title: 'yes', e164: '+16505559876' }, { title: 'yes', e164: '+16505559876' },
{ title: 'no' }, { title: 'no', e164: undefined },
], ],
expected: [{ title: 'yes' }], expected: [{ title: 'yes' }],
}); });

View file

@ -7,10 +7,13 @@ import { assert } from 'chai';
import { type AciString, generateAci } from '../types/ServiceId'; import { type AciString, generateAci } from '../types/ServiceId';
import type { MessageAttributesType } from '../model-types'; import type { MessageAttributesType } from '../model-types';
import { SendStatus } from '../messages/MessageSendState'; import { SendStatus } from '../messages/MessageSendState';
import { import type {
type MessageReceiptAttributesType, MessageReceiptAttributesType,
MessageReceiptType, MessageReceiptType,
} from '../messageModifiers/MessageReceipts';
import {
onReceipt, onReceipt,
messageReceiptTypeSchema,
} from '../messageModifiers/MessageReceipts'; } from '../messageModifiers/MessageReceipts';
import { ReadStatus } from '../messages/MessageReadStatus'; import { ReadStatus } from '../messages/MessageReadStatus';
@ -31,14 +34,16 @@ describe('MessageReceipts', () => {
): MessageReceiptAttributesType { ): MessageReceiptAttributesType {
return { return {
envelopeId: uuid(), envelopeId: uuid(),
syncTaskId: uuid(),
receiptSync: {
messageSentAt, messageSentAt,
receiptTimestamp: 1, receiptTimestamp: 1,
removeFromMessageReceiverCache: () => null,
sourceConversationId, sourceConversationId,
sourceDevice: 1, sourceDevice: 1,
sourceServiceId: generateAci(), sourceServiceId: generateAci(),
type, type,
wasSentEncrypted: true, wasSentEncrypted: true,
},
}; };
} }
it('processes all receipts in a batch', async () => { it('processes all receipts in a batch', async () => {
@ -78,10 +83,18 @@ describe('MessageReceipts', () => {
}); });
await Promise.all([ await Promise.all([
onReceipt(generateReceipt('aaaa', sentAt, MessageReceiptType.Delivery)), onReceipt(
onReceipt(generateReceipt('bbbb', sentAt, MessageReceiptType.Delivery)), generateReceipt('aaaa', sentAt, messageReceiptTypeSchema.enum.Delivery)
onReceipt(generateReceipt('cccc', sentAt, MessageReceiptType.Read)), ),
onReceipt(generateReceipt('aaaa', sentAt, MessageReceiptType.Read)), onReceipt(
generateReceipt('bbbb', sentAt, messageReceiptTypeSchema.enum.Delivery)
),
onReceipt(
generateReceipt('cccc', sentAt, messageReceiptTypeSchema.enum.Read)
),
onReceipt(
generateReceipt('aaaa', sentAt, messageReceiptTypeSchema.enum.Read)
),
]); ]);
const messageFromDatabase = await window.Signal.Data.getMessageById(id); const messageFromDatabase = await window.Signal.Data.getMessageById(id);
@ -154,20 +167,48 @@ describe('MessageReceipts', () => {
await Promise.all([ await Promise.all([
// send receipts for original message // send receipts for original message
onReceipt(generateReceipt('aaaa', sentAt, MessageReceiptType.Delivery)), onReceipt(
onReceipt(generateReceipt('bbbb', sentAt, MessageReceiptType.Delivery)), generateReceipt('aaaa', sentAt, messageReceiptTypeSchema.enum.Delivery)
onReceipt(generateReceipt('cccc', sentAt, MessageReceiptType.Read)), ),
onReceipt(generateReceipt('aaaa', sentAt, MessageReceiptType.Read)), onReceipt(
generateReceipt('bbbb', sentAt, messageReceiptTypeSchema.enum.Delivery)
),
onReceipt(
generateReceipt('cccc', sentAt, messageReceiptTypeSchema.enum.Read)
),
onReceipt(
generateReceipt('aaaa', sentAt, messageReceiptTypeSchema.enum.Read)
),
// and send receipts for edited message // and send receipts for edited message
onReceipt( onReceipt(
generateReceipt('aaaa', editedSentAt, MessageReceiptType.Delivery) generateReceipt(
'aaaa',
editedSentAt,
messageReceiptTypeSchema.enum.Delivery
)
), ),
onReceipt( onReceipt(
generateReceipt('bbbb', editedSentAt, MessageReceiptType.Delivery) generateReceipt(
'bbbb',
editedSentAt,
messageReceiptTypeSchema.enum.Delivery
)
),
onReceipt(
generateReceipt(
'cccc',
editedSentAt,
messageReceiptTypeSchema.enum.Read
)
),
onReceipt(
generateReceipt(
'bbbb',
editedSentAt,
messageReceiptTypeSchema.enum.Read
)
), ),
onReceipt(generateReceipt('cccc', editedSentAt, MessageReceiptType.Read)),
onReceipt(generateReceipt('bbbb', editedSentAt, MessageReceiptType.Read)),
]); ]);
const messageFromDatabase = await window.Signal.Data.getMessageById(id); const messageFromDatabase = await window.Signal.Data.getMessageById(id);

View file

@ -7,6 +7,7 @@ import { v4 as generateUuid } from 'uuid';
import dataInterface from '../../sql/Client'; import dataInterface from '../../sql/Client';
import { generateAci } from '../../types/ServiceId'; import { generateAci } from '../../types/ServiceId';
import { constantTimeEqual, getRandomBytes } from '../../Crypto'; import { constantTimeEqual, getRandomBytes } from '../../Crypto';
import { singleProtoJobQueue } from '../../jobs/singleProtoJobQueue';
const { const {
_getAllSentProtoMessageIds, _getAllSentProtoMessageIds,
@ -148,7 +149,7 @@ describe('sql/sendLog', () => {
assert.strictEqual(actual.timestamp, proto.timestamp); assert.strictEqual(actual.timestamp, proto.timestamp);
await removeMessage(id); await removeMessage(id, { singleProtoJobQueue });
assert.lengthOf(await getAllSentProtos(), 0); assert.lengthOf(await getAllSentProtos(), 0);
}); });

View file

@ -11,6 +11,7 @@ import { SignalProtocolStore } from '../../SignalProtocolStore';
import type { ConversationModel } from '../../models/conversations'; import type { ConversationModel } from '../../models/conversations';
import * as KeyChangeListener from '../../textsecure/KeyChangeListener'; import * as KeyChangeListener from '../../textsecure/KeyChangeListener';
import * as Bytes from '../../Bytes'; import * as Bytes from '../../Bytes';
import { singleProtoJobQueue } from '../../jobs/singleProtoJobQueue';
describe('KeyChangeListener', () => { describe('KeyChangeListener', () => {
let oldNumberId: string | undefined; let oldNumberId: string | undefined;
@ -69,6 +70,7 @@ describe('KeyChangeListener', () => {
afterEach(async () => { afterEach(async () => {
await window.Signal.Data.removeMessagesInConversation(convo.id, { await window.Signal.Data.removeMessagesInConversation(convo.id, {
logId: ourServiceIdWithKeyChange, logId: ourServiceIdWithKeyChange,
singleProtoJobQueue,
}); });
await window.Signal.Data.removeConversation(convo.id); await window.Signal.Data.removeConversation(convo.id);
@ -106,6 +108,7 @@ describe('KeyChangeListener', () => {
afterEach(async () => { afterEach(async () => {
await window.Signal.Data.removeMessagesInConversation(groupConvo.id, { await window.Signal.Data.removeMessagesInConversation(groupConvo.id, {
logId: ourServiceIdWithKeyChange, logId: ourServiceIdWithKeyChange,
singleProtoJobQueue,
}); });
await window.Signal.Data.removeConversation(groupConvo.id); await window.Signal.Data.removeConversation(groupConvo.id);
}); });

View file

@ -137,6 +137,8 @@ import type {
DeleteForMeSyncEventData, DeleteForMeSyncEventData,
DeleteForMeSyncTarget, DeleteForMeSyncTarget,
ConversationToDelete, ConversationToDelete,
ViewSyncEventData,
ReadSyncEventData,
} from './messageReceiverEvents'; } from './messageReceiverEvents';
import * as log from '../logging/log'; import * as log from '../logging/log';
import * as durations from '../util/durations'; import * as durations from '../util/durations';
@ -1728,15 +1730,17 @@ export default class MessageReceiver
await this.dispatchAndWait( await this.dispatchAndWait(
getEnvelopeId(envelope), getEnvelopeId(envelope),
new DeliveryEvent( new DeliveryEvent(
[
{ {
envelopeId: envelope.id,
timestamp: envelope.timestamp, timestamp: envelope.timestamp,
envelopeTimestamp: envelope.timestamp,
source: envelope.source, source: envelope.source,
sourceServiceId: envelope.sourceServiceId, sourceServiceId: envelope.sourceServiceId,
sourceDevice: envelope.sourceDevice, sourceDevice: envelope.sourceDevice,
wasSentEncrypted: false, wasSentEncrypted: false,
}, },
],
envelope.id,
envelope.timestamp,
this.removeFromCache.bind(this, envelope) this.removeFromCache.bind(this, envelope)
) )
); );
@ -2907,22 +2911,22 @@ export default class MessageReceiver
const logId = getEnvelopeId(envelope); const logId = getEnvelopeId(envelope);
await Promise.all( const receipts = receiptMessage.timestamp.map(rawTimestamp => ({
receiptMessage.timestamp.map(async rawTimestamp => {
const ev = new EventClass(
{
envelopeId: envelope.id,
timestamp: rawTimestamp?.toNumber(), timestamp: rawTimestamp?.toNumber(),
envelopeTimestamp: envelope.timestamp,
source: envelope.source, source: envelope.source,
sourceServiceId: envelope.sourceServiceId, sourceServiceId: envelope.sourceServiceId,
sourceDevice: envelope.sourceDevice, sourceDevice: envelope.sourceDevice,
wasSentEncrypted: true, wasSentEncrypted: true as const,
}, }));
await this.dispatchAndWait(
logId,
new EventClass(
receipts,
envelope.id,
envelope.timestamp,
this.removeFromCache.bind(this, envelope) this.removeFromCache.bind(this, envelope)
); )
await this.dispatchAndWait(logId, ev);
})
); );
} }
@ -3469,10 +3473,8 @@ export default class MessageReceiver
logUnexpectedUrgentValue(envelope, 'readSync'); logUnexpectedUrgentValue(envelope, 'readSync');
const results = []; const reads = read.map(
for (const { timestamp, sender, senderAci } of read) { ({ timestamp, sender, senderAci }): ReadSyncEventData => ({
const ev = new ReadSyncEvent(
{
envelopeId: envelope.id, envelopeId: envelope.id,
envelopeTimestamp: envelope.timestamp, envelopeTimestamp: envelope.timestamp,
timestamp: timestamp?.toNumber(), timestamp: timestamp?.toNumber(),
@ -3480,12 +3482,18 @@ export default class MessageReceiver
senderAci: senderAci senderAci: senderAci
? normalizeAci(senderAci, 'handleRead.senderAci') ? normalizeAci(senderAci, 'handleRead.senderAci')
: undefined, : undefined,
}, })
this.removeFromCache.bind(this, envelope) );
await this.dispatchAndWait(
logId,
new ReadSyncEvent(
reads,
envelope.id,
envelope.timestamp,
this.removeFromCache.bind(this, envelope)
)
); );
results.push(this.dispatchAndWait(logId, ev));
}
await Promise.all(results);
} }
private async handleViewed( private async handleViewed(
@ -3497,23 +3505,25 @@ export default class MessageReceiver
logUnexpectedUrgentValue(envelope, 'viewSync'); logUnexpectedUrgentValue(envelope, 'viewSync');
await Promise.all( const views = viewed.map(
viewed.map(async ({ timestamp, senderE164, senderAci }) => { ({ timestamp, senderE164, senderAci }): ViewSyncEventData => ({
const ev = new ViewSyncEvent(
{
envelopeId: envelope.id,
envelopeTimestamp: envelope.timestamp,
timestamp: timestamp?.toNumber(), timestamp: timestamp?.toNumber(),
senderE164: dropNull(senderE164), senderE164: dropNull(senderE164),
senderAci: senderAci senderAci: senderAci
? normalizeAci(senderAci, 'handleViewed.senderAci') ? normalizeAci(senderAci, 'handleViewed.senderAci')
: undefined, : undefined,
},
this.removeFromCache.bind(this, envelope)
);
await this.dispatchAndWait(logId, ev);
}) })
); );
await this.dispatchAndWait(
logId,
new ViewSyncEvent(
views,
envelope.id,
envelope.timestamp,
this.removeFromCache.bind(this, envelope)
)
);
} }
private async handleCallEvent( private async handleCallEvent(
@ -3663,7 +3673,19 @@ export default class MessageReceiver
? processConversationToDelete(item.conversation, logId) ? processConversationToDelete(item.conversation, logId)
: undefined; : undefined;
if (messages?.length && conversation) { if (!conversation) {
log.warn(
`${logId}/handleDeleteForMeSync/messageDeletes: No target conversation`
);
return undefined;
}
if (!messages?.length) {
log.warn(
`${logId}/handleDeleteForMeSync/messageDeletes: No target messages`
);
return undefined;
}
// We want each message in its own task // We want each message in its own task
return messages.map(innerItem => { return messages.map(innerItem => {
return { return {
@ -3673,9 +3695,6 @@ export default class MessageReceiver
timestamp, timestamp,
}; };
}); });
}
return undefined;
}) })
.filter(isNotNil); .filter(isNotNil);
@ -3692,7 +3711,19 @@ export default class MessageReceiver
? processConversationToDelete(item.conversation, logId) ? processConversationToDelete(item.conversation, logId)
: undefined; : undefined;
if (mostRecentMessages?.length && conversation) { if (!conversation) {
log.warn(
`${logId}/handleDeleteForMeSync/conversationDeletes: No target conversation`
);
return undefined;
}
if (!mostRecentMessages?.length) {
log.warn(
`${logId}/handleDeleteForMeSync/conversationDeletes: No target messages`
);
return undefined;
}
return { return {
type: 'delete-conversation' as const, type: 'delete-conversation' as const,
conversation, conversation,
@ -3700,9 +3731,6 @@ export default class MessageReceiver
mostRecentMessages, mostRecentMessages,
timestamp, timestamp,
}; };
}
return undefined;
}) })
.filter(isNotNil); .filter(isNotNil);
@ -3716,15 +3744,18 @@ export default class MessageReceiver
? processConversationToDelete(item.conversation, logId) ? processConversationToDelete(item.conversation, logId)
: undefined; : undefined;
if (conversation) { if (!conversation) {
log.warn(
`${logId}/handleDeleteForMeSync/localOnlyConversationDeletes: No target conversation`
);
return undefined;
}
return { return {
type: 'delete-local-conversation' as const, type: 'delete-local-conversation' as const,
conversation, conversation,
timestamp, timestamp,
}; };
}
return undefined;
}) })
.filter(isNotNil); .filter(isNotNil);
@ -3969,16 +4000,33 @@ function processMessageToDelete(
return undefined; return undefined;
} }
if (target.authorAci) { const { authorServiceId } = target;
if (authorServiceId) {
if (isAciString(authorServiceId)) {
return { return {
type: 'aci' as const, type: 'aci' as const,
authorAci: normalizeAci( authorAci: normalizeAci(
target.authorAci, authorServiceId,
`${logId}/processMessageToDelete` `${logId}/processMessageToDelete/aci`
), ),
sentAt, sentAt,
}; };
} }
if (isPniString(authorServiceId)) {
return {
type: 'pni' as const,
authorPni: normalizePni(
authorServiceId,
`${logId}/processMessageToDelete/pni`
),
sentAt,
};
}
log.error(
`${logId}/processMessageToDelete: invalid authorServiceId, Dropping AddressableMessage.`
);
return undefined;
}
if (target.authorE164) { if (target.authorE164) {
return { return {
type: 'e164' as const, type: 'e164' as const,
@ -3997,14 +4045,26 @@ function processConversationToDelete(
target: Proto.SyncMessage.DeleteForMe.IConversationIdentifier, target: Proto.SyncMessage.DeleteForMe.IConversationIdentifier,
logId: string logId: string
): ConversationToDelete | undefined { ): ConversationToDelete | undefined {
const { threadAci, threadGroupId, threadE164 } = target; const { threadServiceId, threadGroupId, threadE164 } = target;
if (threadAci) { if (threadServiceId) {
if (isAciString(threadServiceId)) {
return { return {
type: 'aci' as const, type: 'aci' as const,
aci: normalizeAci(threadAci, `${logId}/threadAci`), aci: normalizeAci(threadServiceId, `${logId}/aci`),
}; };
} }
if (isPniString(threadServiceId)) {
return {
type: 'pni' as const,
pni: normalizePni(threadServiceId, `${logId}/pni`),
};
}
log.error(
`${logId}/processConversationToDelete: Invalid threadServiceId, dropping ConversationIdentifier.`
);
return undefined;
}
if (threadGroupId) { if (threadGroupId) {
return { return {
type: 'group' as const, type: 'group' as const,

View file

@ -89,6 +89,14 @@ import type {
MessageToDelete, MessageToDelete,
} from './messageReceiverEvents'; } from './messageReceiverEvents';
import { getConversationFromTarget } from '../util/deleteForMe'; import { getConversationFromTarget } from '../util/deleteForMe';
import type { CallDetails } from '../types/CallDisposition';
import {
AdhocCallStatus,
DirectCallStatus,
GroupCallStatus,
} from '../types/CallDisposition';
import { getProtoForCallHistory } from '../util/callDisposition';
import { CallMode } from '../types/Calling';
export type SendMetadataType = { export type SendMetadataType = {
[serviceId: ServiceIdString]: { [serviceId: ServiceIdString]: {
@ -1567,6 +1575,71 @@ export default class MessageSender {
}; };
} }
static getClearCallHistoryMessage(timestamp: number): SingleProtoJobData {
const ourAci = window.textsecure.storage.user.getCheckedAci();
const callLogEvent = new Proto.SyncMessage.CallLogEvent({
type: Proto.SyncMessage.CallLogEvent.Type.CLEAR,
timestamp: Long.fromNumber(timestamp),
});
const syncMessage = MessageSender.createSyncMessage();
syncMessage.callLogEvent = callLogEvent;
const contentMessage = new Proto.Content();
contentMessage.syncMessage = syncMessage;
const { ContentHint } = Proto.UnidentifiedSenderMessage.Message;
return {
contentHint: ContentHint.RESENDABLE,
serviceId: ourAci,
isSyncMessage: true,
protoBase64: Bytes.toBase64(
Proto.Content.encode(contentMessage).finish()
),
type: 'callLogEventSync',
urgent: false,
};
}
static getDeleteCallEvent(callDetails: CallDetails): SingleProtoJobData {
const ourAci = window.textsecure.storage.user.getCheckedAci();
const { mode } = callDetails;
let status;
if (mode === CallMode.Adhoc) {
status = AdhocCallStatus.Deleted;
} else if (mode === CallMode.Direct) {
status = DirectCallStatus.Deleted;
} else if (mode === CallMode.Group) {
status = GroupCallStatus.Deleted;
} else {
throw missingCaseError(mode);
}
const callEvent = getProtoForCallHistory({
...callDetails,
status,
});
const syncMessage = MessageSender.createSyncMessage();
syncMessage.callEvent = callEvent;
const contentMessage = new Proto.Content();
contentMessage.syncMessage = syncMessage;
const { ContentHint } = Proto.UnidentifiedSenderMessage.Message;
return {
contentHint: ContentHint.RESENDABLE,
serviceId: ourAci,
isSyncMessage: true,
protoBase64: Bytes.toBase64(
Proto.Content.encode(contentMessage).finish()
),
type: 'callLogEventSync',
urgent: false,
};
}
async syncReadMessages( async syncReadMessages(
reads: ReadonlyArray<{ reads: ReadonlyArray<{
senderAci?: AciString; senderAci?: AciString;
@ -2353,9 +2426,11 @@ function toAddressableMessage(message: MessageToDelete) {
targetMessage.sentTimestamp = Long.fromNumber(message.sentAt); targetMessage.sentTimestamp = Long.fromNumber(message.sentAt);
if (message.type === 'aci') { if (message.type === 'aci') {
targetMessage.authorAci = message.authorAci; targetMessage.authorServiceId = message.authorAci;
} else if (message.type === 'e164') { } else if (message.type === 'e164') {
targetMessage.authorE164 = message.authorE164; targetMessage.authorE164 = message.authorE164;
} else if (message.type === 'pni') {
targetMessage.authorServiceId = message.authorPni;
} else { } else {
throw missingCaseError(message); throw missingCaseError(message);
} }
@ -2368,7 +2443,9 @@ function toConversationIdentifier(conversation: ConversationToDelete) {
new Proto.SyncMessage.DeleteForMe.ConversationIdentifier(); new Proto.SyncMessage.DeleteForMe.ConversationIdentifier();
if (conversation.type === 'aci') { if (conversation.type === 'aci') {
targetConversation.threadAci = conversation.aci; targetConversation.threadServiceId = conversation.aci;
} else if (conversation.type === 'pni') {
targetConversation.threadServiceId = conversation.pni;
} else if (conversation.type === 'group') { } else if (conversation.type === 'group') {
targetConversation.threadGroupId = Bytes.fromBase64(conversation.groupId); targetConversation.threadGroupId = Bytes.fromBase64(conversation.groupId);
} else if (conversation.type === 'e164') { } else if (conversation.type === 'e164') {

View file

@ -703,8 +703,12 @@ export type WebAPIConnectType = {
connect: (options: WebAPIConnectOptionsType) => WebAPIType; connect: (options: WebAPIConnectOptionsType) => WebAPIType;
}; };
export type CapabilitiesType = Record<string, never>; export type CapabilitiesType = {
export type CapabilitiesUploadType = Record<string, never>; deleteSync: boolean;
};
export type CapabilitiesUploadType = {
deleteSync: true;
};
type StickerPackManifestType = Uint8Array; type StickerPackManifestType = Uint8Array;

View file

@ -6,7 +6,11 @@ import type { PublicKey } from '@signalapp/libsignal-client';
import { z } from 'zod'; import { z } from 'zod';
import type { SignalService as Proto } from '../protobuf'; import type { SignalService as Proto } from '../protobuf';
import type { ServiceIdString, AciString } from '../types/ServiceId'; import {
type ServiceIdString,
type AciString,
isPniString,
} from '../types/ServiceId';
import type { StoryDistributionIdString } from '../types/StoryDistributionId'; import type { StoryDistributionIdString } from '../types/StoryDistributionId';
import type { import type {
ProcessedEnvelope, ProcessedEnvelope,
@ -93,7 +97,6 @@ export class EnvelopeUnsealedEvent extends Event {
} }
} }
// Emitted when we queue previously-decrypted events from the cache
export class EnvelopeQueuedEvent extends Event { export class EnvelopeQueuedEvent extends Event {
constructor(public readonly envelope: ProcessedEnvelope) { constructor(public readonly envelope: ProcessedEnvelope) {
super('envelopeQueued'); super('envelopeQueued');
@ -113,9 +116,7 @@ export class ConfirmableEvent extends Event {
} }
export type DeliveryEventData = Readonly<{ export type DeliveryEventData = Readonly<{
envelopeId: string;
timestamp: number; timestamp: number;
envelopeTimestamp: number;
source?: string; source?: string;
sourceServiceId?: ServiceIdString; sourceServiceId?: ServiceIdString;
sourceDevice?: number; sourceDevice?: number;
@ -124,7 +125,9 @@ export type DeliveryEventData = Readonly<{
export class DeliveryEvent extends ConfirmableEvent { export class DeliveryEvent extends ConfirmableEvent {
constructor( constructor(
public readonly deliveryReceipt: DeliveryEventData, public readonly deliveryReceipts: ReadonlyArray<DeliveryEventData>,
public readonly envelopeId: string,
public readonly envelopeTimestamp: number,
confirm: ConfirmCallback confirm: ConfirmCallback
) { ) {
super('delivery', confirm); super('delivery', confirm);
@ -245,9 +248,7 @@ export class MessageEvent extends ConfirmableEvent {
} }
export type ReadOrViewEventData = Readonly<{ export type ReadOrViewEventData = Readonly<{
envelopeId: string;
timestamp: number; timestamp: number;
envelopeTimestamp: number;
source?: string; source?: string;
sourceServiceId?: ServiceIdString; sourceServiceId?: ServiceIdString;
sourceDevice?: number; sourceDevice?: number;
@ -256,7 +257,9 @@ export type ReadOrViewEventData = Readonly<{
export class ReadEvent extends ConfirmableEvent { export class ReadEvent extends ConfirmableEvent {
constructor( constructor(
public readonly receipt: ReadOrViewEventData, public readonly receipts: ReadonlyArray<ReadOrViewEventData>,
public readonly envelopeId: string,
public readonly envelopeTimestamp: number,
confirm: ConfirmCallback confirm: ConfirmCallback
) { ) {
super('read', confirm); super('read', confirm);
@ -265,7 +268,9 @@ export class ReadEvent extends ConfirmableEvent {
export class ViewEvent extends ConfirmableEvent { export class ViewEvent extends ConfirmableEvent {
constructor( constructor(
public readonly receipt: ReadOrViewEventData, public readonly receipts: ReadonlyArray<ReadOrViewEventData>,
public readonly envelopeId: string,
public readonly envelopeTimestamp: number,
confirm: ConfirmCallback confirm: ConfirmCallback
) { ) {
super('view', confirm); super('view', confirm);
@ -405,7 +410,9 @@ export type ReadSyncEventData = Readonly<{
export class ReadSyncEvent extends ConfirmableEvent { export class ReadSyncEvent extends ConfirmableEvent {
constructor( constructor(
public readonly read: ReadSyncEventData, public readonly reads: ReadonlyArray<ReadSyncEventData>,
public readonly envelopeId: string,
public readonly envelopeTimestamp: number,
confirm: ConfirmCallback confirm: ConfirmCallback
) { ) {
super('readSync', confirm); super('readSync', confirm);
@ -413,16 +420,16 @@ export class ReadSyncEvent extends ConfirmableEvent {
} }
export type ViewSyncEventData = Readonly<{ export type ViewSyncEventData = Readonly<{
envelopeId: string;
timestamp?: number; timestamp?: number;
envelopeTimestamp: number;
senderE164?: string; senderE164?: string;
senderAci?: AciString; senderAci?: AciString;
}>; }>;
export class ViewSyncEvent extends ConfirmableEvent { export class ViewSyncEvent extends ConfirmableEvent {
constructor( constructor(
public readonly view: ViewSyncEventData, public readonly views: ReadonlyArray<ViewSyncEventData>,
public readonly envelopeId: string,
public readonly envelopeTimestamp: number,
confirm: ConfirmCallback confirm: ConfirmCallback
) { ) {
super('viewSync', confirm); super('viewSync', confirm);
@ -470,15 +477,16 @@ const messageToDeleteSchema = z.union([
authorE164: z.string(), authorE164: z.string(),
sentAt: z.number(), sentAt: z.number(),
}), }),
z.object({
type: z.literal('pni').readonly(),
authorPni: z.string().refine(isPniString),
sentAt: z.number(),
}),
]); ]);
export type MessageToDelete = z.infer<typeof messageToDeleteSchema>; export type MessageToDelete = z.infer<typeof messageToDeleteSchema>;
const conversationToDeleteSchema = z.union([ const conversationToDeleteSchema = z.union([
z.object({
type: z.literal('group').readonly(),
groupId: z.string(),
}),
z.object({ z.object({
type: z.literal('aci').readonly(), type: z.literal('aci').readonly(),
aci: z.string().refine(isAciString), aci: z.string().refine(isAciString),
@ -487,6 +495,14 @@ const conversationToDeleteSchema = z.union([
type: z.literal('e164').readonly(), type: z.literal('e164').readonly(),
e164: z.string(), e164: z.string(),
}), }),
z.object({
type: z.literal('group').readonly(),
groupId: z.string(),
}),
z.object({
type: z.literal('pni').readonly(),
pni: z.string().refine(isPniString),
}),
]); ]);
export type ConversationToDelete = z.infer<typeof conversationToDeleteSchema>; export type ConversationToDelete = z.infer<typeof conversationToDeleteSchema>;

View file

@ -280,7 +280,7 @@ function shouldSyncStatus(callStatus: CallStatus) {
return statusToProto[callStatus] != null; return statusToProto[callStatus] != null;
} }
function getProtoForCallHistory( export function getProtoForCallHistory(
callHistory: CallHistoryDetails callHistory: CallHistoryDetails
): Proto.SyncMessage.ICallEvent | null { ): Proto.SyncMessage.ICallEvent | null {
const event = statusToProto[callHistory.status]; const event = statusToProto[callHistory.status];
@ -1026,7 +1026,10 @@ async function saveCallHistory({
if (isDeleted) { if (isDeleted) {
if (prevMessage != null) { if (prevMessage != null) {
await window.Signal.Data.removeMessage(prevMessage.id); await window.Signal.Data.removeMessage(prevMessage.id, {
fromSync: true,
singleProtoJobQueue,
});
} }
return callHistory; return callHistory;
} }
@ -1209,32 +1212,10 @@ export async function clearCallHistoryDataAndSync(): Promise<void> {
window.MessageCache.__DEPRECATED$unregister(messageId); window.MessageCache.__DEPRECATED$unregister(messageId);
}); });
const ourAci = window.textsecure.storage.user.getCheckedAci();
const callLogEvent = new Proto.SyncMessage.CallLogEvent({
type: Proto.SyncMessage.CallLogEvent.Type.CLEAR,
timestamp: Long.fromNumber(timestamp),
});
const syncMessage = MessageSender.createSyncMessage();
syncMessage.callLogEvent = callLogEvent;
const contentMessage = new Proto.Content();
contentMessage.syncMessage = syncMessage;
const { ContentHint } = Proto.UnidentifiedSenderMessage.Message;
log.info('clearCallHistory: Queueing sync message'); log.info('clearCallHistory: Queueing sync message');
await singleProtoJobQueue.add({ await singleProtoJobQueue.add(
contentHint: ContentHint.RESENDABLE, MessageSender.getClearCallHistoryMessage(timestamp)
serviceId: ourAci, );
isSyncMessage: true,
protoBase64: Bytes.toBase64(
Proto.Content.encode(contentMessage).finish()
),
type: 'callLogEventSync',
urgent: false,
});
} catch (error) { } catch (error) {
log.error('clearCallHistory: Failed to clear call history', error); log.error('clearCallHistory: Failed to clear call history', error);
} }

View file

@ -1,17 +1,67 @@
// Copyright 2021 Signal Messenger, LLC // Copyright 2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only // SPDX-License-Identifier: AGPL-3.0-only
import PQueue from 'p-queue';
import { batch } from 'react-redux';
import type { MessageAttributesType } from '../model-types.d'; import type { MessageAttributesType } from '../model-types.d';
import { deletePackReference } from '../types/Stickers'; import { deletePackReference } from '../types/Stickers';
import { isStory } from '../messages/helpers'; import { isStory } from '../messages/helpers';
import { isDirectConversation } from './whatTypeOfConversation'; import { isDirectConversation } from './whatTypeOfConversation';
import * as log from '../logging/log'; import * as log from '../logging/log';
import { getCallHistorySelector } from '../state/selectors/callHistory';
import {
DirectCallStatus,
GroupCallStatus,
AdhocCallStatus,
} from '../types/CallDisposition';
import { getMessageIdForLogging } from './idForLogging';
import type { SingleProtoJobQueue } from '../jobs/singleProtoJobQueue';
import { MINUTE } from './durations';
import { drop } from './drop';
export async function cleanupMessage( export async function cleanupMessages(
message: MessageAttributesType messages: ReadonlyArray<MessageAttributesType>,
{
fromSync,
markCallHistoryDeleted,
singleProtoJobQueue,
}: {
fromSync?: boolean;
markCallHistoryDeleted: (callId: string) => Promise<void>;
singleProtoJobQueue: SingleProtoJobQueue;
}
): Promise<void> { ): Promise<void> {
cleanupMessageFromMemory(message); // First, handle any calls that need to be deleted
const inMemoryQueue = new PQueue({ concurrency: 3, timeout: MINUTE * 30 });
drop(
inMemoryQueue.addAll(
messages.map((message: MessageAttributesType) => async () => {
await maybeDeleteCall(message, {
fromSync,
markCallHistoryDeleted,
singleProtoJobQueue,
});
})
)
);
await inMemoryQueue.onIdle();
// Then, remove messages from memory, so we can batch the updates in redux
batch(() => {
messages.forEach(message => cleanupMessageFromMemory(message));
});
// Then, handle any asynchronous actions (e.g. deleting data from disk)
const unloadedQueue = new PQueue({ concurrency: 3, timeout: MINUTE * 30 });
drop(
unloadedQueue.addAll(
messages.map((message: MessageAttributesType) => async () => {
await deleteMessageData(message); await deleteMessageData(message);
})
)
);
await unloadedQueue.onIdle();
} }
/** Removes a message from redux caches & backbone, but does NOT delete files on disk, /** Removes a message from redux caches & backbone, but does NOT delete files on disk,
@ -122,3 +172,50 @@ export async function deleteMessageData(
await deletePackReference(message.id, packId); await deletePackReference(message.id, packId);
} }
} }
export async function maybeDeleteCall(
message: MessageAttributesType,
{
fromSync,
markCallHistoryDeleted,
singleProtoJobQueue,
}: {
fromSync?: boolean;
markCallHistoryDeleted: (callId: string) => Promise<void>;
singleProtoJobQueue: SingleProtoJobQueue;
}
): Promise<void> {
const { callId } = message;
const logId = `maybeDeleteCall(${getMessageIdForLogging(message)})`;
if (!callId) {
return;
}
const callHistory = getCallHistorySelector(window.reduxStore.getState())(
callId
);
if (!callHistory) {
return;
}
if (
callHistory.status === DirectCallStatus.Pending ||
callHistory.status === GroupCallStatus.Joined ||
callHistory.status === GroupCallStatus.OutgoingRing ||
callHistory.status === GroupCallStatus.Ringing ||
callHistory.status === AdhocCallStatus.Pending
) {
log.warn(
`${logId}: Call status is ${callHistory.status}; not deleting from Call Tab`
);
return;
}
if (!fromSync) {
await singleProtoJobQueue.add(
window.textsecure.MessageSender.getDeleteCallEvent(callHistory)
);
}
await markCallHistoryDeleted(callId);
window.reduxActions.callHistory.removeCallHistory(callId);
}

View file

@ -24,7 +24,9 @@ import type {
ConversationToDelete, ConversationToDelete,
MessageToDelete, MessageToDelete,
} from '../textsecure/messageReceiverEvents'; } from '../textsecure/messageReceiverEvents';
import type { AciString } from '../types/ServiceId'; import { isPniString } from '../types/ServiceId';
import type { AciString, PniString } from '../types/ServiceId';
import { singleProtoJobQueue } from '../jobs/singleProtoJobQueue';
const { const {
getMessagesBySentAt, getMessagesBySentAt,
@ -48,12 +50,16 @@ export function doesMessageMatch({
const conversationMatches = message.conversationId === conversationId; const conversationMatches = message.conversationId === conversationId;
const aciMatches = const aciMatches =
query.authorAci && author?.attributes.serviceId === query.authorAci; query.authorAci && author?.attributes.serviceId === query.authorAci;
const pniMatches =
query.authorPni && author?.attributes.serviceId === query.authorPni;
const e164Matches = const e164Matches =
query.authorE164 && author?.attributes.e164 === query.authorE164; query.authorE164 && author?.attributes.e164 === query.authorE164;
const timestampMatches = sentTimestamps.has(query.sentAt); const timestampMatches = sentTimestamps.has(query.sentAt);
return Boolean( return Boolean(
conversationMatches && timestampMatches && (aciMatches || e164Matches) conversationMatches &&
timestampMatches &&
(aciMatches || e164Matches || pniMatches)
); );
} }
@ -91,7 +97,10 @@ export async function deleteMessage(
return false; return false;
} }
await deleteAndCleanup([found], logId); await deleteAndCleanup([found], logId, {
fromSync: true,
singleProtoJobQueue,
});
return true; return true;
} }
@ -113,8 +122,10 @@ export async function deleteConversation(
const { received_at: receivedAt } = newestMessage; const { received_at: receivedAt } = newestMessage;
await removeMessagesInConversation(conversation.id, { await removeMessagesInConversation(conversation.id, {
fromSync: true,
receivedAt, receivedAt,
logId: `${logId}(receivedAt=${receivedAt})`, logId: `${logId}(receivedAt=${receivedAt})`,
singleProtoJobQueue,
}); });
} }
@ -170,6 +181,9 @@ export function getConversationFromTarget(
if (type === 'e164') { if (type === 'e164') {
return window.ConversationController.get(targetConversation.e164); return window.ConversationController.get(targetConversation.e164);
} }
if (type === 'pni') {
return window.ConversationController.get(targetConversation.pni);
}
throw missingCaseError(type); throw missingCaseError(type);
} }
@ -178,6 +192,7 @@ type MessageQuery = {
sentAt: number; sentAt: number;
authorAci?: AciString; authorAci?: AciString;
authorE164?: string; authorE164?: string;
authorPni?: PniString;
}; };
export function getMessageQueryFromTarget( export function getMessageQueryFromTarget(
@ -191,6 +206,13 @@ export function getMessageQueryFromTarget(
} }
return { sentAt, authorAci: targetMessage.authorAci }; return { sentAt, authorAci: targetMessage.authorAci };
} }
if (type === 'pni') {
if (!isPniString(targetMessage.authorPni)) {
throw new Error('Provided authorPni was not a PNI!');
}
return { sentAt, authorPni: targetMessage.authorPni };
}
if (type === 'e164') { if (type === 'e164') {
return { sentAt, authorE164: targetMessage.authorE164 }; return { sentAt, authorE164: targetMessage.authorE164 };
} }

View file

@ -4,6 +4,7 @@
import * as log from '../logging/log'; import * as log from '../logging/log';
import { calculateExpirationTimestamp } from './expirationTimer'; import { calculateExpirationTimestamp } from './expirationTimer';
import { DAY } from './durations'; import { DAY } from './durations';
import { singleProtoJobQueue } from '../jobs/singleProtoJobQueue';
export async function findAndDeleteOnboardingStoryIfExists(): Promise<void> { export async function findAndDeleteOnboardingStoryIfExists(): Promise<void> {
const existingOnboardingStoryMessageIds = window.storage.get( const existingOnboardingStoryMessageIds = window.storage.get(
@ -43,7 +44,9 @@ export async function findAndDeleteOnboardingStoryIfExists(): Promise<void> {
log.info('findAndDeleteOnboardingStoryIfExists: removing onboarding stories'); log.info('findAndDeleteOnboardingStoryIfExists: removing onboarding stories');
await window.Signal.Data.removeMessages(existingOnboardingStoryMessageIds); await window.Signal.Data.removeMessages(existingOnboardingStoryMessageIds, {
singleProtoJobQueue,
});
await window.storage.put('existingOnboardingStoryMessageIds', undefined); await window.storage.put('existingOnboardingStoryMessageIds', undefined);

View file

@ -217,6 +217,7 @@ export function getConversation(model: ConversationModel): ConversationType {
profileName: getProfileName(attributes), profileName: getProfileName(attributes),
profileSharing: attributes.profileSharing, profileSharing: attributes.profileSharing,
profileLastUpdatedAt: attributes.profileLastUpdatedAt, profileLastUpdatedAt: attributes.profileLastUpdatedAt,
capabilities: attributes.capabilities,
sharingPhoneNumber: attributes.sharingPhoneNumber, sharingPhoneNumber: attributes.sharingPhoneNumber,
publicParams: attributes.publicParams, publicParams: attributes.publicParams,
secretParams: attributes.secretParams, secretParams: attributes.secretParams,

View file

@ -7,7 +7,7 @@ import type { ConversationAttributesType } from '../model-types.d';
import { hasErrors } from '../state/selectors/message'; import { hasErrors } from '../state/selectors/message';
import { readSyncJobQueue } from '../jobs/readSyncJobQueue'; import { readSyncJobQueue } from '../jobs/readSyncJobQueue';
import { notificationService } from '../services/notifications'; import { notificationService } from '../services/notifications';
import { expiringMessagesDeletionService } from '../services/expiringMessagesDeletion'; import { update as updateExpiringMessagesService } from '../services/expiringMessagesDeletion';
import { tapToViewMessagesDeletionService } from '../services/tapToViewMessagesDeletionService'; import { tapToViewMessagesDeletionService } from '../services/tapToViewMessagesDeletionService';
import { isGroup, isDirectConversation } from './whatTypeOfConversation'; import { isGroup, isDirectConversation } from './whatTypeOfConversation';
import * as log from '../logging/log'; import * as log from '../logging/log';
@ -196,7 +196,7 @@ export async function markConversationRead(
} }
} }
void expiringMessagesDeletionService.update(); void updateExpiringMessagesService();
void tapToViewMessagesDeletionService.update(); void tapToViewMessagesDeletionService.update();
return true; return true;

View file

@ -29,6 +29,7 @@ import { getSourceServiceId } from '../messages/helpers';
import { missingCaseError } from './missingCaseError'; import { missingCaseError } from './missingCaseError';
import { reduce } from './iterables'; import { reduce } from './iterables';
import { strictAssert } from './assert'; import { strictAssert } from './assert';
import { singleProtoJobQueue } from '../jobs/singleProtoJobQueue';
export enum ModifyTargetMessageResult { export enum ModifyTargetMessageResult {
Modified = 'Modified', Modified = 'Modified',
@ -55,24 +56,28 @@ export async function modifyTargetMessage(
const syncDeletes = await DeletesForMe.forMessage(message.attributes); const syncDeletes = await DeletesForMe.forMessage(message.attributes);
if (syncDeletes.length) { if (syncDeletes.length) {
if (!isFirstRun) { if (!isFirstRun) {
await window.Signal.Data.removeMessage(message.id); await window.Signal.Data.removeMessage(message.id, {
fromSync: true,
singleProtoJobQueue,
});
} }
return ModifyTargetMessageResult.Deleted; return ModifyTargetMessageResult.Deleted;
} }
if (type === 'outgoing' || (type === 'story' && ourAci === sourceServiceId)) { if (type === 'outgoing' || (type === 'story' && ourAci === sourceServiceId)) {
const sendActions = MessageReceipts.forMessage(message).map(receipt => { const receipts = await MessageReceipts.forMessage(message);
const sendActions = receipts.map(({ receiptSync }) => {
let sendActionType: SendActionType; let sendActionType: SendActionType;
const receiptType = receipt.type; const receiptType = receiptSync.type;
switch (receiptType) { switch (receiptType) {
case MessageReceipts.MessageReceiptType.Delivery: case MessageReceipts.messageReceiptTypeSchema.enum.Delivery:
sendActionType = SendActionType.GotDeliveryReceipt; sendActionType = SendActionType.GotDeliveryReceipt;
break; break;
case MessageReceipts.MessageReceiptType.Read: case MessageReceipts.messageReceiptTypeSchema.enum.Read:
sendActionType = SendActionType.GotReadReceipt; sendActionType = SendActionType.GotReadReceipt;
break; break;
case MessageReceipts.MessageReceiptType.View: case MessageReceipts.messageReceiptTypeSchema.enum.View:
sendActionType = SendActionType.GotViewedReceipt; sendActionType = SendActionType.GotViewedReceipt;
break; break;
default: default:
@ -80,10 +85,10 @@ export async function modifyTargetMessage(
} }
return { return {
destinationConversationId: receipt.sourceConversationId, destinationConversationId: receiptSync.sourceConversationId,
action: { action: {
type: sendActionType, type: sendActionType,
updatedAt: receipt.receiptTimestamp, updatedAt: receiptSync.receiptTimestamp,
}, },
}; };
}); });
@ -123,10 +128,10 @@ export async function modifyTargetMessage(
if (type === 'incoming') { if (type === 'incoming') {
// In a followup (see DESKTOP-2100), we want to make `ReadSyncs#forMessage` return // In a followup (see DESKTOP-2100), we want to make `ReadSyncs#forMessage` return
// an array, not an object. This array wrapping makes that future a bit easier. // an array, not an object. This array wrapping makes that future a bit easier.
const readSync = ReadSyncs.forMessage(message); const maybeSingleReadSync = await ReadSyncs.forMessage(message);
const readSyncs = readSync ? [readSync] : []; const readSyncs = maybeSingleReadSync ? [maybeSingleReadSync] : [];
const viewSyncs = ViewSyncs.forMessage(message); const viewSyncs = await ViewSyncs.forMessage(message);
const isGroupStoryReply = const isGroupStoryReply =
isGroup(conversation.attributes) && message.get('storyId'); isGroup(conversation.attributes) && message.get('storyId');
@ -134,8 +139,8 @@ export async function modifyTargetMessage(
if (readSyncs.length !== 0 || viewSyncs.length !== 0) { if (readSyncs.length !== 0 || viewSyncs.length !== 0) {
const markReadAt = Math.min( const markReadAt = Math.min(
Date.now(), Date.now(),
...readSyncs.map(sync => sync.readAt), ...readSyncs.map(({ readSync }) => readSync.readAt),
...viewSyncs.map(sync => sync.viewedAt) ...viewSyncs.map(({ viewSync }) => viewSync.viewedAt)
); );
if (message.get('expireTimer')) { if (message.get('expireTimer')) {
@ -180,7 +185,7 @@ export async function modifyTargetMessage(
if (!isFirstRun && message.getPendingMarkRead()) { if (!isFirstRun && message.getPendingMarkRead()) {
const markReadAt = message.getPendingMarkRead(); const markReadAt = message.getPendingMarkRead();
message.setPendingMarkRead(undefined); message.setPendingMarkRead(undefined);
const newestSentAt = readSync?.timestamp; const newestSentAt = maybeSingleReadSync?.readSync.timestamp;
// This is primarily to allow the conversation to mark all older // This is primarily to allow the conversation to mark all older
// messages as read, as is done when we receive a read sync for // messages as read, as is done when we receive a read sync for
@ -207,7 +212,7 @@ export async function modifyTargetMessage(
} }
if (isStory(message.attributes)) { if (isStory(message.attributes)) {
const viewSyncs = ViewSyncs.forMessage(message); const viewSyncs = await ViewSyncs.forMessage(message);
if (viewSyncs.length !== 0) { if (viewSyncs.length !== 0) {
message.set({ message.set({
@ -218,7 +223,7 @@ export async function modifyTargetMessage(
const markReadAt = Math.min( const markReadAt = Math.min(
Date.now(), Date.now(),
...viewSyncs.map(sync => sync.viewedAt) ...viewSyncs.map(({ viewSync }) => viewSync.viewedAt)
); );
message.setPendingMarkRead( message.setPendingMarkRead(
Math.min(message.getPendingMarkRead() ?? Date.now(), markReadAt) Math.min(message.getPendingMarkRead() ?? Date.now(), markReadAt)

View file

@ -4,6 +4,7 @@
import { z } from 'zod'; import { z } from 'zod';
import type { ZodSchema } from 'zod'; import type { ZodSchema } from 'zod';
import { drop } from './drop';
import * as log from '../logging/log'; import * as log from '../logging/log';
import * as DeletesForMe from '../messageModifiers/DeletesForMe'; import * as DeletesForMe from '../messageModifiers/DeletesForMe';
import { import {
@ -11,18 +12,31 @@ import {
deleteConversationSchema, deleteConversationSchema,
deleteLocalConversationSchema, deleteLocalConversationSchema,
} from '../textsecure/messageReceiverEvents'; } from '../textsecure/messageReceiverEvents';
import {
receiptSyncTaskSchema,
onReceipt,
} from '../messageModifiers/MessageReceipts';
import { import {
deleteConversation, deleteConversation,
deleteLocalOnlyConversation, deleteLocalOnlyConversation,
getConversationFromTarget, getConversationFromTarget,
} from './deleteForMe'; } from './deleteForMe';
import { drop } from './drop'; import {
onSync as onReadSync,
readSyncTaskSchema,
} from '../messageModifiers/ReadSyncs';
import {
onSync as onViewSync,
viewSyncTaskSchema,
} from '../messageModifiers/ViewSyncs';
const syncTaskDataSchema = z.union([ const syncTaskDataSchema = z.union([
deleteMessageSchema, deleteMessageSchema,
deleteConversationSchema, deleteConversationSchema,
deleteLocalConversationSchema, deleteLocalConversationSchema,
receiptSyncTaskSchema,
readSyncTaskSchema,
viewSyncTaskSchema,
]); ]);
export type SyncTaskData = z.infer<typeof syncTaskDataSchema>; export type SyncTaskData = z.infer<typeof syncTaskDataSchema>;
@ -40,6 +54,11 @@ const SCHEMAS_BY_TYPE: Record<SyncTaskData['type'], ZodSchema> = {
'delete-message': deleteMessageSchema, 'delete-message': deleteMessageSchema,
'delete-conversation': deleteConversationSchema, 'delete-conversation': deleteConversationSchema,
'delete-local-conversation': deleteLocalConversationSchema, 'delete-local-conversation': deleteLocalConversationSchema,
Delivery: receiptSyncTaskSchema,
Read: receiptSyncTaskSchema,
View: receiptSyncTaskSchema,
ReadSync: readSyncTaskSchema,
ViewSync: viewSyncTaskSchema,
}; };
function toLogId(task: SyncTaskType) { function toLogId(task: SyncTaskType) {
@ -77,14 +96,15 @@ export async function queueSyncTasks(
const { data: parsed } = parseResult; const { data: parsed } = parseResult;
if (parsed.type === 'delete-message') { if (parsed.type === 'delete-message') {
// eslint-disable-next-line no-await-in-loop drop(
await DeletesForMe.onDelete({ DeletesForMe.onDelete({
conversation: parsed.conversation, conversation: parsed.conversation,
envelopeId, envelopeId,
message: parsed.message, message: parsed.message,
syncTaskId: id, syncTaskId: id,
timestamp: sentAt, timestamp: sentAt,
}); })
);
} else if (parsed.type === 'delete-conversation') { } else if (parsed.type === 'delete-conversation') {
const { const {
conversation: targetConversation, conversation: targetConversation,
@ -133,6 +153,39 @@ export async function queueSyncTasks(
log.info(`${logId}: Done; result=${result}`); log.info(`${logId}: Done; result=${result}`);
}) })
); );
} else if (
parsed.type === 'Delivery' ||
parsed.type === 'Read' ||
parsed.type === 'View'
) {
drop(
onReceipt({
envelopeId,
receiptSync: parsed,
syncTaskId: id,
})
);
} else if (parsed.type === 'ReadSync') {
drop(
onReadSync({
envelopeId,
readSync: parsed,
syncTaskId: id,
})
);
} else if (parsed.type === 'ViewSync') {
drop(
onViewSync({
envelopeId,
viewSync: parsed,
syncTaskId: id,
})
);
} else {
const parsedType: never = parsed.type;
log.error(`${logId}: Encountered job of type ${parsedType}, removing`);
// eslint-disable-next-line no-await-in-loop
await removeSyncTaskById(id);
} }
} }
} }