Support for local deletes synced to all your devices
This commit is contained in:
parent
06f71a7ef8
commit
11eb1782a7
39 changed files with 2094 additions and 72 deletions
|
@ -5010,6 +5010,10 @@
|
||||||
"messageformat": "What devices would you like to delete {count, plural, one {this message} other {these messages}} from?",
|
"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"
|
"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": {
|
"icu:DeleteMessagesModal--deleteForMe": {
|
||||||
"messageformat": "Delete for me",
|
"messageformat": "Delete for me",
|
||||||
"description": "delete selected messages > confirmation modal > delete for me"
|
"description": "delete selected messages > confirmation modal > delete for me"
|
||||||
|
@ -5026,6 +5030,10 @@
|
||||||
"messageformat": "Delete from all devices",
|
"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)"
|
"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": {
|
"icu:DeleteMessagesModal__toast--TooManyMessagesToDeleteForEveryone": {
|
||||||
"messageformat": "You can only select up to {count, plural, one {# message} other {# messages}} to delete for everyone",
|
"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'"
|
"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",
|
"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"
|
"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": {
|
"icu:Stories__title": {
|
||||||
"messageformat": "Stories",
|
"messageformat": "Stories",
|
||||||
"description": "Title for the stories list"
|
"description": "Title for the stories list"
|
||||||
|
|
1
images/local-delete-sync.svg
Normal file
1
images/local-delete-sync.svg
Normal file
|
@ -0,0 +1 @@
|
||||||
|
<?xml version="1.0" encoding="UTF-8"?><svg id="a" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 138 92"><defs><style>.b{fill:#8f8f8f;fill-rule:evenodd;}.b,.c,.d{stroke-width:0px;}.c{fill:#6b88e8;}.d{fill:#e4e7ff;}</style></defs><path class="d" d="m61.26,29.835h-16.112c-.636,0-1.249.101-1.824.284v16.431c.576.184,1.188.284,1.824.284h16.112c3.314,0,6-2.686,6-6v-5c0-3.314-2.686-6-6-6Z"/><path class="c" d="m95.372,57.835c0-3.314,2.686-6,6-6h23.139c3.314,0,6,2.686,6,6v8c0,3.314-2.686,6-6,6h-23.139c-3.314,0-6-2.686-6-6v-8Z"/><path class="d" d="m34.128,16.835c0-3.314,2.686-6,6-6h36.192c3.314,0,6,2.686,6,6v5c0,3.314-2.686,6-6,6h-36.192c-3.314,0-6-2.686-6-6v-5Z"/><path class="c" d="m18.198,69.902c0-2.761,2.239-5,5-5h9.879c2.762,0,5,2.239,5,5v4.667c0,2.761-2.238,5-5,5h-9.879c-2.761,0-5-2.239-5-5v-4.667Z"/><path class="d" d="m5.681,56.568c0-2.835,2.298-5.133,5.133-5.133h9.612c2.835,0,5.133,2.298,5.133,5.133h0c0,2.835-2.298,5.133-5.133,5.133h-9.612c-2.835,0-5.133-2.298-5.133-5.133h0Z"/><path class="d" d="m5.681,45.102c0-2.835,2.298-5.133,5.133-5.133h15.502c2.835,0,5.133,2.298,5.133,5.133h0c0,2.835-2.298,5.133-5.133,5.133h-15.502c-2.835,0-5.133-2.298-5.133-5.133h0Z"/><path class="b" d="m125,0H38.033c-7.089,0-12.835,5.746-12.835,12.835v10.632h-13.198C5.464,23.467.165,28.765.165,35.302v44.533c0,6.536,5.299,11.835,11.835,11.835h20.495c4.654,0,8.669-2.693,10.602-6.6h81.903c7.089,0,12.835-5.746,12.835-12.835V12.835c0-7.089-5.746-12.835-12.835-12.835ZM40.659,79.835c0,4.509-3.656,8.165-8.165,8.165H12c-4.509,0-8.165-3.656-8.165-8.165v-44.533c0-4.509,3.656-8.165,8.165-8.165h20.495c4.509,0,8.165,3.656,8.165,8.165v44.533Zm93.505-7.6c0,5.062-4.103,9.165-9.165,9.165H44.214c.068-.513.115-1.033.115-1.565v-44.533c0-6.536-5.299-11.835-11.835-11.835h-3.627v-10.632c0-5.062,4.103-9.165,9.165-9.165h86.967c5.062,0,9.165,4.103,9.165,9.165v59.4Z"/></svg>
|
After Width: | Height: | Size: 1.8 KiB |
|
@ -633,6 +633,43 @@ message SyncMessage {
|
||||||
optional uint64 timestamp = 2;
|
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 Sent sent = 1;
|
||||||
optional Contacts contacts = 2;
|
optional Contacts contacts = 2;
|
||||||
reserved /* groups */ 3;
|
reserved /* groups */ 3;
|
||||||
|
@ -654,6 +691,7 @@ message SyncMessage {
|
||||||
optional CallEvent callEvent = 19;
|
optional CallEvent callEvent = 19;
|
||||||
optional CallLinkUpdate callLinkUpdate = 20;
|
optional CallLinkUpdate callLinkUpdate = 20;
|
||||||
optional CallLogEvent callLogEvent = 21;
|
optional CallLogEvent callLogEvent = 21;
|
||||||
|
optional DeleteForMe deleteForMe = 22;
|
||||||
}
|
}
|
||||||
|
|
||||||
message AttachmentPointer {
|
message AttachmentPointer {
|
||||||
|
|
35
stylesheets/components/LocalDeleteWarningModal.scss
Normal file
35
stylesheets/components/LocalDeleteWarningModal.scss
Normal file
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
|
@ -113,6 +113,7 @@
|
||||||
@import './components/LeftPaneSearchInput.scss';
|
@import './components/LeftPaneSearchInput.scss';
|
||||||
@import './components/Lightbox.scss';
|
@import './components/Lightbox.scss';
|
||||||
@import './components/ListTile.scss';
|
@import './components/ListTile.scss';
|
||||||
|
@import './components/LocalDeleteWarningModal.scss';
|
||||||
@import './components/MediaEditor.scss';
|
@import './components/MediaEditor.scss';
|
||||||
@import './components/MediaQualitySelector.scss';
|
@import './components/MediaQualitySelector.scss';
|
||||||
@import './components/MessageAudio.scss';
|
@import './components/MessageAudio.scss';
|
||||||
|
|
|
@ -50,7 +50,7 @@ export async function populateConversationWithMessages({
|
||||||
);
|
);
|
||||||
|
|
||||||
log.info(`${logId}: destroying all messages in ${conversationId}`);
|
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}`);
|
log.info(`${logId}: adding ${messageCount} messages to ${conversationId}`);
|
||||||
let timestamp = Date.now();
|
let timestamp = Date.now();
|
||||||
|
|
|
@ -18,6 +18,8 @@ export type ConfigKeyType =
|
||||||
| 'desktop.calling.adhoc'
|
| 'desktop.calling.adhoc'
|
||||||
| 'desktop.clientExpiration'
|
| 'desktop.clientExpiration'
|
||||||
| 'desktop.backup.credentialFetch'
|
| 'desktop.backup.credentialFetch'
|
||||||
|
| 'desktop.deleteSync.send'
|
||||||
|
| 'desktop.deleteSync.receive'
|
||||||
| 'desktop.groupMultiTypingIndicators'
|
| 'desktop.groupMultiTypingIndicators'
|
||||||
| 'desktop.internalUser'
|
| 'desktop.internalUser'
|
||||||
| 'desktop.mediaQuality.levels'
|
| 'desktop.mediaQuality.levels'
|
||||||
|
|
|
@ -86,6 +86,7 @@ import type {
|
||||||
FetchLatestEvent,
|
FetchLatestEvent,
|
||||||
InvalidPlaintextEvent,
|
InvalidPlaintextEvent,
|
||||||
KeysEvent,
|
KeysEvent,
|
||||||
|
DeleteForMeSyncEvent,
|
||||||
MessageEvent,
|
MessageEvent,
|
||||||
MessageEventData,
|
MessageEventData,
|
||||||
MessageRequestResponseEvent,
|
MessageRequestResponseEvent,
|
||||||
|
@ -111,21 +112,21 @@ import type { BadgesStateType } from './state/ducks/badges';
|
||||||
import { areAnyCallsActiveOrRinging } from './state/selectors/calling';
|
import { areAnyCallsActiveOrRinging } from './state/selectors/calling';
|
||||||
import { badgeImageFileDownloader } from './badges/badgeImageFileDownloader';
|
import { badgeImageFileDownloader } from './badges/badgeImageFileDownloader';
|
||||||
import * as Deletes from './messageModifiers/Deletes';
|
import * as Deletes from './messageModifiers/Deletes';
|
||||||
import type { EditAttributesType } from './messageModifiers/Edits';
|
|
||||||
import * as Edits from './messageModifiers/Edits';
|
import * as Edits from './messageModifiers/Edits';
|
||||||
import type { ReactionAttributesType } from './messageModifiers/Reactions';
|
|
||||||
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 ReadSyncs from './messageModifiers/ReadSyncs';
|
||||||
import * as ViewSyncs from './messageModifiers/ViewSyncs';
|
|
||||||
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 { MessageReceiptAttributesType } from './messageModifiers/MessageReceipts';
|
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 { ReadSyncAttributesType } from './messageModifiers/ReadSyncs';
|
import type { ReadSyncAttributesType } from './messageModifiers/ReadSyncs';
|
||||||
import type { ViewSyncAttributesType } from './messageModifiers/ViewSyncs';
|
|
||||||
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';
|
||||||
|
@ -201,6 +202,8 @@ 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 { queueSyncTasks } from './util/syncTasks';
|
||||||
|
import { isEnabled } from './RemoteConfig';
|
||||||
|
|
||||||
export function isOverHourIntoPast(timestamp: number): boolean {
|
export function isOverHourIntoPast(timestamp: number): boolean {
|
||||||
return isNumber(timestamp) && isOlderThan(timestamp, HOUR);
|
return isNumber(timestamp) && isOlderThan(timestamp, HOUR);
|
||||||
|
@ -558,6 +561,24 @@ export async function startApp(): Promise<void> {
|
||||||
storage: window.storage,
|
storage: window.storage,
|
||||||
serverTrustRoot: window.getServerTrustRoot(),
|
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<E extends Event>(
|
function queuedEventListener<E extends Event>(
|
||||||
handler: (event: E) => Promise<void> | void,
|
handler: (event: E) => Promise<void> | void,
|
||||||
|
@ -691,6 +712,10 @@ export async function startApp(): Promise<void> {
|
||||||
'callLogEventSync',
|
'callLogEventSync',
|
||||||
queuedEventListener(onCallLogEventSync, false)
|
queuedEventListener(onCallLogEventSync, false)
|
||||||
);
|
);
|
||||||
|
messageReceiver.addEventListener(
|
||||||
|
'deleteForMeSync',
|
||||||
|
queuedEventListener(onDeleteForMeSync, false)
|
||||||
|
);
|
||||||
|
|
||||||
if (!window.storage.get('defaultConversationColor')) {
|
if (!window.storage.get('defaultConversationColor')) {
|
||||||
drop(
|
drop(
|
||||||
|
@ -3384,6 +3409,41 @@ export async function startApp(): Promise<void> {
|
||||||
|
|
||||||
drop(MessageReceipts.onReceipt(attributes));
|
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;
|
window.startApp = startApp;
|
||||||
|
|
78
ts/components/DeleteMessagesModal.stories.tsx
Normal file
78
ts/components/DeleteMessagesModal.stories.tsx
Normal file
|
@ -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<DeleteMessagesModalProps>;
|
||||||
|
|
||||||
|
function createProps(args: Partial<DeleteMessagesModalProps>) {
|
||||||
|
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<DeleteMessagesModalProps> = args => {
|
||||||
|
return <DeleteMessagesModal {...args} />;
|
||||||
|
};
|
||||||
|
|
||||||
|
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,
|
||||||
|
});
|
|
@ -8,8 +8,9 @@ import type { LocalizerType } from '../types/Util';
|
||||||
import type { ShowToastAction } from '../state/ducks/toast';
|
import type { ShowToastAction } from '../state/ducks/toast';
|
||||||
import { ToastType } from '../types/Toast';
|
import { ToastType } from '../types/Toast';
|
||||||
|
|
||||||
type DeleteMessagesModalProps = Readonly<{
|
export type DeleteMessagesModalProps = Readonly<{
|
||||||
isMe: boolean;
|
isMe: boolean;
|
||||||
|
isDeleteSyncSendEnabled: boolean;
|
||||||
canDeleteForEveryone: boolean;
|
canDeleteForEveryone: boolean;
|
||||||
i18n: LocalizerType;
|
i18n: LocalizerType;
|
||||||
messageCount: number;
|
messageCount: number;
|
||||||
|
@ -23,6 +24,7 @@ const MAX_DELETE_FOR_EVERYONE = 30;
|
||||||
|
|
||||||
export default function DeleteMessagesModal({
|
export default function DeleteMessagesModal({
|
||||||
isMe,
|
isMe,
|
||||||
|
isDeleteSyncSendEnabled,
|
||||||
canDeleteForEveryone,
|
canDeleteForEveryone,
|
||||||
i18n,
|
i18n,
|
||||||
messageCount,
|
messageCount,
|
||||||
|
@ -33,15 +35,22 @@ export default function DeleteMessagesModal({
|
||||||
}: DeleteMessagesModalProps): JSX.Element {
|
}: DeleteMessagesModalProps): JSX.Element {
|
||||||
const actions: Array<ActionSpec> = [];
|
const actions: Array<ActionSpec> = [];
|
||||||
|
|
||||||
|
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({
|
actions.push({
|
||||||
action: onDeleteForMe,
|
action: onDeleteForMe,
|
||||||
style: 'negative',
|
style: 'negative',
|
||||||
text: isMe
|
text: deleteForMeText,
|
||||||
? i18n('icu:DeleteMessagesModal--deleteFromThisDevice')
|
|
||||||
: i18n('icu:DeleteMessagesModal--deleteForMe'),
|
|
||||||
});
|
});
|
||||||
|
|
||||||
if (canDeleteForEveryone) {
|
if (canDeleteForEveryone && !syncNoteToSelfDelete) {
|
||||||
const tooManyMessages = messageCount > MAX_DELETE_FOR_EVERYONE;
|
const tooManyMessages = messageCount > MAX_DELETE_FOR_EVERYONE;
|
||||||
actions.push({
|
actions.push({
|
||||||
'aria-disabled': tooManyMessages,
|
'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 (
|
return (
|
||||||
<ConfirmationDialog
|
<ConfirmationDialog
|
||||||
actions={actions}
|
actions={actions}
|
||||||
|
@ -74,13 +97,7 @@ export default function DeleteMessagesModal({
|
||||||
})}
|
})}
|
||||||
moduleClassName="DeleteMessagesModal"
|
moduleClassName="DeleteMessagesModal"
|
||||||
>
|
>
|
||||||
{isMe
|
{descriptionText}
|
||||||
? i18n('icu:DeleteMessagesModal--description--noteToSelf', {
|
|
||||||
count: messageCount,
|
|
||||||
})
|
|
||||||
: i18n('icu:DeleteMessagesModal--description', {
|
|
||||||
count: messageCount,
|
|
||||||
})}
|
|
||||||
</ConfirmationDialog>
|
</ConfirmationDialog>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
30
ts/components/LocalDeleteWarningModal.stories.tsx
Normal file
30
ts/components/LocalDeleteWarningModal.stories.tsx
Normal file
|
@ -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<PropsType>;
|
||||||
|
|
||||||
|
// eslint-disable-next-line react/function-component-definition
|
||||||
|
const Template: StoryFn<PropsType> = args => (
|
||||||
|
<LocalDeleteWarningModal {...args} />
|
||||||
|
);
|
||||||
|
|
||||||
|
export const Modal = Template.bind({});
|
||||||
|
Modal.args = {};
|
53
ts/components/LocalDeleteWarningModal.tsx
Normal file
53
ts/components/LocalDeleteWarningModal.tsx
Normal file
|
@ -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 (
|
||||||
|
<Modal
|
||||||
|
modalName="LocalDeleteWarningModal"
|
||||||
|
moduleClassName="LocalDeleteWarningModal"
|
||||||
|
i18n={i18n}
|
||||||
|
onClose={onClose}
|
||||||
|
>
|
||||||
|
<div className="LocalDeleteWarningModal">
|
||||||
|
<div className="LocalDeleteWarningModal__image">
|
||||||
|
<img
|
||||||
|
src="images/local-delete-sync.svg"
|
||||||
|
height="92"
|
||||||
|
width="138"
|
||||||
|
alt=""
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="LocalDeleteWarningModal__header">
|
||||||
|
<I18n i18n={i18n} id="icu:LocalDeleteWarningModal__header" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="LocalDeleteWarningModal__description">
|
||||||
|
<I18n i18n={i18n} id="icu:LocalDeleteWarningModal__description" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="LocalDeleteWarningModal__button">
|
||||||
|
<Button onClick={onClose} variant={ButtonVariant.Primary}>
|
||||||
|
<I18n i18n={i18n} id="icu:LocalDeleteWarningModal__confirm" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
|
@ -46,6 +46,10 @@ const commonProps = {
|
||||||
|
|
||||||
i18n,
|
i18n,
|
||||||
|
|
||||||
|
localDeleteWarningShown: true,
|
||||||
|
isDeleteSyncSendEnabled: true,
|
||||||
|
setLocalDeleteWarningShown: action('setLocalDeleteWarningShown'),
|
||||||
|
|
||||||
onConversationAccept: action('onConversationAccept'),
|
onConversationAccept: action('onConversationAccept'),
|
||||||
onConversationArchive: action('onConversationArchive'),
|
onConversationArchive: action('onConversationArchive'),
|
||||||
onConversationBlock: action('onConversationBlock'),
|
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 <ConversationHeader {...props} theme={theme} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
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 <ConversationHeader {...props} theme={theme} />;
|
||||||
|
}
|
||||||
|
|
|
@ -38,6 +38,7 @@ import {
|
||||||
MessageRequestState,
|
MessageRequestState,
|
||||||
} from './MessageRequestActionsConfirmation';
|
} from './MessageRequestActionsConfirmation';
|
||||||
import type { MinimalConversation } from '../../hooks/useMinimalConversation';
|
import type { MinimalConversation } from '../../hooks/useMinimalConversation';
|
||||||
|
import { LocalDeleteWarningModal } from '../LocalDeleteWarningModal';
|
||||||
|
|
||||||
function HeaderInfoTitle({
|
function HeaderInfoTitle({
|
||||||
name,
|
name,
|
||||||
|
@ -92,6 +93,8 @@ export type PropsDataType = {
|
||||||
conversationName: ContactNameData;
|
conversationName: ContactNameData;
|
||||||
hasPanelShowing?: boolean;
|
hasPanelShowing?: boolean;
|
||||||
hasStories?: HasStories;
|
hasStories?: HasStories;
|
||||||
|
localDeleteWarningShown: boolean;
|
||||||
|
isDeleteSyncSendEnabled: boolean;
|
||||||
isMissingMandatoryProfileSharing?: boolean;
|
isMissingMandatoryProfileSharing?: boolean;
|
||||||
isSelectMode: boolean;
|
isSelectMode: boolean;
|
||||||
isSignalConversation?: boolean;
|
isSignalConversation?: boolean;
|
||||||
|
@ -102,6 +105,8 @@ export type PropsDataType = {
|
||||||
};
|
};
|
||||||
|
|
||||||
export type PropsActionsType = {
|
export type PropsActionsType = {
|
||||||
|
setLocalDeleteWarningShown: () => void;
|
||||||
|
|
||||||
onConversationAccept: () => void;
|
onConversationAccept: () => void;
|
||||||
onConversationArchive: () => void;
|
onConversationArchive: () => void;
|
||||||
onConversationBlock: () => void;
|
onConversationBlock: () => void;
|
||||||
|
@ -147,10 +152,12 @@ export const ConversationHeader = memo(function ConversationHeader({
|
||||||
hasPanelShowing,
|
hasPanelShowing,
|
||||||
hasStories,
|
hasStories,
|
||||||
i18n,
|
i18n,
|
||||||
|
isDeleteSyncSendEnabled,
|
||||||
isMissingMandatoryProfileSharing,
|
isMissingMandatoryProfileSharing,
|
||||||
isSelectMode,
|
isSelectMode,
|
||||||
isSignalConversation,
|
isSignalConversation,
|
||||||
isSMSOnly,
|
isSMSOnly,
|
||||||
|
localDeleteWarningShown,
|
||||||
onConversationAccept,
|
onConversationAccept,
|
||||||
onConversationArchive,
|
onConversationArchive,
|
||||||
onConversationBlock,
|
onConversationBlock,
|
||||||
|
@ -174,6 +181,7 @@ export const ConversationHeader = memo(function ConversationHeader({
|
||||||
onViewRecentMedia,
|
onViewRecentMedia,
|
||||||
onViewUserStories,
|
onViewUserStories,
|
||||||
outgoingCallButtonStyle,
|
outgoingCallButtonStyle,
|
||||||
|
setLocalDeleteWarningShown,
|
||||||
sharedGroupNames,
|
sharedGroupNames,
|
||||||
theme,
|
theme,
|
||||||
}: PropsType): JSX.Element | null {
|
}: PropsType): JSX.Element | null {
|
||||||
|
@ -223,13 +231,16 @@ export const ConversationHeader = memo(function ConversationHeader({
|
||||||
{hasDeleteMessagesConfirmation && (
|
{hasDeleteMessagesConfirmation && (
|
||||||
<DeleteMessagesConfirmationDialog
|
<DeleteMessagesConfirmationDialog
|
||||||
i18n={i18n}
|
i18n={i18n}
|
||||||
onDestoryMessages={() => {
|
isDeleteSyncSendEnabled={isDeleteSyncSendEnabled}
|
||||||
|
localDeleteWarningShown={localDeleteWarningShown}
|
||||||
|
onDestroyMessages={() => {
|
||||||
setHasDeleteMessagesConfirmation(false);
|
setHasDeleteMessagesConfirmation(false);
|
||||||
onConversationDeleteMessages();
|
onConversationDeleteMessages();
|
||||||
}}
|
}}
|
||||||
onClose={() => {
|
onClose={() => {
|
||||||
setHasDeleteMessagesConfirmation(false);
|
setHasDeleteMessagesConfirmation(false);
|
||||||
}}
|
}}
|
||||||
|
setLocalDeleteWarningShown={setLocalDeleteWarningShown}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{hasLeaveGroupConfirmation && (
|
{hasLeaveGroupConfirmation && (
|
||||||
|
@ -923,14 +934,29 @@ function CannotLeaveGroupBecauseYouAreLastAdminAlert({
|
||||||
}
|
}
|
||||||
|
|
||||||
function DeleteMessagesConfirmationDialog({
|
function DeleteMessagesConfirmationDialog({
|
||||||
|
isDeleteSyncSendEnabled,
|
||||||
i18n,
|
i18n,
|
||||||
onDestoryMessages,
|
localDeleteWarningShown,
|
||||||
|
onDestroyMessages,
|
||||||
onClose,
|
onClose,
|
||||||
|
setLocalDeleteWarningShown,
|
||||||
}: {
|
}: {
|
||||||
|
isDeleteSyncSendEnabled: boolean;
|
||||||
i18n: LocalizerType;
|
i18n: LocalizerType;
|
||||||
onDestoryMessages: () => void;
|
localDeleteWarningShown: boolean;
|
||||||
|
onDestroyMessages: () => void;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
|
setLocalDeleteWarningShown: () => void;
|
||||||
}) {
|
}) {
|
||||||
|
if (!localDeleteWarningShown && isDeleteSyncSendEnabled) {
|
||||||
|
return (
|
||||||
|
<LocalDeleteWarningModal
|
||||||
|
i18n={i18n}
|
||||||
|
onClose={setLocalDeleteWarningShown}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ConfirmationDialog
|
<ConfirmationDialog
|
||||||
dialogName="ConversationHeader.destroyMessages"
|
dialogName="ConversationHeader.destroyMessages"
|
||||||
|
@ -939,7 +965,7 @@ function DeleteMessagesConfirmationDialog({
|
||||||
)}
|
)}
|
||||||
actions={[
|
actions={[
|
||||||
{
|
{
|
||||||
action: onDestoryMessages,
|
action: onDestroyMessages,
|
||||||
style: 'negative',
|
style: 'negative',
|
||||||
text: i18n('icu:delete'),
|
text: i18n('icu:delete'),
|
||||||
},
|
},
|
||||||
|
|
113
ts/messageModifiers/DeletesForMe.ts
Normal file
113
ts/messageModifiers/DeletesForMe.ts
Normal file
|
@ -0,0 +1,113 @@
|
||||||
|
// Copyright 2020 Signal Messenger, LLC
|
||||||
|
// SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
|
||||||
|
import * as log from '../logging/log';
|
||||||
|
import * as Errors from '../types/errors';
|
||||||
|
import { drop } from '../util/drop';
|
||||||
|
import { getMessageSentTimestampSet } from '../util/getMessageSentTimestampSet';
|
||||||
|
|
||||||
|
import type { MessageAttributesType } from '../model-types';
|
||||||
|
import type {
|
||||||
|
ConversationToDelete,
|
||||||
|
MessageToDelete,
|
||||||
|
} from '../textsecure/messageReceiverEvents';
|
||||||
|
import {
|
||||||
|
deleteMessage,
|
||||||
|
doesMessageMatch,
|
||||||
|
getConversationFromTarget,
|
||||||
|
getMessageQueryFromTarget,
|
||||||
|
} from '../util/deleteForMe';
|
||||||
|
import dataInterface from '../sql/Client';
|
||||||
|
|
||||||
|
const { removeSyncTaskById } = dataInterface;
|
||||||
|
|
||||||
|
export type DeleteForMeAttributesType = {
|
||||||
|
conversation: ConversationToDelete;
|
||||||
|
envelopeId: string;
|
||||||
|
message: MessageToDelete;
|
||||||
|
syncTaskId: string;
|
||||||
|
timestamp: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
const deletes = new Map<string, DeleteForMeAttributesType>();
|
||||||
|
|
||||||
|
async function remove(item: DeleteForMeAttributesType): Promise<void> {
|
||||||
|
await removeSyncTaskById(item.syncTaskId);
|
||||||
|
deletes.delete(item.envelopeId);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function forMessage(
|
||||||
|
messageAttributes: MessageAttributesType
|
||||||
|
): Promise<Array<DeleteForMeAttributesType>> {
|
||||||
|
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<void> {
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
|
@ -165,6 +165,12 @@ import OS from '../util/os/osMain';
|
||||||
import { getMessageAuthorText } from '../util/getMessageAuthorText';
|
import { getMessageAuthorText } from '../util/getMessageAuthorText';
|
||||||
import { downscaleOutgoingAttachment } from '../util/attachments';
|
import { downscaleOutgoingAttachment } from '../util/attachments';
|
||||||
import { MessageRequestResponseEvent } from '../types/MessageRequestResponseEvent';
|
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';
|
import { getCallHistorySelector } from '../state/selectors/callHistory';
|
||||||
|
|
||||||
/* eslint-disable more/no-then */
|
/* eslint-disable more/no-then */
|
||||||
|
@ -186,6 +192,7 @@ const {
|
||||||
getOlderMessagesByConversation,
|
getOlderMessagesByConversation,
|
||||||
getMessageMetricsForConversation,
|
getMessageMetricsForConversation,
|
||||||
getMessageById,
|
getMessageById,
|
||||||
|
getMostRecentAddressableMessages,
|
||||||
getNewerMessagesByConversation,
|
getNewerMessagesByConversation,
|
||||||
} = window.Signal.Data;
|
} = window.Signal.Data;
|
||||||
|
|
||||||
|
@ -2234,7 +2241,7 @@ export class ConversationModel extends window.Backbone
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isDelete) {
|
if (isDelete) {
|
||||||
await this.destroyMessages();
|
await this.destroyMessages({ source: 'message-request' });
|
||||||
void this.updateLastMessage();
|
void this.updateLastMessage();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -4449,7 +4456,6 @@ export class ConversationModel extends window.Backbone
|
||||||
source: providedSource,
|
source: providedSource,
|
||||||
fromSync = false,
|
fromSync = false,
|
||||||
isInitialSync = false,
|
isInitialSync = false,
|
||||||
fromGroupUpdate = false,
|
|
||||||
}: {
|
}: {
|
||||||
reason: string;
|
reason: string;
|
||||||
receivedAt?: number;
|
receivedAt?: number;
|
||||||
|
@ -4458,7 +4464,6 @@ export class ConversationModel extends window.Backbone
|
||||||
source?: string;
|
source?: string;
|
||||||
fromSync?: boolean;
|
fromSync?: boolean;
|
||||||
isInitialSync?: boolean;
|
isInitialSync?: boolean;
|
||||||
fromGroupUpdate?: boolean;
|
|
||||||
}
|
}
|
||||||
): Promise<boolean | null | MessageModel | void> {
|
): Promise<boolean | null | MessageModel | void> {
|
||||||
const isSetByOther = providedSource || providedSentAt !== undefined;
|
const isSetByOther = providedSource || providedSentAt !== undefined;
|
||||||
|
@ -4554,7 +4559,7 @@ export class ConversationModel extends window.Backbone
|
||||||
(isInitialSync && isFromSyncOperation) || isFromMe || isNoteToSelf;
|
(isInitialSync && isFromSyncOperation) || isFromMe || isNoteToSelf;
|
||||||
|
|
||||||
const id = generateGuid();
|
const id = generateGuid();
|
||||||
const model = new window.Whisper.Message({
|
const attributes = {
|
||||||
id,
|
id,
|
||||||
conversationId: this.id,
|
conversationId: this.id,
|
||||||
expirationTimerUpdate: {
|
expirationTimerUpdate: {
|
||||||
|
@ -4562,7 +4567,6 @@ export class ConversationModel extends window.Backbone
|
||||||
source,
|
source,
|
||||||
sourceServiceId,
|
sourceServiceId,
|
||||||
fromSync,
|
fromSync,
|
||||||
fromGroupUpdate,
|
|
||||||
},
|
},
|
||||||
flags: Proto.DataMessage.Flags.EXPIRATION_TIMER_UPDATE,
|
flags: Proto.DataMessage.Flags.EXPIRATION_TIMER_UPDATE,
|
||||||
readStatus: shouldBeRead ? ReadStatus.Read : ReadStatus.Unread,
|
readStatus: shouldBeRead ? ReadStatus.Read : ReadStatus.Unread,
|
||||||
|
@ -4570,18 +4574,18 @@ export class ConversationModel extends window.Backbone
|
||||||
received_at: receivedAt ?? incrementMessageCounter(),
|
received_at: receivedAt ?? incrementMessageCounter(),
|
||||||
seenStatus: shouldBeRead ? SeenStatus.Seen : SeenStatus.Unseen,
|
seenStatus: shouldBeRead ? SeenStatus.Seen : SeenStatus.Unseen,
|
||||||
sent_at: sentAt,
|
sent_at: sentAt,
|
||||||
type: 'timer-notification',
|
timestamp: sentAt,
|
||||||
// TODO: DESKTOP-722
|
type: 'timer-notification' as const,
|
||||||
} as unknown as MessageAttributesType);
|
};
|
||||||
|
|
||||||
await window.Signal.Data.saveMessage(model.attributes, {
|
await window.Signal.Data.saveMessage(attributes, {
|
||||||
ourAci: window.textsecure.storage.user.getCheckedAci(),
|
ourAci: window.textsecure.storage.user.getCheckedAci(),
|
||||||
forceSave: true,
|
forceSave: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
const message = window.MessageCache.__DEPRECATED$register(
|
const message = window.MessageCache.__DEPRECATED$register(
|
||||||
id,
|
id,
|
||||||
model,
|
new window.Whisper.Message(attributes),
|
||||||
'updateExpirationTimer'
|
'updateExpirationTimer'
|
||||||
);
|
);
|
||||||
|
|
||||||
|
@ -4589,7 +4593,7 @@ export class ConversationModel extends window.Backbone
|
||||||
void this.updateUnread();
|
void this.updateUnread();
|
||||||
|
|
||||||
log.info(
|
log.info(
|
||||||
`${logId}: added a notification received_at=${model.get('received_at')}`
|
`${logId}: added a notification received_at=${message.get('received_at')}`
|
||||||
);
|
);
|
||||||
|
|
||||||
return message;
|
return message;
|
||||||
|
@ -4978,7 +4982,29 @@ export class ConversationModel extends window.Backbone
|
||||||
this.contactCollection!.reset(members);
|
this.contactCollection!.reset(members);
|
||||||
}
|
}
|
||||||
|
|
||||||
async destroyMessages(): Promise<void> {
|
async destroyMessages({
|
||||||
|
source,
|
||||||
|
}: {
|
||||||
|
source: 'message-request' | 'local-delete-sync' | 'local-delete';
|
||||||
|
}): Promise<void> {
|
||||||
|
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<void> {
|
||||||
this.set({
|
this.set({
|
||||||
lastMessage: null,
|
lastMessage: null,
|
||||||
lastMessageAuthor: null,
|
lastMessageAuthor: null,
|
||||||
|
@ -4988,9 +5014,50 @@ export class ConversationModel extends window.Backbone
|
||||||
});
|
});
|
||||||
window.Signal.Data.updateConversation(this.attributes);
|
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<MessageToDelete> = 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(),
|
logId: this.idForLogging(),
|
||||||
});
|
});
|
||||||
|
log.info(`${logId}: Delete complete`);
|
||||||
}
|
}
|
||||||
|
|
||||||
getTitle(options?: { isShort?: boolean }): string {
|
getTitle(options?: { isShort?: boolean }): string {
|
||||||
|
|
|
@ -68,7 +68,11 @@ import {
|
||||||
} from '../util/whatTypeOfConversation';
|
} from '../util/whatTypeOfConversation';
|
||||||
import { handleMessageSend } from '../util/handleMessageSend';
|
import { handleMessageSend } from '../util/handleMessageSend';
|
||||||
import { getSendOptions } from '../util/getSendOptions';
|
import { getSendOptions } from '../util/getSendOptions';
|
||||||
import { modifyTargetMessage } from '../util/modifyTargetMessage';
|
import {
|
||||||
|
modifyTargetMessage,
|
||||||
|
ModifyTargetMessageResult,
|
||||||
|
} from '../util/modifyTargetMessage';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
getMessagePropStatus,
|
getMessagePropStatus,
|
||||||
hasErrors,
|
hasErrors,
|
||||||
|
@ -2177,7 +2181,6 @@ export class MessageModel extends window.Backbone.Model<MessageAttributesType> {
|
||||||
receivedAt: message.get('received_at'),
|
receivedAt: message.get('received_at'),
|
||||||
receivedAtMS: message.get('received_at_ms'),
|
receivedAtMS: message.get('received_at_ms'),
|
||||||
sentAt: message.get('sent_at'),
|
sentAt: message.get('sent_at'),
|
||||||
fromGroupUpdate: isGroupUpdate(message.attributes),
|
|
||||||
reason: idLog,
|
reason: idLog,
|
||||||
});
|
});
|
||||||
} else if (
|
} else if (
|
||||||
|
@ -2297,7 +2300,11 @@ export class MessageModel extends window.Backbone.Model<MessageAttributesType> {
|
||||||
}
|
}
|
||||||
|
|
||||||
const isFirstRun = true;
|
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`);
|
log.info(`${idLog}: Batching save`);
|
||||||
void this.saveAndNotify(conversation, confirm);
|
void this.saveAndNotify(conversation, confirm);
|
||||||
|
@ -2320,10 +2327,16 @@ export class MessageModel extends window.Backbone.Model<MessageAttributesType> {
|
||||||
// Once the message is saved to DB, we queue attachment downloads
|
// Once the message is saved to DB, we queue attachment downloads
|
||||||
await this.handleAttachmentDownloadsForNewMessage(conversation);
|
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;
|
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)) {
|
if (await shouldReplyNotifyUser(this.attributes, conversation)) {
|
||||||
await conversation.notify(this);
|
await conversation.notify(this);
|
||||||
|
@ -2377,7 +2390,7 @@ export class MessageModel extends window.Backbone.Model<MessageAttributesType> {
|
||||||
async modifyTargetMessage(
|
async modifyTargetMessage(
|
||||||
conversation: ConversationModel,
|
conversation: ConversationModel,
|
||||||
isFirstRun: boolean
|
isFirstRun: boolean
|
||||||
): Promise<void> {
|
): Promise<ModifyTargetMessageResult> {
|
||||||
return modifyTargetMessage(this, conversation, {
|
return modifyTargetMessage(this, conversation, {
|
||||||
isFirstRun,
|
isFirstRun,
|
||||||
skipEdits: false,
|
skipEdits: false,
|
||||||
|
|
|
@ -115,7 +115,7 @@ const exclusiveInterface: ClientExclusiveInterface = {
|
||||||
flushUpdateConversationBatcher,
|
flushUpdateConversationBatcher,
|
||||||
|
|
||||||
shutdown,
|
shutdown,
|
||||||
removeAllMessagesInConversation,
|
removeMessagesInConversation,
|
||||||
|
|
||||||
removeOtherData,
|
removeOtherData,
|
||||||
cleanupOrphanedAttachments,
|
cleanupOrphanedAttachments,
|
||||||
|
@ -592,6 +592,21 @@ async function removeMessage(id: string): Promise<void> {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function deleteAndCleanup(
|
||||||
|
messages: Array<MessageAttributesType>,
|
||||||
|
logId: string
|
||||||
|
): Promise<void> {
|
||||||
|
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(
|
async function _cleanupMessages(
|
||||||
messages: ReadonlyArray<MessageAttributesType>
|
messages: ReadonlyArray<MessageAttributesType>
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
|
@ -664,12 +679,14 @@ async function getConversationRangeCenteredOnMessage(
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
async function removeAllMessagesInConversation(
|
async function removeMessagesInConversation(
|
||||||
conversationId: string,
|
conversationId: string,
|
||||||
{
|
{
|
||||||
logId,
|
logId,
|
||||||
|
receivedAt,
|
||||||
}: {
|
}: {
|
||||||
logId: string;
|
logId: string;
|
||||||
|
receivedAt?: number;
|
||||||
}
|
}
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
let messages;
|
let messages;
|
||||||
|
@ -685,6 +702,7 @@ async function removeAllMessagesInConversation(
|
||||||
conversationId,
|
conversationId,
|
||||||
limit: chunkSize,
|
limit: chunkSize,
|
||||||
includeStoryReplies: true,
|
includeStoryReplies: true,
|
||||||
|
receivedAt,
|
||||||
storyId: undefined,
|
storyId: undefined,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@ -692,15 +710,8 @@ async function removeAllMessagesInConversation(
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const ids = messages.map(message => message.id);
|
|
||||||
|
|
||||||
log.info(`removeAllMessagesInConversation/${logId}: Cleanup...`);
|
|
||||||
// eslint-disable-next-line no-await-in-loop
|
// eslint-disable-next-line no-await-in-loop
|
||||||
await _cleanupMessages(messages);
|
await deleteAndCleanup(messages, logId);
|
||||||
|
|
||||||
log.info(`removeAllMessagesInConversation/${logId}: Deleting...`);
|
|
||||||
// eslint-disable-next-line no-await-in-loop
|
|
||||||
await channels.removeMessages(ids);
|
|
||||||
} while (messages.length > 0);
|
} while (messages.length > 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -32,6 +32,7 @@ import type {
|
||||||
import type { CallLinkStateType, CallLinkType } from '../types/CallLink';
|
import type { CallLinkStateType, CallLinkType } from '../types/CallLink';
|
||||||
import type { AttachmentDownloadJobType } from '../types/AttachmentDownload';
|
import type { AttachmentDownloadJobType } from '../types/AttachmentDownload';
|
||||||
import type { GroupSendEndorsementsData } from '../types/GroupSendEndorsements';
|
import type { GroupSendEndorsementsData } from '../types/GroupSendEndorsements';
|
||||||
|
import type { SyncTaskType } from '../util/syncTasks';
|
||||||
|
|
||||||
export type AdjacentMessagesByConversationOptionsType = Readonly<{
|
export type AdjacentMessagesByConversationOptionsType = Readonly<{
|
||||||
conversationId: string;
|
conversationId: string;
|
||||||
|
@ -716,6 +717,15 @@ export type DataInterface = {
|
||||||
ourAci: AciString,
|
ourAci: AciString,
|
||||||
opts: EditedMessageType
|
opts: EditedMessageType
|
||||||
) => Promise<void>;
|
) => Promise<void>;
|
||||||
|
getMostRecentAddressableMessages: (
|
||||||
|
conversationId: string,
|
||||||
|
limit?: number
|
||||||
|
) => Promise<Array<MessageType>>;
|
||||||
|
|
||||||
|
removeSyncTaskById: (id: string) => Promise<void>;
|
||||||
|
saveSyncTasks: (tasks: Array<SyncTaskType>) => Promise<void>;
|
||||||
|
getAllSyncTasks: () => Promise<Array<SyncTaskType>>;
|
||||||
|
|
||||||
getUnprocessedCount: () => Promise<number>;
|
getUnprocessedCount: () => Promise<number>;
|
||||||
getUnprocessedByIdsAndIncrementAttempts: (
|
getUnprocessedByIdsAndIncrementAttempts: (
|
||||||
ids: ReadonlyArray<string>
|
ids: ReadonlyArray<string>
|
||||||
|
@ -1043,10 +1053,11 @@ export type ClientExclusiveInterface = {
|
||||||
// Client-side only
|
// Client-side only
|
||||||
|
|
||||||
shutdown: () => Promise<void>;
|
shutdown: () => Promise<void>;
|
||||||
removeAllMessagesInConversation: (
|
removeMessagesInConversation: (
|
||||||
conversationId: string,
|
conversationId: string,
|
||||||
options: {
|
options: {
|
||||||
logId: string;
|
logId: string;
|
||||||
|
receivedAt?: number;
|
||||||
}
|
}
|
||||||
) => Promise<void>;
|
) => Promise<void>;
|
||||||
removeOtherData: () => Promise<void>;
|
removeOtherData: () => Promise<void>;
|
||||||
|
|
135
ts/sql/Server.ts
135
ts/sql/Server.ts
|
@ -184,6 +184,9 @@ import {
|
||||||
attachmentDownloadJobSchema,
|
attachmentDownloadJobSchema,
|
||||||
type AttachmentDownloadJobType,
|
type AttachmentDownloadJobType,
|
||||||
} from '../types/AttachmentDownload';
|
} 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<{
|
type ConversationRow = Readonly<{
|
||||||
json: string;
|
json: string;
|
||||||
|
@ -360,6 +363,11 @@ const dataInterface: ServerInterface = {
|
||||||
getMessagesBetween,
|
getMessagesBetween,
|
||||||
getNearbyMessageFromDeletedSet,
|
getNearbyMessageFromDeletedSet,
|
||||||
saveEditedMessage,
|
saveEditedMessage,
|
||||||
|
getMostRecentAddressableMessages,
|
||||||
|
|
||||||
|
removeSyncTaskById,
|
||||||
|
saveSyncTasks,
|
||||||
|
getAllSyncTasks,
|
||||||
|
|
||||||
getUnprocessedCount,
|
getUnprocessedCount,
|
||||||
getUnprocessedByIdsAndIncrementAttempts,
|
getUnprocessedByIdsAndIncrementAttempts,
|
||||||
|
@ -2066,6 +2074,131 @@ function hasUserInitiatedMessages(conversationId: string): boolean {
|
||||||
return exists !== 0;
|
return exists !== 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function getMostRecentAddressableMessages(
|
||||||
|
conversationId: string,
|
||||||
|
limit = 5
|
||||||
|
): Promise<Array<MessageType>> {
|
||||||
|
const db = getReadonlyInstance();
|
||||||
|
return getMostRecentAddressableMessagesSync(db, conversationId, limit);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getMostRecentAddressableMessagesSync(
|
||||||
|
db: Database,
|
||||||
|
conversationId: string,
|
||||||
|
limit = 5
|
||||||
|
): Array<MessageType> {
|
||||||
|
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<void> {
|
||||||
|
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<SyncTaskType>): Promise<void> {
|
||||||
|
const db = await getWritableInstance();
|
||||||
|
return saveSyncTasksSync(db, tasks);
|
||||||
|
}
|
||||||
|
export function saveSyncTasksSync(
|
||||||
|
db: Database,
|
||||||
|
tasks: Array<SyncTaskType>
|
||||||
|
): 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<Array<SyncTaskType>> {
|
||||||
|
const db = await getWritableInstance();
|
||||||
|
return getAllSyncTasksSync(db);
|
||||||
|
}
|
||||||
|
export function getAllSyncTasksSync(db: Database): Array<SyncTaskType> {
|
||||||
|
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<SyncTaskType> = 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(
|
function saveMessageSync(
|
||||||
db: Database,
|
db: Database,
|
||||||
data: MessageType,
|
data: MessageType,
|
||||||
|
@ -6036,6 +6169,7 @@ async function removeAll(): Promise<void> {
|
||||||
DELETE FROM storyDistributionMembers;
|
DELETE FROM storyDistributionMembers;
|
||||||
DELETE FROM storyDistributions;
|
DELETE FROM storyDistributions;
|
||||||
DELETE FROM storyReads;
|
DELETE FROM storyReads;
|
||||||
|
DELETE FROM syncTasks;
|
||||||
DELETE FROM unprocessed;
|
DELETE FROM unprocessed;
|
||||||
DELETE FROM uninstalled_sticker_packs;
|
DELETE FROM uninstalled_sticker_packs;
|
||||||
|
|
||||||
|
@ -6078,6 +6212,7 @@ async function removeAllConfiguration(): Promise<void> {
|
||||||
DELETE FROM sendLogRecipients;
|
DELETE FROM sendLogRecipients;
|
||||||
DELETE FROM sessions;
|
DELETE FROM sessions;
|
||||||
DELETE FROM signedPreKeys;
|
DELETE FROM signedPreKeys;
|
||||||
|
DELETE FROM syncTasks;
|
||||||
DELETE FROM unprocessed;
|
DELETE FROM unprocessed;
|
||||||
`
|
`
|
||||||
);
|
);
|
||||||
|
|
|
@ -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!');
|
||||||
|
}
|
|
@ -80,10 +80,11 @@ import { updateToSchemaVersion1010 } from './1010-call-links-table';
|
||||||
import { updateToSchemaVersion1020 } from './1020-self-merges';
|
import { updateToSchemaVersion1020 } from './1020-self-merges';
|
||||||
import { updateToSchemaVersion1030 } from './1030-unblock-event';
|
import { updateToSchemaVersion1030 } from './1030-unblock-event';
|
||||||
import { updateToSchemaVersion1040 } from './1040-undownloaded-backed-up-media';
|
import { updateToSchemaVersion1040 } from './1040-undownloaded-backed-up-media';
|
||||||
|
import { updateToSchemaVersion1050 } from './1050-group-send-endorsements';
|
||||||
import {
|
import {
|
||||||
updateToSchemaVersion1050,
|
updateToSchemaVersion1060,
|
||||||
version as MAX_VERSION,
|
version as MAX_VERSION,
|
||||||
} from './1050-group-send-endorsements';
|
} from './1060-addressable-messages-and-sync-tasks';
|
||||||
|
|
||||||
function updateToSchemaVersion1(
|
function updateToSchemaVersion1(
|
||||||
currentVersion: number,
|
currentVersion: number,
|
||||||
|
@ -2025,12 +2026,14 @@ export const SCHEMA_VERSIONS = [
|
||||||
updateToSchemaVersion970,
|
updateToSchemaVersion970,
|
||||||
updateToSchemaVersion980,
|
updateToSchemaVersion980,
|
||||||
updateToSchemaVersion990,
|
updateToSchemaVersion990,
|
||||||
|
|
||||||
updateToSchemaVersion1000,
|
updateToSchemaVersion1000,
|
||||||
updateToSchemaVersion1010,
|
updateToSchemaVersion1010,
|
||||||
updateToSchemaVersion1020,
|
updateToSchemaVersion1020,
|
||||||
updateToSchemaVersion1030,
|
updateToSchemaVersion1030,
|
||||||
updateToSchemaVersion1040,
|
updateToSchemaVersion1040,
|
||||||
updateToSchemaVersion1050,
|
updateToSchemaVersion1050,
|
||||||
|
updateToSchemaVersion1060,
|
||||||
];
|
];
|
||||||
|
|
||||||
export class DBVersionFromFutureError extends Error {
|
export class DBVersionFromFutureError extends Error {
|
||||||
|
|
|
@ -3,6 +3,7 @@
|
||||||
|
|
||||||
import type { ThunkAction } from 'redux-thunk';
|
import type { ThunkAction } from 'redux-thunk';
|
||||||
import {
|
import {
|
||||||
|
chunk,
|
||||||
difference,
|
difference,
|
||||||
fromPairs,
|
fromPairs,
|
||||||
isEqual,
|
isEqual,
|
||||||
|
@ -184,6 +185,16 @@ import { getConversationIdForLogging } from '../../util/idForLogging';
|
||||||
import { singleProtoJobQueue } from '../../jobs/singleProtoJobQueue';
|
import { singleProtoJobQueue } from '../../jobs/singleProtoJobQueue';
|
||||||
import MessageSender from '../../textsecure/SendMessage';
|
import MessageSender from '../../textsecure/SendMessage';
|
||||||
import { AttachmentDownloadUrgency } from '../../jobs/AttachmentDownloadManager';
|
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
|
// State
|
||||||
|
|
||||||
|
@ -1703,8 +1714,10 @@ function deleteMessages({
|
||||||
throw new Error('deleteMessage: No conversation found');
|
throw new Error('deleteMessage: No conversation found');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const messages = (
|
||||||
await Promise.all(
|
await Promise.all(
|
||||||
messageIds.map(async messageId => {
|
messageIds.map(
|
||||||
|
async (messageId): Promise<MessageToDelete | undefined> => {
|
||||||
const message = await __DEPRECATED$getMessageById(messageId);
|
const message = await __DEPRECATED$getMessageById(messageId);
|
||||||
if (!message) {
|
if (!message) {
|
||||||
throw new Error(`deleteMessages: Message ${messageId} missing!`);
|
throw new Error(`deleteMessages: Message ${messageId} missing!`);
|
||||||
|
@ -1716,8 +1729,12 @@ function deleteMessages({
|
||||||
`deleteMessages: message conversation ${messageConversationId} doesn't match provided conversation ${conversationId}`
|
`deleteMessages: message conversation ${messageConversationId} doesn't match provided conversation ${conversationId}`
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
})
|
|
||||||
);
|
return getMessageToDelete(message.attributes);
|
||||||
|
}
|
||||||
|
)
|
||||||
|
)
|
||||||
|
).filter(isNotNil);
|
||||||
|
|
||||||
let nearbyMessageId: string | null = null;
|
let nearbyMessageId: string | null = null;
|
||||||
|
|
||||||
|
@ -1743,6 +1760,34 @@ function deleteMessages({
|
||||||
if (nearbyMessageId != null) {
|
if (nearbyMessageId != null) {
|
||||||
dispatch(scrollToMessage(conversationId, nearbyMessageId));
|
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
|
undefined
|
||||||
);
|
);
|
||||||
|
|
||||||
await conversation.destroyMessages();
|
await conversation.destroyMessages({ source: 'local-delete' });
|
||||||
drop(conversation.updateLastMessage());
|
drop(conversation.updateLastMessage());
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
|
@ -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
|
// Note: ts/util/stories is the other place this check is done
|
||||||
export const getStoriesEnabled = createSelector(
|
export const getStoriesEnabled = createSelector(
|
||||||
getItems,
|
getItems,
|
||||||
|
@ -242,3 +249,9 @@ export const getShowStickerPickerHint = createSelector(
|
||||||
return state.showStickerPickerHint ?? false;
|
return state.showStickerPickerHint ?? false;
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
|
export const getLocalDeleteWarningShown = createSelector(
|
||||||
|
getItems,
|
||||||
|
(state: ItemsStateType): boolean =>
|
||||||
|
Boolean(state.localDeleteWarningShown ?? false)
|
||||||
|
);
|
||||||
|
|
|
@ -40,6 +40,11 @@ import {
|
||||||
} from '../selectors/conversations';
|
} from '../selectors/conversations';
|
||||||
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 {
|
||||||
|
getDeleteSyncSendEnabled,
|
||||||
|
getLocalDeleteWarningShown,
|
||||||
|
} from '../selectors/items';
|
||||||
|
|
||||||
export type OwnProps = {
|
export type OwnProps = {
|
||||||
id: string;
|
id: string;
|
||||||
|
@ -146,6 +151,7 @@ export const SmartConversationHeader = memo(function SmartConversationHeader({
|
||||||
const conversationName = useContactNameData(conversation);
|
const conversationName = useContactNameData(conversation);
|
||||||
strictAssert(conversationName, 'conversationName is required');
|
strictAssert(conversationName, 'conversationName is required');
|
||||||
|
|
||||||
|
const isDeleteSyncSendEnabled = useSelector(getDeleteSyncSendEnabled);
|
||||||
const isMissingMandatoryProfileSharing =
|
const isMissingMandatoryProfileSharing =
|
||||||
getIsMissingRequiredProfileSharing(conversation);
|
getIsMissingRequiredProfileSharing(conversation);
|
||||||
|
|
||||||
|
@ -248,6 +254,11 @@ export const SmartConversationHeader = memo(function SmartConversationHeader({
|
||||||
|
|
||||||
const minimalConversation = useMinimalConversation(conversation);
|
const minimalConversation = useMinimalConversation(conversation);
|
||||||
|
|
||||||
|
const localDeleteWarningShown = useSelector(getLocalDeleteWarningShown);
|
||||||
|
const { putItem } = useItemsActions();
|
||||||
|
const setLocalDeleteWarningShown = () =>
|
||||||
|
putItem('localDeleteWarningShown', true);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ConversationHeader
|
<ConversationHeader
|
||||||
addedByName={addedByName}
|
addedByName={addedByName}
|
||||||
|
@ -258,6 +269,8 @@ export const SmartConversationHeader = memo(function SmartConversationHeader({
|
||||||
hasPanelShowing={hasPanelShowing}
|
hasPanelShowing={hasPanelShowing}
|
||||||
hasStories={hasStories}
|
hasStories={hasStories}
|
||||||
i18n={i18n}
|
i18n={i18n}
|
||||||
|
localDeleteWarningShown={localDeleteWarningShown}
|
||||||
|
isDeleteSyncSendEnabled={isDeleteSyncSendEnabled}
|
||||||
isMissingMandatoryProfileSharing={isMissingMandatoryProfileSharing}
|
isMissingMandatoryProfileSharing={isMissingMandatoryProfileSharing}
|
||||||
isSelectMode={isSelectMode}
|
isSelectMode={isSelectMode}
|
||||||
isSignalConversation={isSignalConversation(conversation)}
|
isSignalConversation={isSignalConversation(conversation)}
|
||||||
|
@ -287,6 +300,7 @@ export const SmartConversationHeader = memo(function SmartConversationHeader({
|
||||||
onViewRecentMedia={onViewRecentMedia}
|
onViewRecentMedia={onViewRecentMedia}
|
||||||
onViewUserStories={onViewUserStories}
|
onViewUserStories={onViewUserStories}
|
||||||
outgoingCallButtonStyle={outgoingCallButtonStyle}
|
outgoingCallButtonStyle={outgoingCallButtonStyle}
|
||||||
|
setLocalDeleteWarningShown={setLocalDeleteWarningShown}
|
||||||
sharedGroupNames={conversation.sharedGroupNames}
|
sharedGroupNames={conversation.sharedGroupNames}
|
||||||
theme={theme}
|
theme={theme}
|
||||||
/>
|
/>
|
||||||
|
|
|
@ -16,6 +16,12 @@ import {
|
||||||
getLastSelectedMessage,
|
getLastSelectedMessage,
|
||||||
} from '../selectors/conversations';
|
} from '../selectors/conversations';
|
||||||
import { getDeleteMessagesProps } from '../selectors/globalModals';
|
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(
|
export const SmartDeleteMessagesModal = memo(
|
||||||
function SmartDeleteMessagesModal() {
|
function SmartDeleteMessagesModal() {
|
||||||
|
@ -36,6 +42,7 @@ export const SmartDeleteMessagesModal = memo(
|
||||||
[messageIds, isMe]
|
[messageIds, isMe]
|
||||||
);
|
);
|
||||||
const canDeleteForEveryone = useSelector(getCanDeleteForEveryone);
|
const canDeleteForEveryone = useSelector(getCanDeleteForEveryone);
|
||||||
|
const isDeleteSyncSendEnabled = useSelector(getDeleteSyncSendEnabled);
|
||||||
const lastSelectedMessage = useSelector(getLastSelectedMessage);
|
const lastSelectedMessage = useSelector(getLastSelectedMessage);
|
||||||
const i18n = useSelector(getIntl);
|
const i18n = useSelector(getIntl);
|
||||||
const { toggleDeleteMessagesModal } = useGlobalModalActions();
|
const { toggleDeleteMessagesModal } = useGlobalModalActions();
|
||||||
|
@ -69,11 +76,25 @@ export const SmartDeleteMessagesModal = memo(
|
||||||
onDelete?.();
|
onDelete?.();
|
||||||
}, [deleteMessagesForEveryone, messageIds, onDelete]);
|
}, [deleteMessagesForEveryone, messageIds, onDelete]);
|
||||||
|
|
||||||
|
const localDeleteWarningShown = useSelector(getLocalDeleteWarningShown);
|
||||||
|
const { putItem } = useItemsActions();
|
||||||
|
if (!localDeleteWarningShown && isDeleteSyncSendEnabled) {
|
||||||
|
return (
|
||||||
|
<LocalDeleteWarningModal
|
||||||
|
i18n={i18n}
|
||||||
|
onClose={() => {
|
||||||
|
putItem('localDeleteWarningShown', true);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<DeleteMessagesModal
|
<DeleteMessagesModal
|
||||||
isMe={isMe}
|
isMe={isMe}
|
||||||
canDeleteForEveryone={canDeleteForEveryone}
|
canDeleteForEveryone={canDeleteForEveryone}
|
||||||
i18n={i18n}
|
i18n={i18n}
|
||||||
|
isDeleteSyncSendEnabled={isDeleteSyncSendEnabled}
|
||||||
messageCount={messageCount}
|
messageCount={messageCount}
|
||||||
onClose={handleClose}
|
onClose={handleClose}
|
||||||
onDeleteForMe={handleDeleteForMe}
|
onDeleteForMe={handleDeleteForMe}
|
||||||
|
|
|
@ -67,7 +67,7 @@ describe('KeyChangeListener', () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
afterEach(async () => {
|
afterEach(async () => {
|
||||||
await window.Signal.Data.removeAllMessagesInConversation(convo.id, {
|
await window.Signal.Data.removeMessagesInConversation(convo.id, {
|
||||||
logId: ourServiceIdWithKeyChange,
|
logId: ourServiceIdWithKeyChange,
|
||||||
});
|
});
|
||||||
await window.Signal.Data.removeConversation(convo.id);
|
await window.Signal.Data.removeConversation(convo.id);
|
||||||
|
@ -104,7 +104,7 @@ describe('KeyChangeListener', () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
afterEach(async () => {
|
afterEach(async () => {
|
||||||
await window.Signal.Data.removeAllMessagesInConversation(groupConvo.id, {
|
await window.Signal.Data.removeMessagesInConversation(groupConvo.id, {
|
||||||
logId: ourServiceIdWithKeyChange,
|
logId: ourServiceIdWithKeyChange,
|
||||||
});
|
});
|
||||||
await window.Signal.Data.removeConversation(groupConvo.id);
|
await window.Signal.Data.removeConversation(groupConvo.id);
|
||||||
|
|
302
ts/test-node/sql/migration_1060_test.ts
Normal file
302
ts/test-node/sql/migration_1060_test.ts
Normal file
|
@ -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<SyncTaskType> = [
|
||||||
|
{
|
||||||
|
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<SyncTaskType> = [
|
||||||
|
{
|
||||||
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
|
@ -130,6 +130,13 @@ import {
|
||||||
StoryRecipientUpdateEvent,
|
StoryRecipientUpdateEvent,
|
||||||
CallLogEventSyncEvent,
|
CallLogEventSyncEvent,
|
||||||
CallLinkUpdateSyncEvent,
|
CallLinkUpdateSyncEvent,
|
||||||
|
DeleteForMeSyncEvent,
|
||||||
|
} from './messageReceiverEvents';
|
||||||
|
import type {
|
||||||
|
MessageToDelete,
|
||||||
|
DeleteForMeSyncEventData,
|
||||||
|
DeleteForMeSyncTarget,
|
||||||
|
ConversationToDelete,
|
||||||
} 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';
|
||||||
|
@ -686,6 +693,11 @@ export default class MessageReceiver
|
||||||
handler: (ev: CallLogEventSyncEvent) => void
|
handler: (ev: CallLogEventSyncEvent) => void
|
||||||
): void;
|
): void;
|
||||||
|
|
||||||
|
public override addEventListener(
|
||||||
|
name: 'deleteForMeSync',
|
||||||
|
handler: (ev: DeleteForMeSyncEvent) => void
|
||||||
|
): void;
|
||||||
|
|
||||||
public override addEventListener(name: string, handler: EventHandler): void {
|
public override addEventListener(name: string, handler: EventHandler): void {
|
||||||
return super.addEventListener(name, handler);
|
return super.addEventListener(name, handler);
|
||||||
}
|
}
|
||||||
|
@ -3165,6 +3177,9 @@ export default class MessageReceiver
|
||||||
if (syncMessage.callLogEvent) {
|
if (syncMessage.callLogEvent) {
|
||||||
return this.handleCallLogEvent(envelope, syncMessage.callLogEvent);
|
return this.handleCallLogEvent(envelope, syncMessage.callLogEvent);
|
||||||
}
|
}
|
||||||
|
if (syncMessage.deleteForMe) {
|
||||||
|
return this.handleDeleteForMeSync(envelope, syncMessage.deleteForMe);
|
||||||
|
}
|
||||||
|
|
||||||
this.removeFromCache(envelope);
|
this.removeFromCache(envelope);
|
||||||
const envelopeId = getEnvelopeId(envelope);
|
const envelopeId = getEnvelopeId(envelope);
|
||||||
|
@ -3615,6 +3630,118 @@ export default class MessageReceiver
|
||||||
log.info('handleCallLogEvent: finished');
|
log.info('handleCallLogEvent: finished');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async handleDeleteForMeSync(
|
||||||
|
envelope: ProcessedEnvelope,
|
||||||
|
deleteSync: Proto.SyncMessage.IDeleteForMe
|
||||||
|
): Promise<void> {
|
||||||
|
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<DeleteForMeSyncTarget> =
|
||||||
|
deleteSync.messageDeletes
|
||||||
|
.flatMap((item): Array<DeleteForMeSyncTarget> | 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<DeleteForMeSyncTarget> =
|
||||||
|
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<DeleteForMeSyncTarget> =
|
||||||
|
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(
|
private async handleContacts(
|
||||||
envelope: ProcessedEnvelope,
|
envelope: ProcessedEnvelope,
|
||||||
contactSyncProto: Proto.SyncMessage.IContacts
|
contactSyncProto: Proto.SyncMessage.IContacts
|
||||||
|
@ -3820,3 +3947,70 @@ function envelopeTypeToCiphertextType(type: number | undefined): number {
|
||||||
|
|
||||||
throw new Error(`envelopeTypeToCiphertextType: Unknown type ${type}`);
|
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;
|
||||||
|
}
|
||||||
|
|
|
@ -82,6 +82,13 @@ import {
|
||||||
} from '../types/EmbeddedContact';
|
} from '../types/EmbeddedContact';
|
||||||
import { missingCaseError } from '../util/missingCaseError';
|
import { missingCaseError } from '../util/missingCaseError';
|
||||||
import { drop } from '../util/drop';
|
import { drop } from '../util/drop';
|
||||||
|
import type {
|
||||||
|
ConversationToDelete,
|
||||||
|
DeleteForMeSyncEventData,
|
||||||
|
DeleteMessageSyncTarget,
|
||||||
|
MessageToDelete,
|
||||||
|
} from './messageReceiverEvents';
|
||||||
|
import { getConversationFromTarget } from '../util/deleteForMe';
|
||||||
|
|
||||||
export type SendMetadataType = {
|
export type SendMetadataType = {
|
||||||
[serviceId: ServiceIdString]: {
|
[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<DeleteMessageSyncTarget>
|
||||||
|
> = 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(
|
async syncReadMessages(
|
||||||
reads: ReadonlyArray<{
|
reads: ReadonlyArray<{
|
||||||
senderAci?: AciString;
|
senderAci?: AciString;
|
||||||
|
@ -2253,3 +2345,37 @@ export default class MessageSender {
|
||||||
return this.server.sendChallengeResponse(challengeResponse);
|
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;
|
||||||
|
}
|
||||||
|
|
|
@ -3,6 +3,7 @@
|
||||||
/* eslint-disable max-classes-per-file */
|
/* eslint-disable max-classes-per-file */
|
||||||
|
|
||||||
import type { PublicKey } from '@signalapp/libsignal-client';
|
import type { PublicKey } from '@signalapp/libsignal-client';
|
||||||
|
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, AciString } from '../types/ServiceId';
|
||||||
|
@ -15,6 +16,7 @@ import type {
|
||||||
import type { ContactDetailsWithAvatar } from './ContactsParser';
|
import type { ContactDetailsWithAvatar } from './ContactsParser';
|
||||||
import type { CallEventDetails, CallLogEvent } from '../types/CallDisposition';
|
import type { CallEventDetails, CallLogEvent } from '../types/CallDisposition';
|
||||||
import type { CallLinkUpdateSyncType } from '../types/CallLink';
|
import type { CallLinkUpdateSyncType } from '../types/CallLink';
|
||||||
|
import { isAciString } from '../util/isAciString';
|
||||||
|
|
||||||
export class EmptyEvent extends Event {
|
export class EmptyEvent extends Event {
|
||||||
constructor() {
|
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<typeof messageToDeleteSchema>;
|
||||||
|
|
||||||
|
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<typeof conversationToDeleteSchema>;
|
||||||
|
|
||||||
|
export const deleteMessageSchema = z.object({
|
||||||
|
type: z.literal('delete-message').readonly(),
|
||||||
|
conversation: conversationToDeleteSchema,
|
||||||
|
message: messageToDeleteSchema,
|
||||||
|
timestamp: z.number(),
|
||||||
|
});
|
||||||
|
export type DeleteMessageSyncTarget = z.infer<typeof deleteMessageSchema>;
|
||||||
|
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<typeof deleteForMeSyncTargetSchema>;
|
||||||
|
|
||||||
|
export type DeleteForMeSyncEventData = ReadonlyArray<DeleteForMeSyncTarget>;
|
||||||
|
|
||||||
|
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<{
|
export type CallLogEventSyncEventData = Readonly<{
|
||||||
event: CallLogEvent;
|
event: CallLogEvent;
|
||||||
timestamp: number;
|
timestamp: number;
|
||||||
|
|
1
ts/types/Storage.d.ts
vendored
1
ts/types/Storage.d.ts
vendored
|
@ -79,6 +79,7 @@ export type StorageAccessType = {
|
||||||
lastAttemptedToRefreshProfilesAt: number;
|
lastAttemptedToRefreshProfilesAt: number;
|
||||||
lastResortKeyUpdateTime: number;
|
lastResortKeyUpdateTime: number;
|
||||||
lastResortKeyUpdateTimePNI: number;
|
lastResortKeyUpdateTimePNI: number;
|
||||||
|
localDeleteWarningShown: boolean;
|
||||||
masterKey: string;
|
masterKey: string;
|
||||||
masterKeyLastRequestTime: number;
|
masterKeyLastRequestTime: number;
|
||||||
maxPreKeyId: number;
|
maxPreKeyId: number;
|
||||||
|
|
|
@ -23,6 +23,7 @@ export const STORAGE_UI_KEYS: ReadonlyArray<keyof StorageAccessType> = [
|
||||||
'hasCompletedSafetyNumberOnboarding',
|
'hasCompletedSafetyNumberOnboarding',
|
||||||
'hasCompletedUsernameLinkOnboarding',
|
'hasCompletedUsernameLinkOnboarding',
|
||||||
'hide-menu-bar',
|
'hide-menu-bar',
|
||||||
|
'localDeleteWarningShown',
|
||||||
'incoming-call-notification',
|
'incoming-call-notification',
|
||||||
'navTabsCollapsed',
|
'navTabsCollapsed',
|
||||||
'notification-draw-attention',
|
'notification-draw-attention',
|
||||||
|
|
261
ts/util/deleteForMe.ts
Normal file
261
ts/util/deleteForMe.ts
Normal file
|
@ -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<number>;
|
||||||
|
}): 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<MessageAttributesType | undefined> {
|
||||||
|
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<boolean> {
|
||||||
|
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<MessageToDelete>,
|
||||||
|
isFullDelete: boolean,
|
||||||
|
logId: string
|
||||||
|
): Promise<boolean> {
|
||||||
|
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<boolean> {
|
||||||
|
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;
|
||||||
|
}
|
4
ts/util/deleteForMe.types.ts
Normal file
4
ts/util/deleteForMe.types.ts
Normal file
|
@ -0,0 +1,4 @@
|
||||||
|
// Copyright 2020 Signal Messenger, LLC
|
||||||
|
// SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
|
||||||
|
export const MAX_MESSAGE_COUNT = 500;
|
|
@ -55,6 +55,7 @@ export const sendTypesEnum = z.enum([
|
||||||
'pniIdentitySync',
|
'pniIdentitySync',
|
||||||
|
|
||||||
// Syncs, default non-urgent
|
// Syncs, default non-urgent
|
||||||
|
'deleteForMeSync',
|
||||||
'fetchLatestManifestSync',
|
'fetchLatestManifestSync',
|
||||||
'fetchLocalProfileSync',
|
'fetchLocalProfileSync',
|
||||||
'messageRequestSync',
|
'messageRequestSync',
|
||||||
|
|
|
@ -9,6 +9,7 @@ import type { SendStateByConversationId } from '../messages/MessageSendState';
|
||||||
import * as Edits from '../messageModifiers/Edits';
|
import * as Edits from '../messageModifiers/Edits';
|
||||||
import * as log from '../logging/log';
|
import * as log from '../logging/log';
|
||||||
import * as Deletes from '../messageModifiers/Deletes';
|
import * as Deletes from '../messageModifiers/Deletes';
|
||||||
|
import * as DeletesForMe from '../messageModifiers/DeletesForMe';
|
||||||
import * as MessageReceipts from '../messageModifiers/MessageReceipts';
|
import * as MessageReceipts from '../messageModifiers/MessageReceipts';
|
||||||
import * as Reactions from '../messageModifiers/Reactions';
|
import * as Reactions from '../messageModifiers/Reactions';
|
||||||
import * as ReadSyncs from '../messageModifiers/ReadSyncs';
|
import * as ReadSyncs from '../messageModifiers/ReadSyncs';
|
||||||
|
@ -29,6 +30,12 @@ import { missingCaseError } from './missingCaseError';
|
||||||
import { reduce } from './iterables';
|
import { reduce } from './iterables';
|
||||||
import { strictAssert } from './assert';
|
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
|
// 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
|
// saveAndNotify, a function called at the end of handleDataMessage as a cleanup for
|
||||||
// any missed out-of-order events.
|
// any missed out-of-order events.
|
||||||
|
@ -36,7 +43,7 @@ export async function modifyTargetMessage(
|
||||||
message: MessageModel,
|
message: MessageModel,
|
||||||
conversation: ConversationModel,
|
conversation: ConversationModel,
|
||||||
options?: { isFirstRun: boolean; skipEdits: boolean }
|
options?: { isFirstRun: boolean; skipEdits: boolean }
|
||||||
): Promise<void> {
|
): Promise<ModifyTargetMessageResult> {
|
||||||
const { isFirstRun = false, skipEdits = false } = options ?? {};
|
const { isFirstRun = false, skipEdits = false } = options ?? {};
|
||||||
|
|
||||||
const logId = `modifyTargetMessage/${message.idForLogging()}`;
|
const logId = `modifyTargetMessage/${message.idForLogging()}`;
|
||||||
|
@ -45,6 +52,15 @@ export async function modifyTargetMessage(
|
||||||
const ourAci = window.textsecure.storage.user.getCheckedAci();
|
const ourAci = window.textsecure.storage.user.getCheckedAci();
|
||||||
const sourceServiceId = getSourceServiceId(message.attributes);
|
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)) {
|
if (type === 'outgoing' || (type === 'story' && ourAci === sourceServiceId)) {
|
||||||
const sendActions = MessageReceipts.forMessage(message).map(receipt => {
|
const sendActions = MessageReceipts.forMessage(message).map(receipt => {
|
||||||
let sendActionType: SendActionType;
|
let sendActionType: SendActionType;
|
||||||
|
@ -274,4 +290,8 @@ export async function modifyTargetMessage(
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return changed
|
||||||
|
? ModifyTargetMessageResult.Modified
|
||||||
|
: ModifyTargetMessageResult.NotModified;
|
||||||
}
|
}
|
||||||
|
|
138
ts/util/syncTasks.ts
Normal file
138
ts/util/syncTasks.ts
Normal file
|
@ -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<typeof syncTaskDataSchema>;
|
||||||
|
|
||||||
|
export type SyncTaskType = Readonly<{
|
||||||
|
id: string;
|
||||||
|
attempts: number;
|
||||||
|
createdAt: number;
|
||||||
|
data: unknown;
|
||||||
|
envelopeId: string;
|
||||||
|
sentAt: number;
|
||||||
|
type: SyncTaskData['type'];
|
||||||
|
}>;
|
||||||
|
|
||||||
|
const SCHEMAS_BY_TYPE: Record<SyncTaskData['type'], ZodSchema> = {
|
||||||
|
'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<SyncTaskType>,
|
||||||
|
removeSyncTaskById: (id: string) => Promise<void>
|
||||||
|
): Promise<void> {
|
||||||
|
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}`);
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
4
ts/util/syncTasks.types.ts
Normal file
4
ts/util/syncTasks.types.ts
Normal file
|
@ -0,0 +1,4 @@
|
||||||
|
// Copyright 2020 Signal Messenger, LLC
|
||||||
|
// SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
|
||||||
|
export const MAX_SYNC_TASK_ATTEMPTS = 5;
|
Loading…
Add table
Add a link
Reference in a new issue