diff --git a/_locales/en/messages.json b/_locales/en/messages.json
index 22a5a2150451..919a893d44df 100644
--- a/_locales/en/messages.json
+++ b/_locales/en/messages.json
@@ -5010,6 +5010,10 @@
"messageformat": "What devices would you like to delete {count, plural, one {this message} other {these messages}} from?",
"description": "within note to self conversation > delete selected messages > confirmation modal > description"
},
+ "icu:DeleteMessagesModal--description--noteToSelf--deleteSync": {
+ "messageformat": "{count, plural, one {This message} other {These messages}} will be deleted from all your devices.",
+ "description": "within note to self conversation > delete selected messages > confirmation modal > description"
+ },
"icu:DeleteMessagesModal--deleteForMe": {
"messageformat": "Delete for me",
"description": "delete selected messages > confirmation modal > delete for me"
@@ -5026,6 +5030,10 @@
"messageformat": "Delete from all devices",
"description": "within note to self conversation > delete selected messages > confirmation modal > delete from all devices (same as delete for everyone)"
},
+ "icu:DeleteMessagesModal--noteToSelf--deleteSync": {
+ "messageformat": "Delete",
+ "description": "When delete sync is enabled, there is only one Delete option in Note to Self"
+ },
"icu:DeleteMessagesModal__toast--TooManyMessagesToDeleteForEveryone": {
"messageformat": "You can only select up to {count, plural, one {# message} other {# messages}} to delete for everyone",
"description": "delete selected messages > confirmation modal > deleted for everyone (disabled) > toast > too many messages to 'delete for everyone'"
@@ -6292,6 +6300,18 @@
"messageformat": "Your connections can see your name and photo, and can see posts to \"My Story\" unless you hide it from them",
"description": "Additional information about signal connections and the stories they can see"
},
+ "icu:LocalDeleteWarningModal__header": {
+ "messageformat": "\"Delete for Me\" now deletes from all of your devices",
+ "description": "Emphasized text at the top of the explainer dialog you get when you first delete a message or conversation"
+ },
+ "icu:LocalDeleteWarningModal__description": {
+ "messageformat": "When you delete a message in a chat, the message will be deleted from your phone and all linked devices.",
+ "description": "More detailed description of new delete behavior shown in explainer dialog"
+ },
+ "icu:LocalDeleteWarningModal__confirm": {
+ "messageformat": "Got it",
+ "description": "Button to dismiss the dialog explaining that 'delete for me' now syncs between devices"
+ },
"icu:Stories__title": {
"messageformat": "Stories",
"description": "Title for the stories list"
diff --git a/images/local-delete-sync.svg b/images/local-delete-sync.svg
new file mode 100644
index 000000000000..dee360ed29e2
--- /dev/null
+++ b/images/local-delete-sync.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/protos/SignalService.proto b/protos/SignalService.proto
index 72d04e7fa16a..cf8cb3da340d 100644
--- a/protos/SignalService.proto
+++ b/protos/SignalService.proto
@@ -633,6 +633,43 @@ message SyncMessage {
optional uint64 timestamp = 2;
}
+ message DeleteForMe {
+ message ConversationIdentifier {
+ oneof identifier {
+ string threadAci = 1;
+ bytes threadGroupId = 2;
+ string threadE164 = 3;
+ }
+ }
+
+ message AddressableMessage {
+ oneof author {
+ string authorAci = 1;
+ string authorE164 = 2;
+ }
+ optional uint64 sentTimestamp = 3;
+ }
+
+ message MessageDeletes {
+ optional ConversationIdentifier conversation = 1;
+ repeated AddressableMessage messages = 2;
+ }
+
+ message ConversationDelete {
+ optional ConversationIdentifier conversation = 1;
+ repeated AddressableMessage mostRecentMessages = 2;
+ optional bool isFullDelete = 3;
+ }
+
+ message LocalOnlyConversationDelete {
+ optional ConversationIdentifier conversation = 1;
+ }
+
+ repeated MessageDeletes messageDeletes = 1;
+ repeated ConversationDelete conversationDeletes = 2;
+ repeated LocalOnlyConversationDelete localOnlyConversationDeletes = 3;
+ }
+
optional Sent sent = 1;
optional Contacts contacts = 2;
reserved /* groups */ 3;
@@ -654,6 +691,7 @@ message SyncMessage {
optional CallEvent callEvent = 19;
optional CallLinkUpdate callLinkUpdate = 20;
optional CallLogEvent callLogEvent = 21;
+ optional DeleteForMe deleteForMe = 22;
}
message AttachmentPointer {
diff --git a/stylesheets/components/LocalDeleteWarningModal.scss b/stylesheets/components/LocalDeleteWarningModal.scss
new file mode 100644
index 000000000000..bd0b228688e4
--- /dev/null
+++ b/stylesheets/components/LocalDeleteWarningModal.scss
@@ -0,0 +1,35 @@
+// Copyright 2022 Signal Messenger, LLC
+// SPDX-License-Identifier: AGPL-3.0-only
+
+.LocalDeleteWarningModal__width-container {
+ max-width: 440px;
+}
+
+.LocalDeleteWarningModal__image {
+ margin-block: 18px;
+ text-align: center;
+}
+
+.LocalDeleteWarningModal__header {
+ @include font-title-2;
+
+ margin-block: 18px;
+ margin-inline: 8px;
+ text-align: center;
+}
+
+.LocalDeleteWarningModal__description {
+ margin-block: 12px;
+ margin-inline: 8px;
+ text-align: center;
+}
+
+.LocalDeleteWarningModal__button {
+ display: flex;
+ justify-content: center;
+ margin-top: 49px;
+
+ button {
+ padding-inline: 26px;
+ }
+}
diff --git a/stylesheets/manifest.scss b/stylesheets/manifest.scss
index d64a537257c9..7ea68475830f 100644
--- a/stylesheets/manifest.scss
+++ b/stylesheets/manifest.scss
@@ -113,6 +113,7 @@
@import './components/LeftPaneSearchInput.scss';
@import './components/Lightbox.scss';
@import './components/ListTile.scss';
+@import './components/LocalDeleteWarningModal.scss';
@import './components/MediaEditor.scss';
@import './components/MediaQualitySelector.scss';
@import './components/MessageAudio.scss';
diff --git a/ts/CI/benchmarkConversationOpen.ts b/ts/CI/benchmarkConversationOpen.ts
index e3f8aabcc0bf..c841958f8f58 100644
--- a/ts/CI/benchmarkConversationOpen.ts
+++ b/ts/CI/benchmarkConversationOpen.ts
@@ -50,7 +50,7 @@ export async function populateConversationWithMessages({
);
log.info(`${logId}: destroying all messages in ${conversationId}`);
- await conversation.destroyMessages();
+ await conversation.destroyMessages({ source: 'local-delete' });
log.info(`${logId}: adding ${messageCount} messages to ${conversationId}`);
let timestamp = Date.now();
diff --git a/ts/RemoteConfig.ts b/ts/RemoteConfig.ts
index 8a7751e3be2e..881b4e541df1 100644
--- a/ts/RemoteConfig.ts
+++ b/ts/RemoteConfig.ts
@@ -18,6 +18,8 @@ export type ConfigKeyType =
| 'desktop.calling.adhoc'
| 'desktop.clientExpiration'
| 'desktop.backup.credentialFetch'
+ | 'desktop.deleteSync.send'
+ | 'desktop.deleteSync.receive'
| 'desktop.groupMultiTypingIndicators'
| 'desktop.internalUser'
| 'desktop.mediaQuality.levels'
diff --git a/ts/background.ts b/ts/background.ts
index 06371538c0c7..e6df5203b56b 100644
--- a/ts/background.ts
+++ b/ts/background.ts
@@ -86,6 +86,7 @@ import type {
FetchLatestEvent,
InvalidPlaintextEvent,
KeysEvent,
+ DeleteForMeSyncEvent,
MessageEvent,
MessageEventData,
MessageRequestResponseEvent,
@@ -111,21 +112,21 @@ import type { BadgesStateType } from './state/ducks/badges';
import { areAnyCallsActiveOrRinging } from './state/selectors/calling';
import { badgeImageFileDownloader } from './badges/badgeImageFileDownloader';
import * as Deletes from './messageModifiers/Deletes';
-import type { EditAttributesType } from './messageModifiers/Edits';
import * as Edits from './messageModifiers/Edits';
-import type { ReactionAttributesType } from './messageModifiers/Reactions';
import * as MessageReceipts from './messageModifiers/MessageReceipts';
import * as MessageRequests from './messageModifiers/MessageRequests';
import * as Reactions from './messageModifiers/Reactions';
import * as ReadSyncs from './messageModifiers/ReadSyncs';
-import * as ViewSyncs from './messageModifiers/ViewSyncs';
import * as ViewOnceOpenSyncs from './messageModifiers/ViewOnceOpenSyncs';
+import * as ViewSyncs from './messageModifiers/ViewSyncs';
import type { DeleteAttributesType } from './messageModifiers/Deletes';
+import type { EditAttributesType } from './messageModifiers/Edits';
import type { MessageReceiptAttributesType } from './messageModifiers/MessageReceipts';
import type { MessageRequestAttributesType } from './messageModifiers/MessageRequests';
+import type { ReactionAttributesType } from './messageModifiers/Reactions';
import type { ReadSyncAttributesType } from './messageModifiers/ReadSyncs';
-import type { ViewSyncAttributesType } from './messageModifiers/ViewSyncs';
import type { ViewOnceOpenSyncAttributesType } from './messageModifiers/ViewOnceOpenSyncs';
+import type { ViewSyncAttributesType } from './messageModifiers/ViewSyncs';
import { ReadStatus } from './messages/MessageReadStatus';
import type { SendStateByConversationId } from './messages/MessageSendState';
import { SendStatus } from './messages/MessageSendState';
@@ -201,6 +202,8 @@ import { getThemeType } from './util/getThemeType';
import { AttachmentDownloadManager } from './jobs/AttachmentDownloadManager';
import { onCallLinkUpdateSync } from './util/onCallLinkUpdateSync';
import { CallMode } from './types/Calling';
+import { queueSyncTasks } from './util/syncTasks';
+import { isEnabled } from './RemoteConfig';
export function isOverHourIntoPast(timestamp: number): boolean {
return isNumber(timestamp) && isOlderThan(timestamp, HOUR);
@@ -558,6 +561,24 @@ export async function startApp(): Promise {
storage: window.storage,
serverTrustRoot: window.getServerTrustRoot(),
});
+ const onFirstEmpty = async () => {
+ log.info('onFirstEmpty: Starting');
+
+ // We want to remove this handler on the next tick so we don't interfere with
+ // the other handlers being notified of this instance of the 'empty' event.
+ setTimeout(() => {
+ messageReceiver?.removeEventListener('empty', onFirstEmpty);
+ }, 1);
+
+ log.info('onFirstEmpty: Fetching sync tasks');
+ const syncTasks = await window.Signal.Data.getAllSyncTasks();
+
+ log.info(`onFirstEmpty: Queuing ${syncTasks.length} sync tasks`);
+ await queueSyncTasks(syncTasks, window.Signal.Data.removeSyncTaskById);
+
+ log.info('onFirstEmpty: Done');
+ };
+ messageReceiver.addEventListener('empty', onFirstEmpty);
function queuedEventListener(
handler: (event: E) => Promise | void,
@@ -691,6 +712,10 @@ export async function startApp(): Promise {
'callLogEventSync',
queuedEventListener(onCallLogEventSync, false)
);
+ messageReceiver.addEventListener(
+ 'deleteForMeSync',
+ queuedEventListener(onDeleteForMeSync, false)
+ );
if (!window.storage.get('defaultConversationColor')) {
drop(
@@ -3384,6 +3409,41 @@ export async function startApp(): Promise {
drop(MessageReceipts.onReceipt(attributes));
}
+
+ async function onDeleteForMeSync(ev: DeleteForMeSyncEvent) {
+ const { confirm, timestamp, envelopeId, deleteForMeSync } = ev;
+ const logId = `onDeleteForMeSync(${timestamp})`;
+
+ if (!isEnabled('desktop.deleteSync.receive')) {
+ confirm();
+ return;
+ }
+
+ // The user clearly knows about this feature; they did it on another device!
+ drop(window.storage.put('localDeleteWarningShown', true));
+
+ log.info(`${logId}: Saving ${deleteForMeSync.length} sync tasks`);
+
+ const now = Date.now();
+ const syncTasks = deleteForMeSync.map(item => ({
+ id: generateUuid(),
+ attempts: 1,
+ createdAt: now,
+ data: item,
+ envelopeId,
+ sentAt: timestamp,
+ type: item.type,
+ }));
+ 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`);
+ }
}
window.startApp = startApp;
diff --git a/ts/components/DeleteMessagesModal.stories.tsx b/ts/components/DeleteMessagesModal.stories.tsx
new file mode 100644
index 000000000000..e1436ab36ce4
--- /dev/null
+++ b/ts/components/DeleteMessagesModal.stories.tsx
@@ -0,0 +1,78 @@
+// Copyright 2022 Signal Messenger, LLC
+// SPDX-License-Identifier: AGPL-3.0-only
+
+import React from 'react';
+import type { Meta, StoryFn } from '@storybook/react';
+import { action } from '@storybook/addon-actions';
+
+import enMessages from '../../_locales/en/messages.json';
+import { setupI18n } from '../util/setupI18n';
+import DeleteMessagesModal from './DeleteMessagesModal';
+import type { DeleteMessagesModalProps } from './DeleteMessagesModal';
+
+const i18n = setupI18n('en', enMessages);
+
+export default {
+ title: 'Components/DeleteMessagesModal',
+ component: DeleteMessagesModal,
+ args: {
+ i18n,
+ isMe: false,
+ isDeleteSyncSendEnabled: false,
+ canDeleteForEveryone: true,
+ messageCount: 1,
+ onClose: action('onClose'),
+ onDeleteForMe: action('onDeleteForMe'),
+ onDeleteForEveryone: action('onDeleteForEveryone'),
+ showToast: action('showToast'),
+ },
+} satisfies Meta;
+
+function createProps(args: Partial) {
+ return {
+ i18n,
+ isMe: false,
+ isDeleteSyncSendEnabled: false,
+ canDeleteForEveryone: true,
+ messageCount: 1,
+ onClose: action('onClose'),
+ onDeleteForMe: action('onDeleteForMe'),
+ onDeleteForEveryone: action('onDeleteForEveryone'),
+ showToast: action('showToast'),
+ ...args,
+ };
+}
+
+// eslint-disable-next-line react/function-component-definition
+const Template: StoryFn = args => {
+ return ;
+};
+
+export const OneMessage = Template.bind({});
+
+export const ThreeMessages = Template.bind({});
+ThreeMessages.args = createProps({
+ messageCount: 3,
+});
+
+export const IsMe = Template.bind({});
+IsMe.args = createProps({
+ isMe: true,
+});
+
+export const IsMeThreeMessages = Template.bind({});
+IsMeThreeMessages.args = createProps({
+ isMe: true,
+ messageCount: 3,
+});
+
+export const DeleteSyncEnabled = Template.bind({});
+DeleteSyncEnabled.args = createProps({
+ isDeleteSyncSendEnabled: true,
+});
+
+export const IsMeDeleteSyncEnabled = Template.bind({});
+IsMeDeleteSyncEnabled.args = createProps({
+ isDeleteSyncSendEnabled: true,
+ isMe: true,
+});
diff --git a/ts/components/DeleteMessagesModal.tsx b/ts/components/DeleteMessagesModal.tsx
index e12c248270ae..4bf2bd81018e 100644
--- a/ts/components/DeleteMessagesModal.tsx
+++ b/ts/components/DeleteMessagesModal.tsx
@@ -8,8 +8,9 @@ import type { LocalizerType } from '../types/Util';
import type { ShowToastAction } from '../state/ducks/toast';
import { ToastType } from '../types/Toast';
-type DeleteMessagesModalProps = Readonly<{
+export type DeleteMessagesModalProps = Readonly<{
isMe: boolean;
+ isDeleteSyncSendEnabled: boolean;
canDeleteForEveryone: boolean;
i18n: LocalizerType;
messageCount: number;
@@ -23,6 +24,7 @@ const MAX_DELETE_FOR_EVERYONE = 30;
export default function DeleteMessagesModal({
isMe,
+ isDeleteSyncSendEnabled,
canDeleteForEveryone,
i18n,
messageCount,
@@ -33,15 +35,22 @@ export default function DeleteMessagesModal({
}: DeleteMessagesModalProps): JSX.Element {
const actions: Array = [];
+ const syncNoteToSelfDelete = isMe && isDeleteSyncSendEnabled;
+
+ let deleteForMeText = i18n('icu:DeleteMessagesModal--deleteForMe');
+ if (syncNoteToSelfDelete) {
+ deleteForMeText = i18n('icu:DeleteMessagesModal--noteToSelf--deleteSync');
+ } else if (isMe) {
+ deleteForMeText = i18n('icu:DeleteMessagesModal--deleteFromThisDevice');
+ }
+
actions.push({
action: onDeleteForMe,
style: 'negative',
- text: isMe
- ? i18n('icu:DeleteMessagesModal--deleteFromThisDevice')
- : i18n('icu:DeleteMessagesModal--deleteForMe'),
+ text: deleteForMeText,
});
- if (canDeleteForEveryone) {
+ if (canDeleteForEveryone && !syncNoteToSelfDelete) {
const tooManyMessages = messageCount > MAX_DELETE_FOR_EVERYONE;
actions.push({
'aria-disabled': tooManyMessages,
@@ -63,6 +72,20 @@ export default function DeleteMessagesModal({
});
}
+ let descriptionText = i18n('icu:DeleteMessagesModal--description', {
+ count: messageCount,
+ });
+ if (syncNoteToSelfDelete) {
+ descriptionText = i18n(
+ 'icu:DeleteMessagesModal--description--noteToSelf--deleteSync',
+ { count: messageCount }
+ );
+ } else if (isMe) {
+ descriptionText = i18n('icu:DeleteMessagesModal--description--noteToSelf', {
+ count: messageCount,
+ });
+ }
+
return (
- {isMe
- ? i18n('icu:DeleteMessagesModal--description--noteToSelf', {
- count: messageCount,
- })
- : i18n('icu:DeleteMessagesModal--description', {
- count: messageCount,
- })}
+ {descriptionText}
);
}
diff --git a/ts/components/LocalDeleteWarningModal.stories.tsx b/ts/components/LocalDeleteWarningModal.stories.tsx
new file mode 100644
index 000000000000..7a07dd871c62
--- /dev/null
+++ b/ts/components/LocalDeleteWarningModal.stories.tsx
@@ -0,0 +1,30 @@
+// Copyright 2022 Signal Messenger, LLC
+// SPDX-License-Identifier: AGPL-3.0-only
+
+import type { Meta, StoryFn } from '@storybook/react';
+import React from 'react';
+
+import { action } from '@storybook/addon-actions';
+import type { PropsType } from './LocalDeleteWarningModal';
+import enMessages from '../../_locales/en/messages.json';
+import { LocalDeleteWarningModal } from './LocalDeleteWarningModal';
+import { setupI18n } from '../util/setupI18n';
+
+const i18n = setupI18n('en', enMessages);
+
+export default {
+ title: 'Components/LocalDeleteWarningModal',
+ component: LocalDeleteWarningModal,
+ args: {
+ i18n,
+ onClose: action('onClose'),
+ },
+} satisfies Meta;
+
+// eslint-disable-next-line react/function-component-definition
+const Template: StoryFn = args => (
+
+);
+
+export const Modal = Template.bind({});
+Modal.args = {};
diff --git a/ts/components/LocalDeleteWarningModal.tsx b/ts/components/LocalDeleteWarningModal.tsx
new file mode 100644
index 000000000000..83051d9fce5c
--- /dev/null
+++ b/ts/components/LocalDeleteWarningModal.tsx
@@ -0,0 +1,53 @@
+// Copyright 2022 Signal Messenger, LLC
+// SPDX-License-Identifier: AGPL-3.0-only
+
+import React from 'react';
+
+import type { LocalizerType } from '../types/Util';
+import { Button, ButtonVariant } from './Button';
+import { I18n } from './I18n';
+import { Modal } from './Modal';
+
+export type PropsType = {
+ i18n: LocalizerType;
+ onClose: () => unknown;
+};
+
+export function LocalDeleteWarningModal({
+ i18n,
+ onClose,
+}: PropsType): JSX.Element {
+ return (
+
+
+
+

+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/ts/components/conversation/ConversationHeader.stories.tsx b/ts/components/conversation/ConversationHeader.stories.tsx
index 34e9dc0158aa..ef395d9c5605 100644
--- a/ts/components/conversation/ConversationHeader.stories.tsx
+++ b/ts/components/conversation/ConversationHeader.stories.tsx
@@ -46,6 +46,10 @@ const commonProps = {
i18n,
+ localDeleteWarningShown: true,
+ isDeleteSyncSendEnabled: true,
+ setLocalDeleteWarningShown: action('setLocalDeleteWarningShown'),
+
onConversationAccept: action('onConversationAccept'),
onConversationArchive: action('onConversationArchive'),
onConversationBlock: action('onConversationBlock'),
@@ -412,3 +416,32 @@ export function Unaccepted(): JSX.Element {
>
);
}
+
+export function NeedsDeleteConfirmation(): JSX.Element {
+ const [localDeleteWarningShown, setLocalDeleteWarningShown] =
+ React.useState(false);
+ const props = {
+ ...commonProps,
+ conversation: getDefaultConversation(),
+ localDeleteWarningShown,
+ setLocalDeleteWarningShown: () => setLocalDeleteWarningShown(true),
+ };
+ const theme = useContext(StorybookThemeContext);
+
+ return ;
+}
+
+export function NeedsDeleteConfirmationButNotEnabled(): JSX.Element {
+ const [localDeleteWarningShown, setLocalDeleteWarningShown] =
+ React.useState(false);
+ const props = {
+ ...commonProps,
+ conversation: getDefaultConversation(),
+ localDeleteWarningShown,
+ isDeleteSyncSendEnabled: false,
+ setLocalDeleteWarningShown: () => setLocalDeleteWarningShown(true),
+ };
+ const theme = useContext(StorybookThemeContext);
+
+ return ;
+}
diff --git a/ts/components/conversation/ConversationHeader.tsx b/ts/components/conversation/ConversationHeader.tsx
index a3ee8cc4755b..866cdff35cdd 100644
--- a/ts/components/conversation/ConversationHeader.tsx
+++ b/ts/components/conversation/ConversationHeader.tsx
@@ -38,6 +38,7 @@ import {
MessageRequestState,
} from './MessageRequestActionsConfirmation';
import type { MinimalConversation } from '../../hooks/useMinimalConversation';
+import { LocalDeleteWarningModal } from '../LocalDeleteWarningModal';
function HeaderInfoTitle({
name,
@@ -92,6 +93,8 @@ export type PropsDataType = {
conversationName: ContactNameData;
hasPanelShowing?: boolean;
hasStories?: HasStories;
+ localDeleteWarningShown: boolean;
+ isDeleteSyncSendEnabled: boolean;
isMissingMandatoryProfileSharing?: boolean;
isSelectMode: boolean;
isSignalConversation?: boolean;
@@ -102,6 +105,8 @@ export type PropsDataType = {
};
export type PropsActionsType = {
+ setLocalDeleteWarningShown: () => void;
+
onConversationAccept: () => void;
onConversationArchive: () => void;
onConversationBlock: () => void;
@@ -147,10 +152,12 @@ export const ConversationHeader = memo(function ConversationHeader({
hasPanelShowing,
hasStories,
i18n,
+ isDeleteSyncSendEnabled,
isMissingMandatoryProfileSharing,
isSelectMode,
isSignalConversation,
isSMSOnly,
+ localDeleteWarningShown,
onConversationAccept,
onConversationArchive,
onConversationBlock,
@@ -174,6 +181,7 @@ export const ConversationHeader = memo(function ConversationHeader({
onViewRecentMedia,
onViewUserStories,
outgoingCallButtonStyle,
+ setLocalDeleteWarningShown,
sharedGroupNames,
theme,
}: PropsType): JSX.Element | null {
@@ -223,13 +231,16 @@ export const ConversationHeader = memo(function ConversationHeader({
{hasDeleteMessagesConfirmation && (
{
+ isDeleteSyncSendEnabled={isDeleteSyncSendEnabled}
+ localDeleteWarningShown={localDeleteWarningShown}
+ onDestroyMessages={() => {
setHasDeleteMessagesConfirmation(false);
onConversationDeleteMessages();
}}
onClose={() => {
setHasDeleteMessagesConfirmation(false);
}}
+ setLocalDeleteWarningShown={setLocalDeleteWarningShown}
/>
)}
{hasLeaveGroupConfirmation && (
@@ -923,14 +934,29 @@ function CannotLeaveGroupBecauseYouAreLastAdminAlert({
}
function DeleteMessagesConfirmationDialog({
+ isDeleteSyncSendEnabled,
i18n,
- onDestoryMessages,
+ localDeleteWarningShown,
+ onDestroyMessages,
onClose,
+ setLocalDeleteWarningShown,
}: {
+ isDeleteSyncSendEnabled: boolean;
i18n: LocalizerType;
- onDestoryMessages: () => void;
+ localDeleteWarningShown: boolean;
+ onDestroyMessages: () => void;
onClose: () => void;
+ setLocalDeleteWarningShown: () => void;
}) {
+ if (!localDeleteWarningShown && isDeleteSyncSendEnabled) {
+ return (
+
+ );
+ }
+
return (
();
+
+async function remove(item: DeleteForMeAttributesType): Promise {
+ await removeSyncTaskById(item.syncTaskId);
+ deletes.delete(item.envelopeId);
+}
+
+export async function forMessage(
+ messageAttributes: MessageAttributesType
+): Promise> {
+ const sentTimestamps = getMessageSentTimestampSet(messageAttributes);
+ const deleteValues = Array.from(deletes.values());
+
+ const matchingDeletes = deleteValues.filter(item => {
+ const itemConversation = getConversationFromTarget(item.conversation);
+ const query = getMessageQueryFromTarget(item.message);
+
+ if (!itemConversation) {
+ return false;
+ }
+
+ return doesMessageMatch({
+ conversationId: itemConversation.id,
+ message: messageAttributes,
+ query,
+ sentTimestamps,
+ });
+ });
+
+ if (!matchingDeletes.length) {
+ return [];
+ }
+
+ log.info('Found early DeleteForMe for message');
+ await Promise.all(
+ matchingDeletes.map(async item => {
+ await remove(item);
+ })
+ );
+ return matchingDeletes;
+}
+
+export async function onDelete(item: DeleteForMeAttributesType): Promise {
+ try {
+ const conversation = getConversationFromTarget(item.conversation);
+ const message = getMessageQueryFromTarget(item.message);
+
+ const logId = `DeletesForMe.onDelete(sentAt=${message.sentAt},timestamp=${item.timestamp},envelopeId=${item.envelopeId})`;
+
+ deletes.set(item.envelopeId, item);
+
+ if (!conversation) {
+ log.warn(`${logId}: Conversation not found!`);
+ await remove(item);
+ return;
+ }
+
+ // Do not await, since this a can deadlock the queue
+ drop(
+ conversation.queueJob('DeletesForMe.onDelete', async () => {
+ log.info(`${logId}: Starting...`);
+
+ const result = await deleteMessage(
+ conversation.id,
+ item.message,
+ logId
+ );
+ if (result) {
+ await remove(item);
+ }
+
+ log.info(`${logId}: Complete (result=${result})`);
+ })
+ );
+ } catch (error) {
+ log.error(
+ `DeletesForMe.onDelete(task=${item.syncTaskId},envelopeId=${item.envelopeId}): Error`,
+ Errors.toLogFormat(error)
+ );
+ await remove(item);
+ }
+}
diff --git a/ts/models/conversations.ts b/ts/models/conversations.ts
index de9f33920c67..0f9f4aa97c86 100644
--- a/ts/models/conversations.ts
+++ b/ts/models/conversations.ts
@@ -165,6 +165,12 @@ import OS from '../util/os/osMain';
import { getMessageAuthorText } from '../util/getMessageAuthorText';
import { downscaleOutgoingAttachment } from '../util/attachments';
import { MessageRequestResponseEvent } from '../types/MessageRequestResponseEvent';
+import type { MessageToDelete } from '../textsecure/messageReceiverEvents';
+import {
+ getConversationToDelete,
+ getMessageToDelete,
+} from '../util/deleteForMe';
+import { isEnabled } from '../RemoteConfig';
import { getCallHistorySelector } from '../state/selectors/callHistory';
/* eslint-disable more/no-then */
@@ -186,6 +192,7 @@ const {
getOlderMessagesByConversation,
getMessageMetricsForConversation,
getMessageById,
+ getMostRecentAddressableMessages,
getNewerMessagesByConversation,
} = window.Signal.Data;
@@ -2234,7 +2241,7 @@ export class ConversationModel extends window.Backbone
}
if (isDelete) {
- await this.destroyMessages();
+ await this.destroyMessages({ source: 'message-request' });
void this.updateLastMessage();
}
@@ -4449,7 +4456,6 @@ export class ConversationModel extends window.Backbone
source: providedSource,
fromSync = false,
isInitialSync = false,
- fromGroupUpdate = false,
}: {
reason: string;
receivedAt?: number;
@@ -4458,7 +4464,6 @@ export class ConversationModel extends window.Backbone
source?: string;
fromSync?: boolean;
isInitialSync?: boolean;
- fromGroupUpdate?: boolean;
}
): Promise {
const isSetByOther = providedSource || providedSentAt !== undefined;
@@ -4554,7 +4559,7 @@ export class ConversationModel extends window.Backbone
(isInitialSync && isFromSyncOperation) || isFromMe || isNoteToSelf;
const id = generateGuid();
- const model = new window.Whisper.Message({
+ const attributes = {
id,
conversationId: this.id,
expirationTimerUpdate: {
@@ -4562,7 +4567,6 @@ export class ConversationModel extends window.Backbone
source,
sourceServiceId,
fromSync,
- fromGroupUpdate,
},
flags: Proto.DataMessage.Flags.EXPIRATION_TIMER_UPDATE,
readStatus: shouldBeRead ? ReadStatus.Read : ReadStatus.Unread,
@@ -4570,18 +4574,18 @@ export class ConversationModel extends window.Backbone
received_at: receivedAt ?? incrementMessageCounter(),
seenStatus: shouldBeRead ? SeenStatus.Seen : SeenStatus.Unseen,
sent_at: sentAt,
- type: 'timer-notification',
- // TODO: DESKTOP-722
- } as unknown as MessageAttributesType);
+ timestamp: sentAt,
+ type: 'timer-notification' as const,
+ };
- await window.Signal.Data.saveMessage(model.attributes, {
+ await window.Signal.Data.saveMessage(attributes, {
ourAci: window.textsecure.storage.user.getCheckedAci(),
forceSave: true,
});
const message = window.MessageCache.__DEPRECATED$register(
id,
- model,
+ new window.Whisper.Message(attributes),
'updateExpirationTimer'
);
@@ -4589,7 +4593,7 @@ export class ConversationModel extends window.Backbone
void this.updateUnread();
log.info(
- `${logId}: added a notification received_at=${model.get('received_at')}`
+ `${logId}: added a notification received_at=${message.get('received_at')}`
);
return message;
@@ -4978,7 +4982,29 @@ export class ConversationModel extends window.Backbone
this.contactCollection!.reset(members);
}
- async destroyMessages(): Promise {
+ async destroyMessages({
+ source,
+ }: {
+ source: 'message-request' | 'local-delete-sync' | 'local-delete';
+ }): Promise {
+ const logId = `destroyMessages(${this.idForLogging()})/${source}`;
+
+ log.info(`${logId}: Queuing job on conversation`);
+
+ await this.queueJob(logId, async () => {
+ log.info(`${logId}: Starting...`);
+
+ await this.destroyMessagesInner({ logId, source });
+ });
+ }
+
+ async destroyMessagesInner({
+ logId,
+ source,
+ }: {
+ logId: string;
+ source: 'message-request' | 'local-delete-sync' | 'local-delete';
+ }): Promise {
this.set({
lastMessage: null,
lastMessageAuthor: null,
@@ -4988,9 +5014,50 @@ export class ConversationModel extends window.Backbone
});
window.Signal.Data.updateConversation(this.attributes);
- await window.Signal.Data.removeAllMessagesInConversation(this.id, {
+ if (source === 'local-delete' && isEnabled('desktop.deleteSync.send')) {
+ log.info(`${logId}: Preparing sync message`);
+ const timestamp = Date.now();
+
+ const addressableMessages = await getMostRecentAddressableMessages(
+ this.id
+ );
+ const mostRecentMessages: Array = addressableMessages
+ .map(getMessageToDelete)
+ .filter(isNotNil)
+ .slice(0, 5);
+
+ if (mostRecentMessages.length > 0) {
+ await singleProtoJobQueue.add(
+ MessageSender.getDeleteForMeSyncMessage([
+ {
+ type: 'delete-conversation',
+ conversation: getConversationToDelete(this.attributes),
+ isFullDelete: true,
+ mostRecentMessages,
+ timestamp,
+ },
+ ])
+ );
+ } else {
+ await singleProtoJobQueue.add(
+ MessageSender.getDeleteForMeSyncMessage([
+ {
+ type: 'delete-local-conversation',
+ conversation: getConversationToDelete(this.attributes),
+ timestamp,
+ },
+ ])
+ );
+ }
+
+ log.info(`${logId}: Sync message queue complete`);
+ }
+
+ log.info(`${logId}: Starting delete`);
+ await window.Signal.Data.removeMessagesInConversation(this.id, {
logId: this.idForLogging(),
});
+ log.info(`${logId}: Delete complete`);
}
getTitle(options?: { isShort?: boolean }): string {
diff --git a/ts/models/messages.ts b/ts/models/messages.ts
index 4b24ef30e426..57fe79cc9037 100644
--- a/ts/models/messages.ts
+++ b/ts/models/messages.ts
@@ -68,7 +68,11 @@ import {
} from '../util/whatTypeOfConversation';
import { handleMessageSend } from '../util/handleMessageSend';
import { getSendOptions } from '../util/getSendOptions';
-import { modifyTargetMessage } from '../util/modifyTargetMessage';
+import {
+ modifyTargetMessage,
+ ModifyTargetMessageResult,
+} from '../util/modifyTargetMessage';
+
import {
getMessagePropStatus,
hasErrors,
@@ -2177,7 +2181,6 @@ export class MessageModel extends window.Backbone.Model {
receivedAt: message.get('received_at'),
receivedAtMS: message.get('received_at_ms'),
sentAt: message.get('sent_at'),
- fromGroupUpdate: isGroupUpdate(message.attributes),
reason: idLog,
});
} else if (
@@ -2297,7 +2300,11 @@ export class MessageModel extends window.Backbone.Model {
}
const isFirstRun = true;
- await this.modifyTargetMessage(conversation, isFirstRun);
+ const result = await this.modifyTargetMessage(conversation, isFirstRun);
+ if (result === ModifyTargetMessageResult.Deleted) {
+ confirm();
+ return;
+ }
log.info(`${idLog}: Batching save`);
void this.saveAndNotify(conversation, confirm);
@@ -2320,10 +2327,16 @@ export class MessageModel extends window.Backbone.Model {
// Once the message is saved to DB, we queue attachment downloads
await this.handleAttachmentDownloadsForNewMessage(conversation);
- conversation.trigger('newmessage', this);
-
+ // We'd like to check for deletions before scheduling downloads, but if an edit comes
+ // in, we want to have kicked off attachment downloads for the original message.
const isFirstRun = false;
- await this.modifyTargetMessage(conversation, isFirstRun);
+ const result = await this.modifyTargetMessage(conversation, isFirstRun);
+ if (result === ModifyTargetMessageResult.Deleted) {
+ confirm();
+ return;
+ }
+
+ conversation.trigger('newmessage', this);
if (await shouldReplyNotifyUser(this.attributes, conversation)) {
await conversation.notify(this);
@@ -2377,7 +2390,7 @@ export class MessageModel extends window.Backbone.Model {
async modifyTargetMessage(
conversation: ConversationModel,
isFirstRun: boolean
- ): Promise {
+ ): Promise {
return modifyTargetMessage(this, conversation, {
isFirstRun,
skipEdits: false,
diff --git a/ts/sql/Client.ts b/ts/sql/Client.ts
index b9085d4ae68b..9dabfb0c4682 100644
--- a/ts/sql/Client.ts
+++ b/ts/sql/Client.ts
@@ -115,7 +115,7 @@ const exclusiveInterface: ClientExclusiveInterface = {
flushUpdateConversationBatcher,
shutdown,
- removeAllMessagesInConversation,
+ removeMessagesInConversation,
removeOtherData,
cleanupOrphanedAttachments,
@@ -592,6 +592,21 @@ async function removeMessage(id: string): Promise {
}
}
+export async function deleteAndCleanup(
+ messages: Array,
+ logId: string
+): Promise {
+ const ids = messages.map(message => message.id);
+
+ log.info(`deleteAndCleanup/${logId}: Deleting ${ids.length} messages...`);
+ await channels.removeMessages(ids);
+
+ log.info(`deleteAndCleanup/${logId}: Cleanup for ${ids.length} messages...`);
+ await _cleanupMessages(messages);
+
+ log.info(`deleteAndCleanup/${logId}: Complete`);
+}
+
async function _cleanupMessages(
messages: ReadonlyArray
): Promise {
@@ -664,12 +679,14 @@ async function getConversationRangeCenteredOnMessage(
};
}
-async function removeAllMessagesInConversation(
+async function removeMessagesInConversation(
conversationId: string,
{
logId,
+ receivedAt,
}: {
logId: string;
+ receivedAt?: number;
}
): Promise {
let messages;
@@ -685,6 +702,7 @@ async function removeAllMessagesInConversation(
conversationId,
limit: chunkSize,
includeStoryReplies: true,
+ receivedAt,
storyId: undefined,
});
@@ -692,15 +710,8 @@ async function removeAllMessagesInConversation(
return;
}
- const ids = messages.map(message => message.id);
-
- log.info(`removeAllMessagesInConversation/${logId}: Cleanup...`);
// eslint-disable-next-line no-await-in-loop
- await _cleanupMessages(messages);
-
- log.info(`removeAllMessagesInConversation/${logId}: Deleting...`);
- // eslint-disable-next-line no-await-in-loop
- await channels.removeMessages(ids);
+ await deleteAndCleanup(messages, logId);
} while (messages.length > 0);
}
diff --git a/ts/sql/Interface.ts b/ts/sql/Interface.ts
index 5b4102dcbe08..e975bab57f4b 100644
--- a/ts/sql/Interface.ts
+++ b/ts/sql/Interface.ts
@@ -32,6 +32,7 @@ import type {
import type { CallLinkStateType, CallLinkType } from '../types/CallLink';
import type { AttachmentDownloadJobType } from '../types/AttachmentDownload';
import type { GroupSendEndorsementsData } from '../types/GroupSendEndorsements';
+import type { SyncTaskType } from '../util/syncTasks';
export type AdjacentMessagesByConversationOptionsType = Readonly<{
conversationId: string;
@@ -716,6 +717,15 @@ export type DataInterface = {
ourAci: AciString,
opts: EditedMessageType
) => Promise;
+ getMostRecentAddressableMessages: (
+ conversationId: string,
+ limit?: number
+ ) => Promise>;
+
+ removeSyncTaskById: (id: string) => Promise;
+ saveSyncTasks: (tasks: Array) => Promise;
+ getAllSyncTasks: () => Promise>;
+
getUnprocessedCount: () => Promise;
getUnprocessedByIdsAndIncrementAttempts: (
ids: ReadonlyArray
@@ -1043,10 +1053,11 @@ export type ClientExclusiveInterface = {
// Client-side only
shutdown: () => Promise;
- removeAllMessagesInConversation: (
+ removeMessagesInConversation: (
conversationId: string,
options: {
logId: string;
+ receivedAt?: number;
}
) => Promise;
removeOtherData: () => Promise;
diff --git a/ts/sql/Server.ts b/ts/sql/Server.ts
index 7908282eaf0e..0ad994e9c7ea 100644
--- a/ts/sql/Server.ts
+++ b/ts/sql/Server.ts
@@ -184,6 +184,9 @@ import {
attachmentDownloadJobSchema,
type AttachmentDownloadJobType,
} from '../types/AttachmentDownload';
+import { MAX_SYNC_TASK_ATTEMPTS } from '../util/syncTasks.types';
+import type { SyncTaskType } from '../util/syncTasks';
+import { isMoreRecentThan } from '../util/timestamp';
type ConversationRow = Readonly<{
json: string;
@@ -360,6 +363,11 @@ const dataInterface: ServerInterface = {
getMessagesBetween,
getNearbyMessageFromDeletedSet,
saveEditedMessage,
+ getMostRecentAddressableMessages,
+
+ removeSyncTaskById,
+ saveSyncTasks,
+ getAllSyncTasks,
getUnprocessedCount,
getUnprocessedByIdsAndIncrementAttempts,
@@ -2066,6 +2074,131 @@ function hasUserInitiatedMessages(conversationId: string): boolean {
return exists !== 0;
}
+async function getMostRecentAddressableMessages(
+ conversationId: string,
+ limit = 5
+): Promise> {
+ const db = getReadonlyInstance();
+ return getMostRecentAddressableMessagesSync(db, conversationId, limit);
+}
+
+export function getMostRecentAddressableMessagesSync(
+ db: Database,
+ conversationId: string,
+ limit = 5
+): Array {
+ const [query, parameters] = sql`
+ SELECT json FROM messages
+ INDEXED BY messages_by_date_addressable
+ WHERE
+ conversationId IS ${conversationId} AND
+ isAddressableMessage = 1
+ ORDER BY received_at DESC, sent_at DESC
+ LIMIT ${limit};
+ `;
+
+ const rows = db.prepare(query).all(parameters);
+
+ return rows.map(row => jsonToObject(row.json));
+}
+
+async function removeSyncTaskById(id: string): Promise {
+ const db = await getWritableInstance();
+ removeSyncTaskByIdSync(db, id);
+}
+export function removeSyncTaskByIdSync(db: Database, id: string): void {
+ const [query, parameters] = sql`
+ DELETE FROM syncTasks
+ WHERE id IS ${id}
+ `;
+
+ db.prepare(query).run(parameters);
+}
+async function saveSyncTasks(tasks: Array): Promise {
+ const db = await getWritableInstance();
+ return saveSyncTasksSync(db, tasks);
+}
+export function saveSyncTasksSync(
+ db: Database,
+ tasks: Array
+): void {
+ return db.transaction(() => {
+ tasks.forEach(task => assertSync(saveSyncTaskSync(db, task)));
+ })();
+}
+export function saveSyncTaskSync(db: Database, task: SyncTaskType): void {
+ const { id, attempts, createdAt, data, envelopeId, sentAt, type } = task;
+
+ const [query, parameters] = sql`
+ INSERT INTO syncTasks (
+ id,
+ attempts,
+ createdAt,
+ data,
+ envelopeId,
+ sentAt,
+ type
+ ) VALUES (
+ ${id},
+ ${attempts},
+ ${createdAt},
+ ${objectToJSON(data)},
+ ${envelopeId},
+ ${sentAt},
+ ${type}
+ )
+ `;
+
+ db.prepare(query).run(parameters);
+}
+async function getAllSyncTasks(): Promise> {
+ const db = await getWritableInstance();
+ return getAllSyncTasksSync(db);
+}
+export function getAllSyncTasksSync(db: Database): Array {
+ return db.transaction(() => {
+ const [selectAllQuery] = sql`
+ SELECT * FROM syncTasks ORDER BY createdAt ASC, sentAt ASC, id ASC
+ `;
+
+ const rows = db.prepare(selectAllQuery).all();
+
+ const tasks: Array = rows.map(row => ({
+ ...row,
+ data: jsonToObject(row.data),
+ }));
+
+ const [query] = sql`
+ UPDATE syncTasks
+ SET attempts = attempts + 1
+ `;
+ db.prepare(query).run();
+
+ const [toDelete, toReturn] = partition(tasks, task => {
+ if (
+ isNormalNumber(task.attempts) &&
+ task.attempts < MAX_SYNC_TASK_ATTEMPTS
+ ) {
+ return false;
+ }
+ if (isMoreRecentThan(task.createdAt, durations.WEEK)) {
+ return false;
+ }
+
+ return true;
+ });
+
+ if (toDelete.length > 0) {
+ log.warn(`getAllSyncTasks: Removing ${toDelete.length} expired tasks`);
+ toDelete.forEach(task => {
+ assertSync(removeSyncTaskByIdSync(db, task.id));
+ });
+ }
+
+ return toReturn;
+ })();
+}
+
function saveMessageSync(
db: Database,
data: MessageType,
@@ -6036,6 +6169,7 @@ async function removeAll(): Promise {
DELETE FROM storyDistributionMembers;
DELETE FROM storyDistributions;
DELETE FROM storyReads;
+ DELETE FROM syncTasks;
DELETE FROM unprocessed;
DELETE FROM uninstalled_sticker_packs;
@@ -6078,6 +6212,7 @@ async function removeAllConfiguration(): Promise {
DELETE FROM sendLogRecipients;
DELETE FROM sessions;
DELETE FROM signedPreKeys;
+ DELETE FROM syncTasks;
DELETE FROM unprocessed;
`
);
diff --git a/ts/sql/migrations/1060-addressable-messages-and-sync-tasks.ts b/ts/sql/migrations/1060-addressable-messages-and-sync-tasks.ts
new file mode 100644
index 000000000000..c18dc785d2db
--- /dev/null
+++ b/ts/sql/migrations/1060-addressable-messages-and-sync-tasks.ts
@@ -0,0 +1,56 @@
+// Copyright 2024 Signal Messenger, LLC
+// SPDX-License-Identifier: AGPL-3.0-only
+
+import type { Database } from '@signalapp/better-sqlite3';
+
+import type { LoggerType } from '../../types/Logging';
+
+export const version = 1060;
+
+export function updateToSchemaVersion1060(
+ currentVersion: number,
+ db: Database,
+ logger: LoggerType
+): void {
+ if (currentVersion >= 1060) {
+ return;
+ }
+
+ db.transaction(() => {
+ db.exec(`
+ ALTER TABLE messages
+ ADD COLUMN isAddressableMessage INTEGER
+ GENERATED ALWAYS AS (
+ type IS NULL
+ OR
+ type IN (
+ 'incoming',
+ 'outgoing'
+ )
+ );
+
+ CREATE INDEX messages_by_date_addressable
+ ON messages (
+ conversationId, isAddressableMessage, received_at, sent_at
+ );
+
+ CREATE TABLE syncTasks(
+ id TEXT PRIMARY KEY NOT NULL,
+ attempts INTEGER NOT NULL,
+ createdAt INTEGER NOT NULL,
+ data TEXT NOT NULL,
+ envelopeId TEXT NOT NULL,
+ sentAt INTEGER NOT NULL,
+ type TEXT NOT NULL
+ ) STRICT;
+
+ CREATE INDEX syncTasks_order ON syncTasks (
+ createdAt, sentAt, id
+ )
+ `);
+ })();
+
+ db.pragma('user_version = 1060');
+
+ logger.info('updateToSchemaVersion1060: success!');
+}
diff --git a/ts/sql/migrations/index.ts b/ts/sql/migrations/index.ts
index d4426756b0c3..3b1ce12122c1 100644
--- a/ts/sql/migrations/index.ts
+++ b/ts/sql/migrations/index.ts
@@ -80,10 +80,11 @@ import { updateToSchemaVersion1010 } from './1010-call-links-table';
import { updateToSchemaVersion1020 } from './1020-self-merges';
import { updateToSchemaVersion1030 } from './1030-unblock-event';
import { updateToSchemaVersion1040 } from './1040-undownloaded-backed-up-media';
+import { updateToSchemaVersion1050 } from './1050-group-send-endorsements';
import {
- updateToSchemaVersion1050,
+ updateToSchemaVersion1060,
version as MAX_VERSION,
-} from './1050-group-send-endorsements';
+} from './1060-addressable-messages-and-sync-tasks';
function updateToSchemaVersion1(
currentVersion: number,
@@ -2025,12 +2026,14 @@ export const SCHEMA_VERSIONS = [
updateToSchemaVersion970,
updateToSchemaVersion980,
updateToSchemaVersion990,
+
updateToSchemaVersion1000,
updateToSchemaVersion1010,
updateToSchemaVersion1020,
updateToSchemaVersion1030,
updateToSchemaVersion1040,
updateToSchemaVersion1050,
+ updateToSchemaVersion1060,
];
export class DBVersionFromFutureError extends Error {
diff --git a/ts/state/ducks/conversations.ts b/ts/state/ducks/conversations.ts
index 0a940d8ec855..4521a2119c4c 100644
--- a/ts/state/ducks/conversations.ts
+++ b/ts/state/ducks/conversations.ts
@@ -3,6 +3,7 @@
import type { ThunkAction } from 'redux-thunk';
import {
+ chunk,
difference,
fromPairs,
isEqual,
@@ -184,6 +185,16 @@ import { getConversationIdForLogging } from '../../util/idForLogging';
import { singleProtoJobQueue } from '../../jobs/singleProtoJobQueue';
import MessageSender from '../../textsecure/SendMessage';
import { AttachmentDownloadUrgency } from '../../jobs/AttachmentDownloadManager';
+import type {
+ DeleteForMeSyncEventData,
+ MessageToDelete,
+} from '../../textsecure/messageReceiverEvents';
+import {
+ getConversationToDelete,
+ getMessageToDelete,
+} from '../../util/deleteForMe';
+import { MAX_MESSAGE_COUNT } from '../../util/deleteForMe.types';
+import { isEnabled } from '../../RemoteConfig';
// State
@@ -1703,21 +1714,27 @@ function deleteMessages({
throw new Error('deleteMessage: No conversation found');
}
- await Promise.all(
- messageIds.map(async messageId => {
- const message = await __DEPRECATED$getMessageById(messageId);
- if (!message) {
- throw new Error(`deleteMessages: Message ${messageId} missing!`);
- }
+ const messages = (
+ await Promise.all(
+ messageIds.map(
+ async (messageId): Promise => {
+ const message = await __DEPRECATED$getMessageById(messageId);
+ if (!message) {
+ throw new Error(`deleteMessages: Message ${messageId} missing!`);
+ }
- const messageConversationId = message.get('conversationId');
- if (conversationId !== messageConversationId) {
- throw new Error(
- `deleteMessages: message conversation ${messageConversationId} doesn't match provided conversation ${conversationId}`
- );
- }
- })
- );
+ const messageConversationId = message.get('conversationId');
+ if (conversationId !== messageConversationId) {
+ throw new Error(
+ `deleteMessages: message conversation ${messageConversationId} doesn't match provided conversation ${conversationId}`
+ );
+ }
+
+ return getMessageToDelete(message.attributes);
+ }
+ )
+ )
+ ).filter(isNotNil);
let nearbyMessageId: string | null = null;
@@ -1743,6 +1760,34 @@ function deleteMessages({
if (nearbyMessageId != null) {
dispatch(scrollToMessage(conversationId, nearbyMessageId));
}
+
+ if (!isEnabled('desktop.deleteSync.send')) {
+ return;
+ }
+ if (messages.length === 0) {
+ return;
+ }
+
+ const chunks = chunk(messages, MAX_MESSAGE_COUNT);
+ const conversationToDelete = getConversationToDelete(
+ conversation.attributes
+ );
+ const timestamp = Date.now();
+
+ await Promise.all(
+ chunks.map(async items => {
+ const data: DeleteForMeSyncEventData = items.map(item => ({
+ conversation: conversationToDelete,
+ message: item,
+ timestamp,
+ type: 'delete-message' as const,
+ }));
+
+ await singleProtoJobQueue.add(
+ MessageSender.getDeleteForMeSyncMessage(data)
+ );
+ })
+ );
};
}
@@ -1770,7 +1815,7 @@ function destroyMessages(
undefined
);
- await conversation.destroyMessages();
+ await conversation.destroyMessages({ source: 'local-delete' });
drop(conversation.updateLastMessage());
},
});
diff --git a/ts/state/selectors/items.ts b/ts/state/selectors/items.ts
index d5c1e664e707..88d2367ba26f 100644
--- a/ts/state/selectors/items.ts
+++ b/ts/state/selectors/items.ts
@@ -127,6 +127,13 @@ 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
export const getStoriesEnabled = createSelector(
getItems,
@@ -242,3 +249,9 @@ export const getShowStickerPickerHint = createSelector(
return state.showStickerPickerHint ?? false;
}
);
+
+export const getLocalDeleteWarningShown = createSelector(
+ getItems,
+ (state: ItemsStateType): boolean =>
+ Boolean(state.localDeleteWarningShown ?? false)
+);
diff --git a/ts/state/smart/ConversationHeader.tsx b/ts/state/smart/ConversationHeader.tsx
index 7c34e433fd28..ee3157a3880a 100644
--- a/ts/state/smart/ConversationHeader.tsx
+++ b/ts/state/smart/ConversationHeader.tsx
@@ -40,6 +40,11 @@ import {
} from '../selectors/conversations';
import { getHasStoriesSelector } from '../selectors/stories2';
import { getIntl, getTheme, getUserACI } from '../selectors/user';
+import { useItemsActions } from '../ducks/items';
+import {
+ getDeleteSyncSendEnabled,
+ getLocalDeleteWarningShown,
+} from '../selectors/items';
export type OwnProps = {
id: string;
@@ -146,6 +151,7 @@ export const SmartConversationHeader = memo(function SmartConversationHeader({
const conversationName = useContactNameData(conversation);
strictAssert(conversationName, 'conversationName is required');
+ const isDeleteSyncSendEnabled = useSelector(getDeleteSyncSendEnabled);
const isMissingMandatoryProfileSharing =
getIsMissingRequiredProfileSharing(conversation);
@@ -248,6 +254,11 @@ export const SmartConversationHeader = memo(function SmartConversationHeader({
const minimalConversation = useMinimalConversation(conversation);
+ const localDeleteWarningShown = useSelector(getLocalDeleteWarningShown);
+ const { putItem } = useItemsActions();
+ const setLocalDeleteWarningShown = () =>
+ putItem('localDeleteWarningShown', true);
+
return (
diff --git a/ts/state/smart/DeleteMessagesModal.tsx b/ts/state/smart/DeleteMessagesModal.tsx
index ad7b8ded25c0..3e8dbbf52ee2 100644
--- a/ts/state/smart/DeleteMessagesModal.tsx
+++ b/ts/state/smart/DeleteMessagesModal.tsx
@@ -16,6 +16,12 @@ import {
getLastSelectedMessage,
} from '../selectors/conversations';
import { getDeleteMessagesProps } from '../selectors/globalModals';
+import { useItemsActions } from '../ducks/items';
+import {
+ getLocalDeleteWarningShown,
+ getDeleteSyncSendEnabled,
+} from '../selectors/items';
+import { LocalDeleteWarningModal } from '../../components/LocalDeleteWarningModal';
export const SmartDeleteMessagesModal = memo(
function SmartDeleteMessagesModal() {
@@ -36,6 +42,7 @@ export const SmartDeleteMessagesModal = memo(
[messageIds, isMe]
);
const canDeleteForEveryone = useSelector(getCanDeleteForEveryone);
+ const isDeleteSyncSendEnabled = useSelector(getDeleteSyncSendEnabled);
const lastSelectedMessage = useSelector(getLastSelectedMessage);
const i18n = useSelector(getIntl);
const { toggleDeleteMessagesModal } = useGlobalModalActions();
@@ -69,11 +76,25 @@ export const SmartDeleteMessagesModal = memo(
onDelete?.();
}, [deleteMessagesForEveryone, messageIds, onDelete]);
+ const localDeleteWarningShown = useSelector(getLocalDeleteWarningShown);
+ const { putItem } = useItemsActions();
+ if (!localDeleteWarningShown && isDeleteSyncSendEnabled) {
+ return (
+ {
+ putItem('localDeleteWarningShown', true);
+ }}
+ />
+ );
+ }
+
return (
{
});
afterEach(async () => {
- await window.Signal.Data.removeAllMessagesInConversation(convo.id, {
+ await window.Signal.Data.removeMessagesInConversation(convo.id, {
logId: ourServiceIdWithKeyChange,
});
await window.Signal.Data.removeConversation(convo.id);
@@ -104,7 +104,7 @@ describe('KeyChangeListener', () => {
});
afterEach(async () => {
- await window.Signal.Data.removeAllMessagesInConversation(groupConvo.id, {
+ await window.Signal.Data.removeMessagesInConversation(groupConvo.id, {
logId: ourServiceIdWithKeyChange,
});
await window.Signal.Data.removeConversation(groupConvo.id);
diff --git a/ts/test-node/sql/migration_1060_test.ts b/ts/test-node/sql/migration_1060_test.ts
new file mode 100644
index 000000000000..babcdb2414ee
--- /dev/null
+++ b/ts/test-node/sql/migration_1060_test.ts
@@ -0,0 +1,302 @@
+// Copyright 2024 Signal Messenger, LLC
+// SPDX-License-Identifier: AGPL-3.0-only
+
+import { assert } from 'chai';
+import type { Database } from '@signalapp/better-sqlite3';
+import SQL from '@signalapp/better-sqlite3';
+import { v4 as generateGuid } from 'uuid';
+
+import {
+ getAllSyncTasksSync,
+ getMostRecentAddressableMessagesSync,
+ removeSyncTaskByIdSync,
+ saveSyncTasksSync,
+} from '../../sql/Server';
+import { insertData, updateToVersion } from './helpers';
+import { MAX_SYNC_TASK_ATTEMPTS } from '../../util/syncTasks.types';
+import { WEEK } from '../../util/durations';
+
+import type { MessageAttributesType } from '../../model-types';
+import type { SyncTaskType } from '../../util/syncTasks';
+
+/* eslint-disable camelcase */
+
+function generateMessage(json: MessageAttributesType) {
+ const { conversationId, received_at, sent_at, type } = json;
+
+ return {
+ conversationId,
+ json,
+ received_at,
+ sent_at,
+ type,
+ };
+}
+
+describe('SQL/updateToSchemaVersion1060', () => {
+ let db: Database;
+ beforeEach(() => {
+ db = new SQL(':memory:');
+ updateToVersion(db, 1060);
+ });
+
+ afterEach(() => {
+ db.close();
+ });
+
+ describe('Addressable Messages', () => {
+ describe('Storing of new attachment jobs', () => {
+ it('returns only incoming/outgoing messages', () => {
+ const conversationId = generateGuid();
+ const otherConversationId = generateGuid();
+
+ insertData(db, 'messages', [
+ generateMessage({
+ id: '1',
+ conversationId,
+ type: 'incoming',
+ received_at: 1,
+ sent_at: 1,
+ timestamp: 1,
+ }),
+ generateMessage({
+ id: '2',
+ conversationId,
+ type: 'story',
+ received_at: 2,
+ sent_at: 2,
+ timestamp: 2,
+ }),
+ generateMessage({
+ id: '3',
+ conversationId,
+ type: 'outgoing',
+ received_at: 3,
+ sent_at: 3,
+ timestamp: 3,
+ }),
+ generateMessage({
+ id: '4',
+ conversationId,
+ type: 'group-v1-migration',
+ received_at: 4,
+ sent_at: 4,
+ timestamp: 4,
+ }),
+ generateMessage({
+ id: '5',
+ conversationId,
+ type: 'group-v2-change',
+ received_at: 5,
+ sent_at: 5,
+ timestamp: 5,
+ }),
+ generateMessage({
+ id: '6',
+ conversationId,
+ type: 'incoming',
+ received_at: 6,
+ sent_at: 6,
+ timestamp: 6,
+ }),
+ generateMessage({
+ id: '7',
+ conversationId,
+ type: 'profile-change',
+ received_at: 7,
+ sent_at: 7,
+ timestamp: 7,
+ }),
+ generateMessage({
+ id: '8',
+ conversationId: otherConversationId,
+ type: 'incoming',
+ received_at: 8,
+ sent_at: 8,
+ timestamp: 8,
+ }),
+ ]);
+
+ const messages = getMostRecentAddressableMessagesSync(
+ db,
+ conversationId
+ );
+
+ assert.lengthOf(messages, 3);
+ assert.deepEqual(messages, [
+ {
+ id: '6',
+ conversationId,
+ type: 'incoming',
+ received_at: 6,
+ sent_at: 6,
+ timestamp: 6,
+ },
+ {
+ id: '3',
+ conversationId,
+ type: 'outgoing',
+ received_at: 3,
+ sent_at: 3,
+ timestamp: 3,
+ },
+ {
+ id: '1',
+ conversationId,
+ type: 'incoming',
+ received_at: 1,
+ sent_at: 1,
+ timestamp: 1,
+ },
+ ]);
+ });
+
+ it('ensures that index is used for getMostRecentAddressableMessagesSync, with storyId', () => {
+ const { detail } = db
+ .prepare(
+ `
+ EXPLAIN QUERY PLAN
+ SELECT json FROM messages
+ INDEXED BY messages_by_date_addressable
+ WHERE
+ conversationId IS 'not-important' AND
+ isAddressableMessage = 1
+ ORDER BY received_at DESC, sent_at DESC
+ LIMIT 5;
+ `
+ )
+ .get();
+
+ assert.notInclude(detail, 'B-TREE');
+ assert.notInclude(detail, 'SCAN');
+ assert.include(
+ detail,
+ 'SEARCH messages USING INDEX messages_by_date_addressable (conversationId=? AND isAddressableMessage=?)'
+ );
+ });
+ });
+ });
+
+ describe('Sync Tasks', () => {
+ it('creates tasks in bulk, and fetches all', () => {
+ const now = Date.now();
+ const expected: Array = [
+ {
+ id: generateGuid(),
+ attempts: 1,
+ createdAt: now + 1,
+ data: {
+ jsonField: 'one',
+ data: 1,
+ },
+ envelopeId: 'envelope-id-1',
+ sentAt: 1,
+ type: 'delete-conversation',
+ },
+ {
+ id: generateGuid(),
+ attempts: 2,
+ createdAt: now + 2,
+ data: {
+ jsonField: 'two',
+ data: 2,
+ },
+ envelopeId: 'envelope-id-2',
+ sentAt: 2,
+ type: 'delete-conversation',
+ },
+ {
+ id: generateGuid(),
+ attempts: 3,
+ createdAt: now + 3,
+ data: {
+ jsonField: 'three',
+ data: 3,
+ },
+ envelopeId: 'envelope-id-3',
+ sentAt: 3,
+ type: 'delete-conversation',
+ },
+ ];
+
+ saveSyncTasksSync(db, expected);
+
+ const actual = getAllSyncTasksSync(db);
+ assert.deepEqual(expected, actual, 'before delete');
+
+ removeSyncTaskByIdSync(db, expected[1].id);
+
+ const actualAfterDelete = getAllSyncTasksSync(db);
+ assert.deepEqual(
+ [
+ { ...expected[0], attempts: 2 },
+ { ...expected[2], attempts: 4 },
+ ],
+ actualAfterDelete,
+ 'after delete'
+ );
+ });
+
+ it('getAllSyncTasksSync expired tasks', () => {
+ const now = Date.now();
+ const twoWeeksAgo = now - WEEK * 2;
+ const expected: Array = [
+ {
+ id: generateGuid(),
+ attempts: MAX_SYNC_TASK_ATTEMPTS,
+ createdAt: twoWeeksAgo,
+ data: {
+ jsonField: 'expired',
+ data: 1,
+ },
+ envelopeId: 'envelope-id-1',
+ sentAt: 1,
+ type: 'delete-conversation',
+ },
+ {
+ id: generateGuid(),
+ attempts: 2,
+ createdAt: twoWeeksAgo,
+ data: {
+ jsonField: 'old-but-few-attemts',
+ data: 2,
+ },
+ envelopeId: 'envelope-id-2',
+ sentAt: 2,
+ type: 'delete-conversation',
+ },
+ {
+ id: generateGuid(),
+ attempts: MAX_SYNC_TASK_ATTEMPTS * 2,
+ createdAt: now,
+ data: {
+ jsonField: 'new-but-many-attempts',
+ data: 3,
+ },
+ envelopeId: 'envelope-id-3',
+ sentAt: 3,
+ type: 'delete-conversation',
+ },
+ {
+ id: generateGuid(),
+ attempts: MAX_SYNC_TASK_ATTEMPTS - 1,
+ createdAt: now + 1,
+ data: {
+ jsonField: 'new-and-fresh',
+ data: 4,
+ },
+ envelopeId: 'envelope-id-4',
+ sentAt: 4,
+ type: 'delete-conversation',
+ },
+ ];
+
+ saveSyncTasksSync(db, expected);
+
+ const actual = getAllSyncTasksSync(db);
+
+ assert.lengthOf(actual, 3);
+ assert.deepEqual([expected[1], expected[2], expected[3]], actual);
+ });
+ });
+});
diff --git a/ts/textsecure/MessageReceiver.ts b/ts/textsecure/MessageReceiver.ts
index d9cf0981d0c0..d5c780ef55f3 100644
--- a/ts/textsecure/MessageReceiver.ts
+++ b/ts/textsecure/MessageReceiver.ts
@@ -130,6 +130,13 @@ import {
StoryRecipientUpdateEvent,
CallLogEventSyncEvent,
CallLinkUpdateSyncEvent,
+ DeleteForMeSyncEvent,
+} from './messageReceiverEvents';
+import type {
+ MessageToDelete,
+ DeleteForMeSyncEventData,
+ DeleteForMeSyncTarget,
+ ConversationToDelete,
} from './messageReceiverEvents';
import * as log from '../logging/log';
import * as durations from '../util/durations';
@@ -686,6 +693,11 @@ export default class MessageReceiver
handler: (ev: CallLogEventSyncEvent) => void
): void;
+ public override addEventListener(
+ name: 'deleteForMeSync',
+ handler: (ev: DeleteForMeSyncEvent) => void
+ ): void;
+
public override addEventListener(name: string, handler: EventHandler): void {
return super.addEventListener(name, handler);
}
@@ -3165,6 +3177,9 @@ export default class MessageReceiver
if (syncMessage.callLogEvent) {
return this.handleCallLogEvent(envelope, syncMessage.callLogEvent);
}
+ if (syncMessage.deleteForMe) {
+ return this.handleDeleteForMeSync(envelope, syncMessage.deleteForMe);
+ }
this.removeFromCache(envelope);
const envelopeId = getEnvelopeId(envelope);
@@ -3615,6 +3630,118 @@ export default class MessageReceiver
log.info('handleCallLogEvent: finished');
}
+ private async handleDeleteForMeSync(
+ envelope: ProcessedEnvelope,
+ deleteSync: Proto.SyncMessage.IDeleteForMe
+ ): Promise {
+ const logId = getEnvelopeId(envelope);
+ log.info('MessageReceiver.handleDeleteForMeSync', logId);
+
+ logUnexpectedUrgentValue(envelope, 'deleteForMeSync');
+
+ const { timestamp } = envelope;
+ let eventData: DeleteForMeSyncEventData = [];
+
+ try {
+ if (deleteSync.messageDeletes?.length) {
+ const messageDeletes: Array =
+ deleteSync.messageDeletes
+ .flatMap((item): Array | undefined => {
+ const messages = item.messages
+ ?.map(message => processMessageToDelete(message, logId))
+ .filter(isNotNil);
+ const conversation = item.conversation
+ ? processConversationToDelete(item.conversation, logId)
+ : undefined;
+
+ if (messages?.length && conversation) {
+ // We want each message in its own task
+ return messages.map(innerItem => {
+ return {
+ type: 'delete-message' as const,
+ message: innerItem,
+ conversation,
+ timestamp,
+ };
+ });
+ }
+
+ return undefined;
+ })
+ .filter(isNotNil);
+
+ eventData = eventData.concat(messageDeletes);
+ }
+ if (deleteSync.conversationDeletes?.length) {
+ const conversationDeletes: Array =
+ deleteSync.conversationDeletes
+ .map(item => {
+ const mostRecentMessages = item.mostRecentMessages
+ ?.map(message => processMessageToDelete(message, logId))
+ .filter(isNotNil);
+ const conversation = item.conversation
+ ? processConversationToDelete(item.conversation, logId)
+ : undefined;
+
+ if (mostRecentMessages?.length && conversation) {
+ return {
+ type: 'delete-conversation' as const,
+ conversation,
+ isFullDelete: Boolean(item.isFullDelete),
+ mostRecentMessages,
+ timestamp,
+ };
+ }
+
+ return undefined;
+ })
+ .filter(isNotNil);
+
+ eventData = eventData.concat(conversationDeletes);
+ }
+ if (deleteSync.localOnlyConversationDeletes?.length) {
+ const localOnlyConversationDeletes: Array =
+ deleteSync.localOnlyConversationDeletes
+ .map(item => {
+ const conversation = item.conversation
+ ? processConversationToDelete(item.conversation, logId)
+ : undefined;
+
+ if (conversation) {
+ return {
+ type: 'delete-local-conversation' as const,
+ conversation,
+ timestamp,
+ };
+ }
+
+ return undefined;
+ })
+ .filter(isNotNil);
+
+ eventData = eventData.concat(localOnlyConversationDeletes);
+ }
+ if (!eventData.length) {
+ throw new Error(`${logId}: Nothing found in sync message!`);
+ }
+ } catch (error: unknown) {
+ this.removeFromCache(envelope);
+
+ throw error;
+ }
+
+ const deleteSyncEventSync = new DeleteForMeSyncEvent(
+ eventData,
+ timestamp,
+ envelope.id,
+ this.removeFromCache.bind(this, envelope)
+ );
+
+ await this.dispatchAndWait(logId, deleteSyncEventSync);
+
+ log.info('handleDeleteForMeSync: finished');
+ }
+
private async handleContacts(
envelope: ProcessedEnvelope,
contactSyncProto: Proto.SyncMessage.IContacts
@@ -3820,3 +3947,70 @@ function envelopeTypeToCiphertextType(type: number | undefined): number {
throw new Error(`envelopeTypeToCiphertextType: Unknown type ${type}`);
}
+
+function processMessageToDelete(
+ target: Proto.SyncMessage.DeleteForMe.IAddressableMessage,
+ logId: string
+): MessageToDelete | undefined {
+ const sentAt = target.sentTimestamp?.toNumber();
+ if (!isNumber(sentAt)) {
+ log.warn(
+ `${logId}/processMessageToDelete: No sentTimestamp found! Dropping AddressableMessage.`
+ );
+ return undefined;
+ }
+
+ if (target.authorAci) {
+ return {
+ type: 'aci' as const,
+ authorAci: normalizeAci(
+ target.authorAci,
+ `${logId}/processMessageToDelete`
+ ),
+ sentAt,
+ };
+ }
+ if (target.authorE164) {
+ return {
+ type: 'e164' as const,
+ authorE164: target.authorE164,
+ sentAt,
+ };
+ }
+
+ log.warn(
+ `${logId}/processMessageToDelete: No author field found! Dropping AddressableMessage.`
+ );
+ return undefined;
+}
+
+function processConversationToDelete(
+ target: Proto.SyncMessage.DeleteForMe.IConversationIdentifier,
+ logId: string
+): ConversationToDelete | undefined {
+ const { threadAci, threadGroupId, threadE164 } = target;
+
+ if (threadAci) {
+ return {
+ type: 'aci' as const,
+ aci: normalizeAci(threadAci, `${logId}/threadAci`),
+ };
+ }
+ if (threadGroupId) {
+ return {
+ type: 'group' as const,
+ groupId: Buffer.from(threadGroupId).toString('base64'),
+ };
+ }
+ if (threadE164) {
+ return {
+ type: 'e164' as const,
+ e164: threadE164,
+ };
+ }
+
+ log.warn(
+ `${logId}/processConversationToDelete: No identifier field found! Dropping ConversationIdentifier.`
+ );
+ return undefined;
+}
diff --git a/ts/textsecure/SendMessage.ts b/ts/textsecure/SendMessage.ts
index d3e0e02323f7..c914986942ff 100644
--- a/ts/textsecure/SendMessage.ts
+++ b/ts/textsecure/SendMessage.ts
@@ -82,6 +82,13 @@ import {
} from '../types/EmbeddedContact';
import { missingCaseError } from '../util/missingCaseError';
import { drop } from '../util/drop';
+import type {
+ ConversationToDelete,
+ DeleteForMeSyncEventData,
+ DeleteMessageSyncTarget,
+ MessageToDelete,
+} from './messageReceiverEvents';
+import { getConversationFromTarget } from '../util/deleteForMe';
export type SendMetadataType = {
[serviceId: ServiceIdString]: {
@@ -1475,6 +1482,91 @@ export default class MessageSender {
};
}
+ static getDeleteForMeSyncMessage(
+ data: DeleteForMeSyncEventData
+ ): SingleProtoJobData {
+ const myAci = window.textsecure.storage.user.getCheckedAci();
+
+ const deleteForMe = new Proto.SyncMessage.DeleteForMe();
+ const messageDeletes: Map<
+ string,
+ Array
+ > = new Map();
+
+ data.forEach(item => {
+ if (item.type === 'delete-message') {
+ const conversation = getConversationFromTarget(item.conversation);
+ if (!conversation) {
+ throw new Error(
+ 'getDeleteForMeSyncMessage: Failed to find conversation for delete-message'
+ );
+ }
+ const existing = messageDeletes.get(conversation.id);
+ if (existing) {
+ existing.push(item);
+ } else {
+ messageDeletes.set(conversation.id, [item]);
+ }
+ } else if (item.type === 'delete-conversation') {
+ const mostRecentMessages =
+ item.mostRecentMessages.map(toAddressableMessage);
+ const conversation = toConversationIdentifier(item.conversation);
+
+ deleteForMe.conversationDeletes = deleteForMe.conversationDeletes || [];
+ deleteForMe.conversationDeletes.push({
+ mostRecentMessages,
+ conversation,
+ isFullDelete: true,
+ });
+ } else if (item.type === 'delete-local-conversation') {
+ const conversation = toConversationIdentifier(item.conversation);
+
+ deleteForMe.localOnlyConversationDeletes =
+ deleteForMe.localOnlyConversationDeletes || [];
+ deleteForMe.localOnlyConversationDeletes.push({
+ conversation,
+ });
+ } else {
+ throw missingCaseError(item);
+ }
+ });
+
+ if (messageDeletes.size > 0) {
+ for (const items of messageDeletes.values()) {
+ const first = items[0];
+ if (!first) {
+ throw new Error('Failed to fetch first from items');
+ }
+ const messages = items.map(item => toAddressableMessage(item.message));
+ const conversation = toConversationIdentifier(first.conversation);
+
+ deleteForMe.messageDeletes = deleteForMe.messageDeletes || [];
+ deleteForMe.messageDeletes.push({
+ messages,
+ conversation,
+ });
+ }
+ }
+
+ const syncMessage = this.createSyncMessage();
+ syncMessage.deleteForMe = deleteForMe;
+ const contentMessage = new Proto.Content();
+ contentMessage.syncMessage = syncMessage;
+
+ const { ContentHint } = Proto.UnidentifiedSenderMessage.Message;
+
+ return {
+ contentHint: ContentHint.RESENDABLE,
+ serviceId: myAci,
+ isSyncMessage: true,
+ protoBase64: Bytes.toBase64(
+ Proto.Content.encode(contentMessage).finish()
+ ),
+ type: 'deleteForMeSync',
+ urgent: false,
+ };
+ }
+
async syncReadMessages(
reads: ReadonlyArray<{
senderAci?: AciString;
@@ -2253,3 +2345,37 @@ export default class MessageSender {
return this.server.sendChallengeResponse(challengeResponse);
}
}
+
+// Helpers
+
+function toAddressableMessage(message: MessageToDelete) {
+ const targetMessage = new Proto.SyncMessage.DeleteForMe.AddressableMessage();
+ targetMessage.sentTimestamp = Long.fromNumber(message.sentAt);
+
+ if (message.type === 'aci') {
+ targetMessage.authorAci = message.authorAci;
+ } else if (message.type === 'e164') {
+ targetMessage.authorE164 = message.authorE164;
+ } else {
+ throw missingCaseError(message);
+ }
+
+ return targetMessage;
+}
+
+function toConversationIdentifier(conversation: ConversationToDelete) {
+ const targetConversation =
+ new Proto.SyncMessage.DeleteForMe.ConversationIdentifier();
+
+ if (conversation.type === 'aci') {
+ targetConversation.threadAci = conversation.aci;
+ } else if (conversation.type === 'group') {
+ targetConversation.threadGroupId = Bytes.fromBase64(conversation.groupId);
+ } else if (conversation.type === 'e164') {
+ targetConversation.threadE164 = conversation.e164;
+ } else {
+ throw missingCaseError(conversation);
+ }
+
+ return targetConversation;
+}
diff --git a/ts/textsecure/messageReceiverEvents.ts b/ts/textsecure/messageReceiverEvents.ts
index dbfec138515a..c00068508c95 100644
--- a/ts/textsecure/messageReceiverEvents.ts
+++ b/ts/textsecure/messageReceiverEvents.ts
@@ -3,6 +3,7 @@
/* eslint-disable max-classes-per-file */
import type { PublicKey } from '@signalapp/libsignal-client';
+import { z } from 'zod';
import type { SignalService as Proto } from '../protobuf';
import type { ServiceIdString, AciString } from '../types/ServiceId';
@@ -15,6 +16,7 @@ import type {
import type { ContactDetailsWithAvatar } from './ContactsParser';
import type { CallEventDetails, CallLogEvent } from '../types/CallDisposition';
import type { CallLinkUpdateSyncType } from '../types/CallLink';
+import { isAciString } from '../util/isAciString';
export class EmptyEvent extends Event {
constructor() {
@@ -456,6 +458,78 @@ export class CallLinkUpdateSyncEvent extends ConfirmableEvent {
}
}
+const messageToDeleteSchema = z.union([
+ z.object({
+ type: z.literal('aci').readonly(),
+ authorAci: z.string().refine(isAciString),
+ sentAt: z.number(),
+ }),
+ z.object({
+ type: z.literal('e164').readonly(),
+ authorE164: z.string(),
+ sentAt: z.number(),
+ }),
+]);
+
+export type MessageToDelete = z.infer;
+
+const conversationToDeleteSchema = z.union([
+ z.object({
+ type: z.literal('group').readonly(),
+ groupId: z.string(),
+ }),
+ z.object({
+ type: z.literal('aci').readonly(),
+ aci: z.string().refine(isAciString),
+ }),
+ z.object({
+ type: z.literal('e164').readonly(),
+ e164: z.string(),
+ }),
+]);
+
+export type ConversationToDelete = z.infer;
+
+export const deleteMessageSchema = z.object({
+ type: z.literal('delete-message').readonly(),
+ conversation: conversationToDeleteSchema,
+ message: messageToDeleteSchema,
+ timestamp: z.number(),
+});
+export type DeleteMessageSyncTarget = z.infer;
+export const deleteConversationSchema = z.object({
+ type: z.literal('delete-conversation').readonly(),
+ conversation: conversationToDeleteSchema,
+ mostRecentMessages: z.array(messageToDeleteSchema),
+ isFullDelete: z.boolean(),
+ timestamp: z.number(),
+});
+export const deleteLocalConversationSchema = z.object({
+ type: z.literal('delete-local-conversation').readonly(),
+ conversation: conversationToDeleteSchema,
+ timestamp: z.number(),
+});
+export const deleteForMeSyncTargetSchema = z.union([
+ deleteMessageSchema,
+ deleteConversationSchema,
+ deleteLocalConversationSchema,
+]);
+
+export type DeleteForMeSyncTarget = z.infer;
+
+export type DeleteForMeSyncEventData = ReadonlyArray;
+
+export class DeleteForMeSyncEvent extends ConfirmableEvent {
+ constructor(
+ public readonly deleteForMeSync: DeleteForMeSyncEventData,
+ public readonly timestamp: number,
+ public readonly envelopeId: string,
+ confirm: ConfirmCallback
+ ) {
+ super('deleteForMeSync', confirm);
+ }
+}
+
export type CallLogEventSyncEventData = Readonly<{
event: CallLogEvent;
timestamp: number;
diff --git a/ts/types/Storage.d.ts b/ts/types/Storage.d.ts
index 14079cede7c1..14ca43d69ad6 100644
--- a/ts/types/Storage.d.ts
+++ b/ts/types/Storage.d.ts
@@ -79,6 +79,7 @@ export type StorageAccessType = {
lastAttemptedToRefreshProfilesAt: number;
lastResortKeyUpdateTime: number;
lastResortKeyUpdateTimePNI: number;
+ localDeleteWarningShown: boolean;
masterKey: string;
masterKeyLastRequestTime: number;
maxPreKeyId: number;
diff --git a/ts/types/StorageUIKeys.ts b/ts/types/StorageUIKeys.ts
index 7b3b3532e2a9..018e06edce9b 100644
--- a/ts/types/StorageUIKeys.ts
+++ b/ts/types/StorageUIKeys.ts
@@ -23,6 +23,7 @@ export const STORAGE_UI_KEYS: ReadonlyArray = [
'hasCompletedSafetyNumberOnboarding',
'hasCompletedUsernameLinkOnboarding',
'hide-menu-bar',
+ 'localDeleteWarningShown',
'incoming-call-notification',
'navTabsCollapsed',
'notification-draw-attention',
diff --git a/ts/util/deleteForMe.ts b/ts/util/deleteForMe.ts
new file mode 100644
index 000000000000..65c78d4f3ea5
--- /dev/null
+++ b/ts/util/deleteForMe.ts
@@ -0,0 +1,261 @@
+// Copyright 2020 Signal Messenger, LLC
+// SPDX-License-Identifier: AGPL-3.0-only
+
+import { last, sortBy } from 'lodash';
+
+import * as log from '../logging/log';
+import { isAciString } from './isAciString';
+import { isGroup, isGroupV2 } from './whatTypeOfConversation';
+import {
+ getConversationIdForLogging,
+ getMessageIdForLogging,
+} from './idForLogging';
+import { missingCaseError } from './missingCaseError';
+import { getMessageSentTimestampSet } from './getMessageSentTimestampSet';
+import { getAuthor } from '../messages/helpers';
+import dataInterface, { deleteAndCleanup } from '../sql/Client';
+
+import type {
+ ConversationAttributesType,
+ MessageAttributesType,
+} from '../model-types';
+import type { ConversationModel } from '../models/conversations';
+import type {
+ ConversationToDelete,
+ MessageToDelete,
+} from '../textsecure/messageReceiverEvents';
+import type { AciString } from '../types/ServiceId';
+
+const {
+ getMessagesBySentAt,
+ getMostRecentAddressableMessages,
+ removeMessagesInConversation,
+} = dataInterface;
+
+export function doesMessageMatch({
+ conversationId,
+ message,
+ query,
+ sentTimestamps,
+}: {
+ message: MessageAttributesType;
+ conversationId: string;
+ query: MessageQuery;
+ sentTimestamps: ReadonlySet;
+}): boolean {
+ const author = getAuthor(message);
+
+ const conversationMatches = message.conversationId === conversationId;
+ const aciMatches =
+ query.authorAci && author?.attributes.serviceId === query.authorAci;
+ const e164Matches =
+ query.authorE164 && author?.attributes.e164 === query.authorE164;
+ const timestampMatches = sentTimestamps.has(query.sentAt);
+
+ return Boolean(
+ conversationMatches && timestampMatches && (aciMatches || e164Matches)
+ );
+}
+
+export async function findMatchingMessage(
+ conversationId: string,
+ query: MessageQuery
+): Promise {
+ const sentAtMatches = await getMessagesBySentAt(query.sentAt);
+
+ if (!sentAtMatches.length) {
+ return undefined;
+ }
+
+ return sentAtMatches.find(message => {
+ const sentTimestamps = getMessageSentTimestampSet(message);
+ return doesMessageMatch({
+ conversationId,
+ message,
+ query,
+ sentTimestamps,
+ });
+ });
+}
+
+export async function deleteMessage(
+ conversationId: string,
+ targetMessage: MessageToDelete,
+ logId: string
+): Promise {
+ const query = getMessageQueryFromTarget(targetMessage);
+ const found = await findMatchingMessage(conversationId, query);
+
+ if (!found) {
+ log.warn(`${logId}: Couldn't find matching message`);
+ return false;
+ }
+
+ await deleteAndCleanup([found], logId);
+
+ return true;
+}
+
+export async function deleteConversation(
+ conversation: ConversationModel,
+ mostRecentMessages: Array,
+ isFullDelete: boolean,
+ logId: string
+): Promise {
+ const queries = mostRecentMessages.map(getMessageQueryFromTarget);
+ const found = await Promise.all(
+ queries.map(query => findMatchingMessage(conversation.id, query))
+ );
+
+ const sorted = sortBy(found, 'received_at');
+ const newestMessage = last(sorted);
+ if (newestMessage) {
+ const { received_at: receivedAt } = newestMessage;
+
+ await removeMessagesInConversation(conversation.id, {
+ receivedAt,
+ logId: `${logId}(receivedAt=${receivedAt})`,
+ });
+ }
+
+ if (!newestMessage) {
+ log.warn(`${logId}: Found no target messages for delete`);
+ }
+
+ if (isFullDelete) {
+ log.info(`${logId}: isFullDelete=true, proceeding to local-only delete`);
+ return deleteLocalOnlyConversation(conversation, logId);
+ }
+
+ return true;
+}
+
+export async function deleteLocalOnlyConversation(
+ conversation: ConversationModel,
+ logId: string
+): Promise {
+ const limit = 1;
+ const messages = await getMostRecentAddressableMessages(
+ conversation.id,
+ limit
+ );
+ if (messages.length > 0) {
+ log.warn(
+ `${logId}: Attempted local-only delete but found an addressable message`
+ );
+ return false;
+ }
+
+ // This will delete all messages and remove the conversation from the left pane.
+ // We need to call destroyMessagesInner, since we're already in conversation.queueJob()
+ await conversation.destroyMessagesInner({
+ logId,
+ source: 'local-delete-sync',
+ });
+
+ return true;
+}
+
+export function getConversationFromTarget(
+ targetConversation: ConversationToDelete
+): ConversationModel | undefined {
+ const { type } = targetConversation;
+
+ if (type === 'aci') {
+ return window.ConversationController.get(targetConversation.aci);
+ }
+ if (type === 'group') {
+ return window.ConversationController.get(targetConversation.groupId);
+ }
+ if (type === 'e164') {
+ return window.ConversationController.get(targetConversation.e164);
+ }
+
+ throw missingCaseError(type);
+}
+
+type MessageQuery = {
+ sentAt: number;
+ authorAci?: AciString;
+ authorE164?: string;
+};
+
+export function getMessageQueryFromTarget(
+ targetMessage: MessageToDelete
+): MessageQuery {
+ const { type, sentAt } = targetMessage;
+
+ if (type === 'aci') {
+ if (!isAciString(targetMessage.authorAci)) {
+ throw new Error('Provided authorAci was not an ACI!');
+ }
+ return { sentAt, authorAci: targetMessage.authorAci };
+ }
+ if (type === 'e164') {
+ return { sentAt, authorE164: targetMessage.authorE164 };
+ }
+
+ throw missingCaseError(type);
+}
+
+export function getConversationToDelete(
+ attributes: ConversationAttributesType
+): ConversationToDelete {
+ const { groupId, serviceId: aci, e164 } = attributes;
+ const idForLogging = getConversationIdForLogging(attributes);
+ const logId = `getConversationToDelete(${idForLogging})`;
+
+ if (isGroupV2(attributes) && groupId) {
+ return {
+ type: 'group',
+ groupId,
+ };
+ }
+ if (isGroup(attributes)) {
+ throw new Error(`${logId}: is a group, but not groupV2 or no groupId!`);
+ }
+ if (aci && isAciString(aci)) {
+ return {
+ type: 'aci',
+ aci,
+ };
+ }
+ if (e164) {
+ return {
+ type: 'e164',
+ e164,
+ };
+ }
+
+ throw new Error(`${logId}: No valid identifier found!`);
+}
+
+export function getMessageToDelete(
+ attributes: MessageAttributesType
+): MessageToDelete | undefined {
+ const logId = `getMessageToDelete(${getMessageIdForLogging(attributes)})`;
+ const { sent_at: sentAt } = attributes;
+
+ const author = getAuthor(attributes);
+ const authorAci = author?.get('serviceId');
+ const authorE164 = author?.get('e164');
+
+ if (authorAci && isAciString(authorAci)) {
+ return {
+ type: 'aci' as const,
+ authorAci,
+ sentAt,
+ };
+ }
+ if (authorE164) {
+ return {
+ type: 'e164' as const,
+ authorE164,
+ sentAt,
+ };
+ }
+
+ log.warn(`${logId}: Message was missing source ACI/e164`);
+
+ return undefined;
+}
diff --git a/ts/util/deleteForMe.types.ts b/ts/util/deleteForMe.types.ts
new file mode 100644
index 000000000000..32898d672048
--- /dev/null
+++ b/ts/util/deleteForMe.types.ts
@@ -0,0 +1,4 @@
+// Copyright 2020 Signal Messenger, LLC
+// SPDX-License-Identifier: AGPL-3.0-only
+
+export const MAX_MESSAGE_COUNT = 500;
diff --git a/ts/util/handleMessageSend.ts b/ts/util/handleMessageSend.ts
index e9554fb82a49..bf027b700e6c 100644
--- a/ts/util/handleMessageSend.ts
+++ b/ts/util/handleMessageSend.ts
@@ -55,6 +55,7 @@ export const sendTypesEnum = z.enum([
'pniIdentitySync',
// Syncs, default non-urgent
+ 'deleteForMeSync',
'fetchLatestManifestSync',
'fetchLocalProfileSync',
'messageRequestSync',
diff --git a/ts/util/modifyTargetMessage.ts b/ts/util/modifyTargetMessage.ts
index 36724137ea9e..f2f63b4fca49 100644
--- a/ts/util/modifyTargetMessage.ts
+++ b/ts/util/modifyTargetMessage.ts
@@ -9,6 +9,7 @@ import type { SendStateByConversationId } from '../messages/MessageSendState';
import * as Edits from '../messageModifiers/Edits';
import * as log from '../logging/log';
import * as Deletes from '../messageModifiers/Deletes';
+import * as DeletesForMe from '../messageModifiers/DeletesForMe';
import * as MessageReceipts from '../messageModifiers/MessageReceipts';
import * as Reactions from '../messageModifiers/Reactions';
import * as ReadSyncs from '../messageModifiers/ReadSyncs';
@@ -29,6 +30,12 @@ import { missingCaseError } from './missingCaseError';
import { reduce } from './iterables';
import { strictAssert } from './assert';
+export enum ModifyTargetMessageResult {
+ Modified = 'Modified',
+ NotModified = 'MotModified',
+ Deleted = 'Deleted',
+}
+
// This function is called twice - once from handleDataMessage, and then again from
// saveAndNotify, a function called at the end of handleDataMessage as a cleanup for
// any missed out-of-order events.
@@ -36,7 +43,7 @@ export async function modifyTargetMessage(
message: MessageModel,
conversation: ConversationModel,
options?: { isFirstRun: boolean; skipEdits: boolean }
-): Promise {
+): Promise {
const { isFirstRun = false, skipEdits = false } = options ?? {};
const logId = `modifyTargetMessage/${message.idForLogging()}`;
@@ -45,6 +52,15 @@ export async function modifyTargetMessage(
const ourAci = window.textsecure.storage.user.getCheckedAci();
const sourceServiceId = getSourceServiceId(message.attributes);
+ const syncDeletes = await DeletesForMe.forMessage(message.attributes);
+ if (syncDeletes.length) {
+ if (!isFirstRun) {
+ await window.Signal.Data.removeMessage(message.id);
+ }
+
+ return ModifyTargetMessageResult.Deleted;
+ }
+
if (type === 'outgoing' || (type === 'story' && ourAci === sourceServiceId)) {
const sendActions = MessageReceipts.forMessage(message).map(receipt => {
let sendActionType: SendActionType;
@@ -274,4 +290,8 @@ export async function modifyTargetMessage(
)
);
}
+
+ return changed
+ ? ModifyTargetMessageResult.Modified
+ : ModifyTargetMessageResult.NotModified;
}
diff --git a/ts/util/syncTasks.ts b/ts/util/syncTasks.ts
new file mode 100644
index 000000000000..ce883a4e3c6d
--- /dev/null
+++ b/ts/util/syncTasks.ts
@@ -0,0 +1,138 @@
+// Copyright 2020 Signal Messenger, LLC
+// SPDX-License-Identifier: AGPL-3.0-only
+
+import { z } from 'zod';
+import type { ZodSchema } from 'zod';
+
+import * as log from '../logging/log';
+import * as DeletesForMe from '../messageModifiers/DeletesForMe';
+import {
+ deleteMessageSchema,
+ deleteConversationSchema,
+ deleteLocalConversationSchema,
+} from '../textsecure/messageReceiverEvents';
+
+import {
+ deleteConversation,
+ deleteLocalOnlyConversation,
+ getConversationFromTarget,
+} from './deleteForMe';
+import { drop } from './drop';
+
+const syncTaskDataSchema = z.union([
+ deleteMessageSchema,
+ deleteConversationSchema,
+ deleteLocalConversationSchema,
+]);
+export type SyncTaskData = z.infer;
+
+export type SyncTaskType = Readonly<{
+ id: string;
+ attempts: number;
+ createdAt: number;
+ data: unknown;
+ envelopeId: string;
+ sentAt: number;
+ type: SyncTaskData['type'];
+}>;
+
+const SCHEMAS_BY_TYPE: Record = {
+ 'delete-message': deleteMessageSchema,
+ 'delete-conversation': deleteConversationSchema,
+ 'delete-local-conversation': deleteLocalConversationSchema,
+};
+
+function toLogId(task: SyncTaskType) {
+ return `task=${task.id},timestamp:${task},type=${task.type},envelopeId=${task.envelopeId}`;
+}
+
+export async function queueSyncTasks(
+ tasks: Array,
+ removeSyncTaskById: (id: string) => Promise
+): Promise {
+ const logId = 'queueSyncTasks';
+
+ for (let i = 0, max = tasks.length; i < max; i += 1) {
+ const task = tasks[i];
+ const { id, envelopeId, type, sentAt, data } = task;
+ const innerLogId = `${logId}(${toLogId(task)})`;
+
+ const schema = SCHEMAS_BY_TYPE[type];
+ if (!schema) {
+ log.error(`${innerLogId}: Schema not found. Deleting.`);
+ // eslint-disable-next-line no-await-in-loop
+ await removeSyncTaskById(id);
+ return;
+ }
+ const parseResult = syncTaskDataSchema.safeParse(data);
+ if (!parseResult.success) {
+ log.error(
+ `${innerLogId}: Failed to parse. Deleting. Error: ${parseResult.error}`
+ );
+ // eslint-disable-next-line no-await-in-loop
+ await removeSyncTaskById(id);
+ return;
+ }
+
+ const { data: parsed } = parseResult;
+
+ if (parsed.type === 'delete-message') {
+ // eslint-disable-next-line no-await-in-loop
+ await DeletesForMe.onDelete({
+ conversation: parsed.conversation,
+ envelopeId,
+ message: parsed.message,
+ syncTaskId: id,
+ timestamp: sentAt,
+ });
+ } else if (parsed.type === 'delete-conversation') {
+ const {
+ conversation: targetConversation,
+ mostRecentMessages,
+ isFullDelete,
+ } = parsed;
+ const conversation = getConversationFromTarget(targetConversation);
+ if (!conversation) {
+ log.error(`${innerLogId}: Conversation not found!`);
+ continue;
+ }
+ drop(
+ conversation.queueJob(innerLogId, async () => {
+ log.info(`${logId}: Starting...`);
+ const result = await deleteConversation(
+ conversation,
+ mostRecentMessages,
+ isFullDelete,
+ innerLogId
+ );
+ if (result) {
+ await removeSyncTaskById(id);
+ }
+ log.info(`${logId}: Done, result=${result}`);
+ })
+ );
+ } else if (parsed.type === 'delete-local-conversation') {
+ const { conversation: targetConversation } = parsed;
+ const conversation = getConversationFromTarget(targetConversation);
+ if (!conversation) {
+ log.error(`${innerLogId}: Conversation not found!`);
+ continue;
+ }
+ drop(
+ conversation.queueJob(innerLogId, async () => {
+ log.info(`${logId}: Starting...`);
+ const result = await deleteLocalOnlyConversation(
+ conversation,
+ innerLogId
+ );
+
+ // Note: we remove even with a 'false' result because we're only gonna
+ // get more messages in this conversation from here!
+ await removeSyncTaskById(id);
+
+ log.info(`${logId}: Done; result=${result}`);
+ })
+ );
+ }
+ }
+}
diff --git a/ts/util/syncTasks.types.ts b/ts/util/syncTasks.types.ts
new file mode 100644
index 000000000000..f8a32125d8c2
--- /dev/null
+++ b/ts/util/syncTasks.types.ts
@@ -0,0 +1,4 @@
+// Copyright 2020 Signal Messenger, LLC
+// SPDX-License-Identifier: AGPL-3.0-only
+
+export const MAX_SYNC_TASK_ATTEMPTS = 5;