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?",
|
||||
"description": "within note to self conversation > delete selected messages > confirmation modal > description"
|
||||
},
|
||||
"icu:DeleteMessagesModal--description--noteToSelf--deleteSync": {
|
||||
"messageformat": "{count, plural, one {This message} other {These messages}} will be deleted from all your devices.",
|
||||
"description": "within note to self conversation > delete selected messages > confirmation modal > description"
|
||||
},
|
||||
"icu:DeleteMessagesModal--deleteForMe": {
|
||||
"messageformat": "Delete for me",
|
||||
"description": "delete selected messages > confirmation modal > delete for me"
|
||||
|
@ -5026,6 +5030,10 @@
|
|||
"messageformat": "Delete from all devices",
|
||||
"description": "within note to self conversation > delete selected messages > confirmation modal > delete from all devices (same as delete for everyone)"
|
||||
},
|
||||
"icu:DeleteMessagesModal--noteToSelf--deleteSync": {
|
||||
"messageformat": "Delete",
|
||||
"description": "When delete sync is enabled, there is only one Delete option in Note to Self"
|
||||
},
|
||||
"icu:DeleteMessagesModal__toast--TooManyMessagesToDeleteForEveryone": {
|
||||
"messageformat": "You can only select up to {count, plural, one {# message} other {# messages}} to delete for everyone",
|
||||
"description": "delete selected messages > confirmation modal > deleted for everyone (disabled) > toast > too many messages to 'delete for everyone'"
|
||||
|
@ -6292,6 +6300,18 @@
|
|||
"messageformat": "Your connections can see your name and photo, and can see posts to \"My Story\" unless you hide it from them",
|
||||
"description": "Additional information about signal connections and the stories they can see"
|
||||
},
|
||||
"icu:LocalDeleteWarningModal__header": {
|
||||
"messageformat": "\"Delete for Me\" now deletes from all of your devices",
|
||||
"description": "Emphasized text at the top of the explainer dialog you get when you first delete a message or conversation"
|
||||
},
|
||||
"icu:LocalDeleteWarningModal__description": {
|
||||
"messageformat": "When you delete a message in a chat, the message will be deleted from your phone and all linked devices.",
|
||||
"description": "More detailed description of new delete behavior shown in explainer dialog"
|
||||
},
|
||||
"icu:LocalDeleteWarningModal__confirm": {
|
||||
"messageformat": "Got it",
|
||||
"description": "Button to dismiss the dialog explaining that 'delete for me' now syncs between devices"
|
||||
},
|
||||
"icu:Stories__title": {
|
||||
"messageformat": "Stories",
|
||||
"description": "Title for the stories list"
|
||||
|
|
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;
|
||||
}
|
||||
|
||||
message DeleteForMe {
|
||||
message ConversationIdentifier {
|
||||
oneof identifier {
|
||||
string threadAci = 1;
|
||||
bytes threadGroupId = 2;
|
||||
string threadE164 = 3;
|
||||
}
|
||||
}
|
||||
|
||||
message AddressableMessage {
|
||||
oneof author {
|
||||
string authorAci = 1;
|
||||
string authorE164 = 2;
|
||||
}
|
||||
optional uint64 sentTimestamp = 3;
|
||||
}
|
||||
|
||||
message MessageDeletes {
|
||||
optional ConversationIdentifier conversation = 1;
|
||||
repeated AddressableMessage messages = 2;
|
||||
}
|
||||
|
||||
message ConversationDelete {
|
||||
optional ConversationIdentifier conversation = 1;
|
||||
repeated AddressableMessage mostRecentMessages = 2;
|
||||
optional bool isFullDelete = 3;
|
||||
}
|
||||
|
||||
message LocalOnlyConversationDelete {
|
||||
optional ConversationIdentifier conversation = 1;
|
||||
}
|
||||
|
||||
repeated MessageDeletes messageDeletes = 1;
|
||||
repeated ConversationDelete conversationDeletes = 2;
|
||||
repeated LocalOnlyConversationDelete localOnlyConversationDeletes = 3;
|
||||
}
|
||||
|
||||
optional Sent sent = 1;
|
||||
optional Contacts contacts = 2;
|
||||
reserved /* groups */ 3;
|
||||
|
@ -654,6 +691,7 @@ message SyncMessage {
|
|||
optional CallEvent callEvent = 19;
|
||||
optional CallLinkUpdate callLinkUpdate = 20;
|
||||
optional CallLogEvent callLogEvent = 21;
|
||||
optional DeleteForMe deleteForMe = 22;
|
||||
}
|
||||
|
||||
message AttachmentPointer {
|
||||
|
|
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/Lightbox.scss';
|
||||
@import './components/ListTile.scss';
|
||||
@import './components/LocalDeleteWarningModal.scss';
|
||||
@import './components/MediaEditor.scss';
|
||||
@import './components/MediaQualitySelector.scss';
|
||||
@import './components/MessageAudio.scss';
|
||||
|
|
|
@ -50,7 +50,7 @@ export async function populateConversationWithMessages({
|
|||
);
|
||||
|
||||
log.info(`${logId}: destroying all messages in ${conversationId}`);
|
||||
await conversation.destroyMessages();
|
||||
await conversation.destroyMessages({ source: 'local-delete' });
|
||||
|
||||
log.info(`${logId}: adding ${messageCount} messages to ${conversationId}`);
|
||||
let timestamp = Date.now();
|
||||
|
|
|
@ -18,6 +18,8 @@ export type ConfigKeyType =
|
|||
| 'desktop.calling.adhoc'
|
||||
| 'desktop.clientExpiration'
|
||||
| 'desktop.backup.credentialFetch'
|
||||
| 'desktop.deleteSync.send'
|
||||
| 'desktop.deleteSync.receive'
|
||||
| 'desktop.groupMultiTypingIndicators'
|
||||
| 'desktop.internalUser'
|
||||
| 'desktop.mediaQuality.levels'
|
||||
|
|
|
@ -86,6 +86,7 @@ import type {
|
|||
FetchLatestEvent,
|
||||
InvalidPlaintextEvent,
|
||||
KeysEvent,
|
||||
DeleteForMeSyncEvent,
|
||||
MessageEvent,
|
||||
MessageEventData,
|
||||
MessageRequestResponseEvent,
|
||||
|
@ -111,21 +112,21 @@ import type { BadgesStateType } from './state/ducks/badges';
|
|||
import { areAnyCallsActiveOrRinging } from './state/selectors/calling';
|
||||
import { badgeImageFileDownloader } from './badges/badgeImageFileDownloader';
|
||||
import * as Deletes from './messageModifiers/Deletes';
|
||||
import type { EditAttributesType } from './messageModifiers/Edits';
|
||||
import * as Edits from './messageModifiers/Edits';
|
||||
import type { ReactionAttributesType } from './messageModifiers/Reactions';
|
||||
import * as MessageReceipts from './messageModifiers/MessageReceipts';
|
||||
import * as MessageRequests from './messageModifiers/MessageRequests';
|
||||
import * as Reactions from './messageModifiers/Reactions';
|
||||
import * as ReadSyncs from './messageModifiers/ReadSyncs';
|
||||
import * as ViewSyncs from './messageModifiers/ViewSyncs';
|
||||
import * as ViewOnceOpenSyncs from './messageModifiers/ViewOnceOpenSyncs';
|
||||
import * as ViewSyncs from './messageModifiers/ViewSyncs';
|
||||
import type { DeleteAttributesType } from './messageModifiers/Deletes';
|
||||
import type { EditAttributesType } from './messageModifiers/Edits';
|
||||
import type { MessageReceiptAttributesType } from './messageModifiers/MessageReceipts';
|
||||
import type { MessageRequestAttributesType } from './messageModifiers/MessageRequests';
|
||||
import type { ReactionAttributesType } from './messageModifiers/Reactions';
|
||||
import type { ReadSyncAttributesType } from './messageModifiers/ReadSyncs';
|
||||
import type { ViewSyncAttributesType } from './messageModifiers/ViewSyncs';
|
||||
import type { ViewOnceOpenSyncAttributesType } from './messageModifiers/ViewOnceOpenSyncs';
|
||||
import type { ViewSyncAttributesType } from './messageModifiers/ViewSyncs';
|
||||
import { ReadStatus } from './messages/MessageReadStatus';
|
||||
import type { SendStateByConversationId } from './messages/MessageSendState';
|
||||
import { SendStatus } from './messages/MessageSendState';
|
||||
|
@ -201,6 +202,8 @@ import { getThemeType } from './util/getThemeType';
|
|||
import { AttachmentDownloadManager } from './jobs/AttachmentDownloadManager';
|
||||
import { onCallLinkUpdateSync } from './util/onCallLinkUpdateSync';
|
||||
import { CallMode } from './types/Calling';
|
||||
import { queueSyncTasks } from './util/syncTasks';
|
||||
import { isEnabled } from './RemoteConfig';
|
||||
|
||||
export function isOverHourIntoPast(timestamp: number): boolean {
|
||||
return isNumber(timestamp) && isOlderThan(timestamp, HOUR);
|
||||
|
@ -558,6 +561,24 @@ export async function startApp(): Promise<void> {
|
|||
storage: window.storage,
|
||||
serverTrustRoot: window.getServerTrustRoot(),
|
||||
});
|
||||
const onFirstEmpty = async () => {
|
||||
log.info('onFirstEmpty: Starting');
|
||||
|
||||
// We want to remove this handler on the next tick so we don't interfere with
|
||||
// the other handlers being notified of this instance of the 'empty' event.
|
||||
setTimeout(() => {
|
||||
messageReceiver?.removeEventListener('empty', onFirstEmpty);
|
||||
}, 1);
|
||||
|
||||
log.info('onFirstEmpty: Fetching sync tasks');
|
||||
const syncTasks = await window.Signal.Data.getAllSyncTasks();
|
||||
|
||||
log.info(`onFirstEmpty: Queuing ${syncTasks.length} sync tasks`);
|
||||
await queueSyncTasks(syncTasks, window.Signal.Data.removeSyncTaskById);
|
||||
|
||||
log.info('onFirstEmpty: Done');
|
||||
};
|
||||
messageReceiver.addEventListener('empty', onFirstEmpty);
|
||||
|
||||
function queuedEventListener<E extends Event>(
|
||||
handler: (event: E) => Promise<void> | void,
|
||||
|
@ -691,6 +712,10 @@ export async function startApp(): Promise<void> {
|
|||
'callLogEventSync',
|
||||
queuedEventListener(onCallLogEventSync, false)
|
||||
);
|
||||
messageReceiver.addEventListener(
|
||||
'deleteForMeSync',
|
||||
queuedEventListener(onDeleteForMeSync, false)
|
||||
);
|
||||
|
||||
if (!window.storage.get('defaultConversationColor')) {
|
||||
drop(
|
||||
|
@ -3384,6 +3409,41 @@ export async function startApp(): Promise<void> {
|
|||
|
||||
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;
|
||||
|
|
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 { ToastType } from '../types/Toast';
|
||||
|
||||
type DeleteMessagesModalProps = Readonly<{
|
||||
export type DeleteMessagesModalProps = Readonly<{
|
||||
isMe: boolean;
|
||||
isDeleteSyncSendEnabled: boolean;
|
||||
canDeleteForEveryone: boolean;
|
||||
i18n: LocalizerType;
|
||||
messageCount: number;
|
||||
|
@ -23,6 +24,7 @@ const MAX_DELETE_FOR_EVERYONE = 30;
|
|||
|
||||
export default function DeleteMessagesModal({
|
||||
isMe,
|
||||
isDeleteSyncSendEnabled,
|
||||
canDeleteForEveryone,
|
||||
i18n,
|
||||
messageCount,
|
||||
|
@ -33,15 +35,22 @@ export default function DeleteMessagesModal({
|
|||
}: DeleteMessagesModalProps): JSX.Element {
|
||||
const actions: Array<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({
|
||||
action: onDeleteForMe,
|
||||
style: 'negative',
|
||||
text: isMe
|
||||
? i18n('icu:DeleteMessagesModal--deleteFromThisDevice')
|
||||
: i18n('icu:DeleteMessagesModal--deleteForMe'),
|
||||
text: deleteForMeText,
|
||||
});
|
||||
|
||||
if (canDeleteForEveryone) {
|
||||
if (canDeleteForEveryone && !syncNoteToSelfDelete) {
|
||||
const tooManyMessages = messageCount > MAX_DELETE_FOR_EVERYONE;
|
||||
actions.push({
|
||||
'aria-disabled': tooManyMessages,
|
||||
|
@ -63,6 +72,20 @@ export default function DeleteMessagesModal({
|
|||
});
|
||||
}
|
||||
|
||||
let descriptionText = i18n('icu:DeleteMessagesModal--description', {
|
||||
count: messageCount,
|
||||
});
|
||||
if (syncNoteToSelfDelete) {
|
||||
descriptionText = i18n(
|
||||
'icu:DeleteMessagesModal--description--noteToSelf--deleteSync',
|
||||
{ count: messageCount }
|
||||
);
|
||||
} else if (isMe) {
|
||||
descriptionText = i18n('icu:DeleteMessagesModal--description--noteToSelf', {
|
||||
count: messageCount,
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<ConfirmationDialog
|
||||
actions={actions}
|
||||
|
@ -74,13 +97,7 @@ export default function DeleteMessagesModal({
|
|||
})}
|
||||
moduleClassName="DeleteMessagesModal"
|
||||
>
|
||||
{isMe
|
||||
? i18n('icu:DeleteMessagesModal--description--noteToSelf', {
|
||||
count: messageCount,
|
||||
})
|
||||
: i18n('icu:DeleteMessagesModal--description', {
|
||||
count: messageCount,
|
||||
})}
|
||||
{descriptionText}
|
||||
</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,
|
||||
|
||||
localDeleteWarningShown: true,
|
||||
isDeleteSyncSendEnabled: true,
|
||||
setLocalDeleteWarningShown: action('setLocalDeleteWarningShown'),
|
||||
|
||||
onConversationAccept: action('onConversationAccept'),
|
||||
onConversationArchive: action('onConversationArchive'),
|
||||
onConversationBlock: action('onConversationBlock'),
|
||||
|
@ -412,3 +416,32 @@ export function Unaccepted(): JSX.Element {
|
|||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export function NeedsDeleteConfirmation(): JSX.Element {
|
||||
const [localDeleteWarningShown, setLocalDeleteWarningShown] =
|
||||
React.useState(false);
|
||||
const props = {
|
||||
...commonProps,
|
||||
conversation: getDefaultConversation(),
|
||||
localDeleteWarningShown,
|
||||
setLocalDeleteWarningShown: () => setLocalDeleteWarningShown(true),
|
||||
};
|
||||
const theme = useContext(StorybookThemeContext);
|
||||
|
||||
return <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,
|
||||
} from './MessageRequestActionsConfirmation';
|
||||
import type { MinimalConversation } from '../../hooks/useMinimalConversation';
|
||||
import { LocalDeleteWarningModal } from '../LocalDeleteWarningModal';
|
||||
|
||||
function HeaderInfoTitle({
|
||||
name,
|
||||
|
@ -92,6 +93,8 @@ export type PropsDataType = {
|
|||
conversationName: ContactNameData;
|
||||
hasPanelShowing?: boolean;
|
||||
hasStories?: HasStories;
|
||||
localDeleteWarningShown: boolean;
|
||||
isDeleteSyncSendEnabled: boolean;
|
||||
isMissingMandatoryProfileSharing?: boolean;
|
||||
isSelectMode: boolean;
|
||||
isSignalConversation?: boolean;
|
||||
|
@ -102,6 +105,8 @@ export type PropsDataType = {
|
|||
};
|
||||
|
||||
export type PropsActionsType = {
|
||||
setLocalDeleteWarningShown: () => void;
|
||||
|
||||
onConversationAccept: () => void;
|
||||
onConversationArchive: () => void;
|
||||
onConversationBlock: () => void;
|
||||
|
@ -147,10 +152,12 @@ export const ConversationHeader = memo(function ConversationHeader({
|
|||
hasPanelShowing,
|
||||
hasStories,
|
||||
i18n,
|
||||
isDeleteSyncSendEnabled,
|
||||
isMissingMandatoryProfileSharing,
|
||||
isSelectMode,
|
||||
isSignalConversation,
|
||||
isSMSOnly,
|
||||
localDeleteWarningShown,
|
||||
onConversationAccept,
|
||||
onConversationArchive,
|
||||
onConversationBlock,
|
||||
|
@ -174,6 +181,7 @@ export const ConversationHeader = memo(function ConversationHeader({
|
|||
onViewRecentMedia,
|
||||
onViewUserStories,
|
||||
outgoingCallButtonStyle,
|
||||
setLocalDeleteWarningShown,
|
||||
sharedGroupNames,
|
||||
theme,
|
||||
}: PropsType): JSX.Element | null {
|
||||
|
@ -223,13 +231,16 @@ export const ConversationHeader = memo(function ConversationHeader({
|
|||
{hasDeleteMessagesConfirmation && (
|
||||
<DeleteMessagesConfirmationDialog
|
||||
i18n={i18n}
|
||||
onDestoryMessages={() => {
|
||||
isDeleteSyncSendEnabled={isDeleteSyncSendEnabled}
|
||||
localDeleteWarningShown={localDeleteWarningShown}
|
||||
onDestroyMessages={() => {
|
||||
setHasDeleteMessagesConfirmation(false);
|
||||
onConversationDeleteMessages();
|
||||
}}
|
||||
onClose={() => {
|
||||
setHasDeleteMessagesConfirmation(false);
|
||||
}}
|
||||
setLocalDeleteWarningShown={setLocalDeleteWarningShown}
|
||||
/>
|
||||
)}
|
||||
{hasLeaveGroupConfirmation && (
|
||||
|
@ -923,14 +934,29 @@ function CannotLeaveGroupBecauseYouAreLastAdminAlert({
|
|||
}
|
||||
|
||||
function DeleteMessagesConfirmationDialog({
|
||||
isDeleteSyncSendEnabled,
|
||||
i18n,
|
||||
onDestoryMessages,
|
||||
localDeleteWarningShown,
|
||||
onDestroyMessages,
|
||||
onClose,
|
||||
setLocalDeleteWarningShown,
|
||||
}: {
|
||||
isDeleteSyncSendEnabled: boolean;
|
||||
i18n: LocalizerType;
|
||||
onDestoryMessages: () => void;
|
||||
localDeleteWarningShown: boolean;
|
||||
onDestroyMessages: () => void;
|
||||
onClose: () => void;
|
||||
setLocalDeleteWarningShown: () => void;
|
||||
}) {
|
||||
if (!localDeleteWarningShown && isDeleteSyncSendEnabled) {
|
||||
return (
|
||||
<LocalDeleteWarningModal
|
||||
i18n={i18n}
|
||||
onClose={setLocalDeleteWarningShown}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<ConfirmationDialog
|
||||
dialogName="ConversationHeader.destroyMessages"
|
||||
|
@ -939,7 +965,7 @@ function DeleteMessagesConfirmationDialog({
|
|||
)}
|
||||
actions={[
|
||||
{
|
||||
action: onDestoryMessages,
|
||||
action: onDestroyMessages,
|
||||
style: 'negative',
|
||||
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 { downscaleOutgoingAttachment } from '../util/attachments';
|
||||
import { MessageRequestResponseEvent } from '../types/MessageRequestResponseEvent';
|
||||
import type { MessageToDelete } from '../textsecure/messageReceiverEvents';
|
||||
import {
|
||||
getConversationToDelete,
|
||||
getMessageToDelete,
|
||||
} from '../util/deleteForMe';
|
||||
import { isEnabled } from '../RemoteConfig';
|
||||
import { getCallHistorySelector } from '../state/selectors/callHistory';
|
||||
|
||||
/* eslint-disable more/no-then */
|
||||
|
@ -186,6 +192,7 @@ const {
|
|||
getOlderMessagesByConversation,
|
||||
getMessageMetricsForConversation,
|
||||
getMessageById,
|
||||
getMostRecentAddressableMessages,
|
||||
getNewerMessagesByConversation,
|
||||
} = window.Signal.Data;
|
||||
|
||||
|
@ -2234,7 +2241,7 @@ export class ConversationModel extends window.Backbone
|
|||
}
|
||||
|
||||
if (isDelete) {
|
||||
await this.destroyMessages();
|
||||
await this.destroyMessages({ source: 'message-request' });
|
||||
void this.updateLastMessage();
|
||||
}
|
||||
|
||||
|
@ -4449,7 +4456,6 @@ export class ConversationModel extends window.Backbone
|
|||
source: providedSource,
|
||||
fromSync = false,
|
||||
isInitialSync = false,
|
||||
fromGroupUpdate = false,
|
||||
}: {
|
||||
reason: string;
|
||||
receivedAt?: number;
|
||||
|
@ -4458,7 +4464,6 @@ export class ConversationModel extends window.Backbone
|
|||
source?: string;
|
||||
fromSync?: boolean;
|
||||
isInitialSync?: boolean;
|
||||
fromGroupUpdate?: boolean;
|
||||
}
|
||||
): Promise<boolean | null | MessageModel | void> {
|
||||
const isSetByOther = providedSource || providedSentAt !== undefined;
|
||||
|
@ -4554,7 +4559,7 @@ export class ConversationModel extends window.Backbone
|
|||
(isInitialSync && isFromSyncOperation) || isFromMe || isNoteToSelf;
|
||||
|
||||
const id = generateGuid();
|
||||
const model = new window.Whisper.Message({
|
||||
const attributes = {
|
||||
id,
|
||||
conversationId: this.id,
|
||||
expirationTimerUpdate: {
|
||||
|
@ -4562,7 +4567,6 @@ export class ConversationModel extends window.Backbone
|
|||
source,
|
||||
sourceServiceId,
|
||||
fromSync,
|
||||
fromGroupUpdate,
|
||||
},
|
||||
flags: Proto.DataMessage.Flags.EXPIRATION_TIMER_UPDATE,
|
||||
readStatus: shouldBeRead ? ReadStatus.Read : ReadStatus.Unread,
|
||||
|
@ -4570,18 +4574,18 @@ export class ConversationModel extends window.Backbone
|
|||
received_at: receivedAt ?? incrementMessageCounter(),
|
||||
seenStatus: shouldBeRead ? SeenStatus.Seen : SeenStatus.Unseen,
|
||||
sent_at: sentAt,
|
||||
type: 'timer-notification',
|
||||
// TODO: DESKTOP-722
|
||||
} as unknown as MessageAttributesType);
|
||||
timestamp: sentAt,
|
||||
type: 'timer-notification' as const,
|
||||
};
|
||||
|
||||
await window.Signal.Data.saveMessage(model.attributes, {
|
||||
await window.Signal.Data.saveMessage(attributes, {
|
||||
ourAci: window.textsecure.storage.user.getCheckedAci(),
|
||||
forceSave: true,
|
||||
});
|
||||
|
||||
const message = window.MessageCache.__DEPRECATED$register(
|
||||
id,
|
||||
model,
|
||||
new window.Whisper.Message(attributes),
|
||||
'updateExpirationTimer'
|
||||
);
|
||||
|
||||
|
@ -4589,7 +4593,7 @@ export class ConversationModel extends window.Backbone
|
|||
void this.updateUnread();
|
||||
|
||||
log.info(
|
||||
`${logId}: added a notification received_at=${model.get('received_at')}`
|
||||
`${logId}: added a notification received_at=${message.get('received_at')}`
|
||||
);
|
||||
|
||||
return message;
|
||||
|
@ -4978,7 +4982,29 @@ export class ConversationModel extends window.Backbone
|
|||
this.contactCollection!.reset(members);
|
||||
}
|
||||
|
||||
async destroyMessages(): Promise<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({
|
||||
lastMessage: null,
|
||||
lastMessageAuthor: null,
|
||||
|
@ -4988,9 +5014,50 @@ export class ConversationModel extends window.Backbone
|
|||
});
|
||||
window.Signal.Data.updateConversation(this.attributes);
|
||||
|
||||
await window.Signal.Data.removeAllMessagesInConversation(this.id, {
|
||||
if (source === 'local-delete' && isEnabled('desktop.deleteSync.send')) {
|
||||
log.info(`${logId}: Preparing sync message`);
|
||||
const timestamp = Date.now();
|
||||
|
||||
const addressableMessages = await getMostRecentAddressableMessages(
|
||||
this.id
|
||||
);
|
||||
const mostRecentMessages: Array<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(),
|
||||
});
|
||||
log.info(`${logId}: Delete complete`);
|
||||
}
|
||||
|
||||
getTitle(options?: { isShort?: boolean }): string {
|
||||
|
|
|
@ -68,7 +68,11 @@ import {
|
|||
} from '../util/whatTypeOfConversation';
|
||||
import { handleMessageSend } from '../util/handleMessageSend';
|
||||
import { getSendOptions } from '../util/getSendOptions';
|
||||
import { modifyTargetMessage } from '../util/modifyTargetMessage';
|
||||
import {
|
||||
modifyTargetMessage,
|
||||
ModifyTargetMessageResult,
|
||||
} from '../util/modifyTargetMessage';
|
||||
|
||||
import {
|
||||
getMessagePropStatus,
|
||||
hasErrors,
|
||||
|
@ -2177,7 +2181,6 @@ export class MessageModel extends window.Backbone.Model<MessageAttributesType> {
|
|||
receivedAt: message.get('received_at'),
|
||||
receivedAtMS: message.get('received_at_ms'),
|
||||
sentAt: message.get('sent_at'),
|
||||
fromGroupUpdate: isGroupUpdate(message.attributes),
|
||||
reason: idLog,
|
||||
});
|
||||
} else if (
|
||||
|
@ -2297,7 +2300,11 @@ export class MessageModel extends window.Backbone.Model<MessageAttributesType> {
|
|||
}
|
||||
|
||||
const isFirstRun = true;
|
||||
await this.modifyTargetMessage(conversation, isFirstRun);
|
||||
const result = await this.modifyTargetMessage(conversation, isFirstRun);
|
||||
if (result === ModifyTargetMessageResult.Deleted) {
|
||||
confirm();
|
||||
return;
|
||||
}
|
||||
|
||||
log.info(`${idLog}: Batching save`);
|
||||
void this.saveAndNotify(conversation, confirm);
|
||||
|
@ -2320,10 +2327,16 @@ export class MessageModel extends window.Backbone.Model<MessageAttributesType> {
|
|||
// Once the message is saved to DB, we queue attachment downloads
|
||||
await this.handleAttachmentDownloadsForNewMessage(conversation);
|
||||
|
||||
conversation.trigger('newmessage', this);
|
||||
|
||||
// We'd like to check for deletions before scheduling downloads, but if an edit comes
|
||||
// in, we want to have kicked off attachment downloads for the original message.
|
||||
const isFirstRun = false;
|
||||
await this.modifyTargetMessage(conversation, isFirstRun);
|
||||
const result = await this.modifyTargetMessage(conversation, isFirstRun);
|
||||
if (result === ModifyTargetMessageResult.Deleted) {
|
||||
confirm();
|
||||
return;
|
||||
}
|
||||
|
||||
conversation.trigger('newmessage', this);
|
||||
|
||||
if (await shouldReplyNotifyUser(this.attributes, conversation)) {
|
||||
await conversation.notify(this);
|
||||
|
@ -2377,7 +2390,7 @@ export class MessageModel extends window.Backbone.Model<MessageAttributesType> {
|
|||
async modifyTargetMessage(
|
||||
conversation: ConversationModel,
|
||||
isFirstRun: boolean
|
||||
): Promise<void> {
|
||||
): Promise<ModifyTargetMessageResult> {
|
||||
return modifyTargetMessage(this, conversation, {
|
||||
isFirstRun,
|
||||
skipEdits: false,
|
||||
|
|
|
@ -115,7 +115,7 @@ const exclusiveInterface: ClientExclusiveInterface = {
|
|||
flushUpdateConversationBatcher,
|
||||
|
||||
shutdown,
|
||||
removeAllMessagesInConversation,
|
||||
removeMessagesInConversation,
|
||||
|
||||
removeOtherData,
|
||||
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(
|
||||
messages: ReadonlyArray<MessageAttributesType>
|
||||
): Promise<void> {
|
||||
|
@ -664,12 +679,14 @@ async function getConversationRangeCenteredOnMessage(
|
|||
};
|
||||
}
|
||||
|
||||
async function removeAllMessagesInConversation(
|
||||
async function removeMessagesInConversation(
|
||||
conversationId: string,
|
||||
{
|
||||
logId,
|
||||
receivedAt,
|
||||
}: {
|
||||
logId: string;
|
||||
receivedAt?: number;
|
||||
}
|
||||
): Promise<void> {
|
||||
let messages;
|
||||
|
@ -685,6 +702,7 @@ async function removeAllMessagesInConversation(
|
|||
conversationId,
|
||||
limit: chunkSize,
|
||||
includeStoryReplies: true,
|
||||
receivedAt,
|
||||
storyId: undefined,
|
||||
});
|
||||
|
||||
|
@ -692,15 +710,8 @@ async function removeAllMessagesInConversation(
|
|||
return;
|
||||
}
|
||||
|
||||
const ids = messages.map(message => message.id);
|
||||
|
||||
log.info(`removeAllMessagesInConversation/${logId}: Cleanup...`);
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
await _cleanupMessages(messages);
|
||||
|
||||
log.info(`removeAllMessagesInConversation/${logId}: Deleting...`);
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
await channels.removeMessages(ids);
|
||||
await deleteAndCleanup(messages, logId);
|
||||
} while (messages.length > 0);
|
||||
}
|
||||
|
||||
|
|
|
@ -32,6 +32,7 @@ import type {
|
|||
import type { CallLinkStateType, CallLinkType } from '../types/CallLink';
|
||||
import type { AttachmentDownloadJobType } from '../types/AttachmentDownload';
|
||||
import type { GroupSendEndorsementsData } from '../types/GroupSendEndorsements';
|
||||
import type { SyncTaskType } from '../util/syncTasks';
|
||||
|
||||
export type AdjacentMessagesByConversationOptionsType = Readonly<{
|
||||
conversationId: string;
|
||||
|
@ -716,6 +717,15 @@ export type DataInterface = {
|
|||
ourAci: AciString,
|
||||
opts: EditedMessageType
|
||||
) => Promise<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>;
|
||||
getUnprocessedByIdsAndIncrementAttempts: (
|
||||
ids: ReadonlyArray<string>
|
||||
|
@ -1043,10 +1053,11 @@ export type ClientExclusiveInterface = {
|
|||
// Client-side only
|
||||
|
||||
shutdown: () => Promise<void>;
|
||||
removeAllMessagesInConversation: (
|
||||
removeMessagesInConversation: (
|
||||
conversationId: string,
|
||||
options: {
|
||||
logId: string;
|
||||
receivedAt?: number;
|
||||
}
|
||||
) => Promise<void>;
|
||||
removeOtherData: () => Promise<void>;
|
||||
|
|
135
ts/sql/Server.ts
135
ts/sql/Server.ts
|
@ -184,6 +184,9 @@ import {
|
|||
attachmentDownloadJobSchema,
|
||||
type AttachmentDownloadJobType,
|
||||
} from '../types/AttachmentDownload';
|
||||
import { MAX_SYNC_TASK_ATTEMPTS } from '../util/syncTasks.types';
|
||||
import type { SyncTaskType } from '../util/syncTasks';
|
||||
import { isMoreRecentThan } from '../util/timestamp';
|
||||
|
||||
type ConversationRow = Readonly<{
|
||||
json: string;
|
||||
|
@ -360,6 +363,11 @@ const dataInterface: ServerInterface = {
|
|||
getMessagesBetween,
|
||||
getNearbyMessageFromDeletedSet,
|
||||
saveEditedMessage,
|
||||
getMostRecentAddressableMessages,
|
||||
|
||||
removeSyncTaskById,
|
||||
saveSyncTasks,
|
||||
getAllSyncTasks,
|
||||
|
||||
getUnprocessedCount,
|
||||
getUnprocessedByIdsAndIncrementAttempts,
|
||||
|
@ -2066,6 +2074,131 @@ function hasUserInitiatedMessages(conversationId: string): boolean {
|
|||
return exists !== 0;
|
||||
}
|
||||
|
||||
async function getMostRecentAddressableMessages(
|
||||
conversationId: string,
|
||||
limit = 5
|
||||
): Promise<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(
|
||||
db: Database,
|
||||
data: MessageType,
|
||||
|
@ -6036,6 +6169,7 @@ async function removeAll(): Promise<void> {
|
|||
DELETE FROM storyDistributionMembers;
|
||||
DELETE FROM storyDistributions;
|
||||
DELETE FROM storyReads;
|
||||
DELETE FROM syncTasks;
|
||||
DELETE FROM unprocessed;
|
||||
DELETE FROM uninstalled_sticker_packs;
|
||||
|
||||
|
@ -6078,6 +6212,7 @@ async function removeAllConfiguration(): Promise<void> {
|
|||
DELETE FROM sendLogRecipients;
|
||||
DELETE FROM sessions;
|
||||
DELETE FROM signedPreKeys;
|
||||
DELETE FROM syncTasks;
|
||||
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 { updateToSchemaVersion1030 } from './1030-unblock-event';
|
||||
import { updateToSchemaVersion1040 } from './1040-undownloaded-backed-up-media';
|
||||
import { updateToSchemaVersion1050 } from './1050-group-send-endorsements';
|
||||
import {
|
||||
updateToSchemaVersion1050,
|
||||
updateToSchemaVersion1060,
|
||||
version as MAX_VERSION,
|
||||
} from './1050-group-send-endorsements';
|
||||
} from './1060-addressable-messages-and-sync-tasks';
|
||||
|
||||
function updateToSchemaVersion1(
|
||||
currentVersion: number,
|
||||
|
@ -2025,12 +2026,14 @@ export const SCHEMA_VERSIONS = [
|
|||
updateToSchemaVersion970,
|
||||
updateToSchemaVersion980,
|
||||
updateToSchemaVersion990,
|
||||
|
||||
updateToSchemaVersion1000,
|
||||
updateToSchemaVersion1010,
|
||||
updateToSchemaVersion1020,
|
||||
updateToSchemaVersion1030,
|
||||
updateToSchemaVersion1040,
|
||||
updateToSchemaVersion1050,
|
||||
updateToSchemaVersion1060,
|
||||
];
|
||||
|
||||
export class DBVersionFromFutureError extends Error {
|
||||
|
|
|
@ -3,6 +3,7 @@
|
|||
|
||||
import type { ThunkAction } from 'redux-thunk';
|
||||
import {
|
||||
chunk,
|
||||
difference,
|
||||
fromPairs,
|
||||
isEqual,
|
||||
|
@ -184,6 +185,16 @@ import { getConversationIdForLogging } from '../../util/idForLogging';
|
|||
import { singleProtoJobQueue } from '../../jobs/singleProtoJobQueue';
|
||||
import MessageSender from '../../textsecure/SendMessage';
|
||||
import { AttachmentDownloadUrgency } from '../../jobs/AttachmentDownloadManager';
|
||||
import type {
|
||||
DeleteForMeSyncEventData,
|
||||
MessageToDelete,
|
||||
} from '../../textsecure/messageReceiverEvents';
|
||||
import {
|
||||
getConversationToDelete,
|
||||
getMessageToDelete,
|
||||
} from '../../util/deleteForMe';
|
||||
import { MAX_MESSAGE_COUNT } from '../../util/deleteForMe.types';
|
||||
import { isEnabled } from '../../RemoteConfig';
|
||||
|
||||
// State
|
||||
|
||||
|
@ -1703,8 +1714,10 @@ function deleteMessages({
|
|||
throw new Error('deleteMessage: No conversation found');
|
||||
}
|
||||
|
||||
const messages = (
|
||||
await Promise.all(
|
||||
messageIds.map(async messageId => {
|
||||
messageIds.map(
|
||||
async (messageId): Promise<MessageToDelete | undefined> => {
|
||||
const message = await __DEPRECATED$getMessageById(messageId);
|
||||
if (!message) {
|
||||
throw new Error(`deleteMessages: Message ${messageId} missing!`);
|
||||
|
@ -1716,8 +1729,12 @@ function deleteMessages({
|
|||
`deleteMessages: message conversation ${messageConversationId} doesn't match provided conversation ${conversationId}`
|
||||
);
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
return getMessageToDelete(message.attributes);
|
||||
}
|
||||
)
|
||||
)
|
||||
).filter(isNotNil);
|
||||
|
||||
let nearbyMessageId: string | null = null;
|
||||
|
||||
|
@ -1743,6 +1760,34 @@ function deleteMessages({
|
|||
if (nearbyMessageId != null) {
|
||||
dispatch(scrollToMessage(conversationId, nearbyMessageId));
|
||||
}
|
||||
|
||||
if (!isEnabled('desktop.deleteSync.send')) {
|
||||
return;
|
||||
}
|
||||
if (messages.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const chunks = chunk(messages, MAX_MESSAGE_COUNT);
|
||||
const conversationToDelete = getConversationToDelete(
|
||||
conversation.attributes
|
||||
);
|
||||
const timestamp = Date.now();
|
||||
|
||||
await Promise.all(
|
||||
chunks.map(async items => {
|
||||
const data: DeleteForMeSyncEventData = items.map(item => ({
|
||||
conversation: conversationToDelete,
|
||||
message: item,
|
||||
timestamp,
|
||||
type: 'delete-message' as const,
|
||||
}));
|
||||
|
||||
await singleProtoJobQueue.add(
|
||||
MessageSender.getDeleteForMeSyncMessage(data)
|
||||
);
|
||||
})
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
|
@ -1770,7 +1815,7 @@ function destroyMessages(
|
|||
undefined
|
||||
);
|
||||
|
||||
await conversation.destroyMessages();
|
||||
await conversation.destroyMessages({ source: 'local-delete' });
|
||||
drop(conversation.updateLastMessage());
|
||||
},
|
||||
});
|
||||
|
|
|
@ -127,6 +127,13 @@ export const isInternalUser = createSelector(
|
|||
}
|
||||
);
|
||||
|
||||
export const getDeleteSyncSendEnabled = createSelector(
|
||||
getRemoteConfig,
|
||||
(remoteConfig: ConfigMapType): boolean => {
|
||||
return isRemoteConfigFlagEnabled(remoteConfig, 'desktop.deleteSync.send');
|
||||
}
|
||||
);
|
||||
|
||||
// Note: ts/util/stories is the other place this check is done
|
||||
export const getStoriesEnabled = createSelector(
|
||||
getItems,
|
||||
|
@ -242,3 +249,9 @@ export const getShowStickerPickerHint = createSelector(
|
|||
return state.showStickerPickerHint ?? false;
|
||||
}
|
||||
);
|
||||
|
||||
export const getLocalDeleteWarningShown = createSelector(
|
||||
getItems,
|
||||
(state: ItemsStateType): boolean =>
|
||||
Boolean(state.localDeleteWarningShown ?? false)
|
||||
);
|
||||
|
|
|
@ -40,6 +40,11 @@ import {
|
|||
} from '../selectors/conversations';
|
||||
import { getHasStoriesSelector } from '../selectors/stories2';
|
||||
import { getIntl, getTheme, getUserACI } from '../selectors/user';
|
||||
import { useItemsActions } from '../ducks/items';
|
||||
import {
|
||||
getDeleteSyncSendEnabled,
|
||||
getLocalDeleteWarningShown,
|
||||
} from '../selectors/items';
|
||||
|
||||
export type OwnProps = {
|
||||
id: string;
|
||||
|
@ -146,6 +151,7 @@ export const SmartConversationHeader = memo(function SmartConversationHeader({
|
|||
const conversationName = useContactNameData(conversation);
|
||||
strictAssert(conversationName, 'conversationName is required');
|
||||
|
||||
const isDeleteSyncSendEnabled = useSelector(getDeleteSyncSendEnabled);
|
||||
const isMissingMandatoryProfileSharing =
|
||||
getIsMissingRequiredProfileSharing(conversation);
|
||||
|
||||
|
@ -248,6 +254,11 @@ export const SmartConversationHeader = memo(function SmartConversationHeader({
|
|||
|
||||
const minimalConversation = useMinimalConversation(conversation);
|
||||
|
||||
const localDeleteWarningShown = useSelector(getLocalDeleteWarningShown);
|
||||
const { putItem } = useItemsActions();
|
||||
const setLocalDeleteWarningShown = () =>
|
||||
putItem('localDeleteWarningShown', true);
|
||||
|
||||
return (
|
||||
<ConversationHeader
|
||||
addedByName={addedByName}
|
||||
|
@ -258,6 +269,8 @@ export const SmartConversationHeader = memo(function SmartConversationHeader({
|
|||
hasPanelShowing={hasPanelShowing}
|
||||
hasStories={hasStories}
|
||||
i18n={i18n}
|
||||
localDeleteWarningShown={localDeleteWarningShown}
|
||||
isDeleteSyncSendEnabled={isDeleteSyncSendEnabled}
|
||||
isMissingMandatoryProfileSharing={isMissingMandatoryProfileSharing}
|
||||
isSelectMode={isSelectMode}
|
||||
isSignalConversation={isSignalConversation(conversation)}
|
||||
|
@ -287,6 +300,7 @@ export const SmartConversationHeader = memo(function SmartConversationHeader({
|
|||
onViewRecentMedia={onViewRecentMedia}
|
||||
onViewUserStories={onViewUserStories}
|
||||
outgoingCallButtonStyle={outgoingCallButtonStyle}
|
||||
setLocalDeleteWarningShown={setLocalDeleteWarningShown}
|
||||
sharedGroupNames={conversation.sharedGroupNames}
|
||||
theme={theme}
|
||||
/>
|
||||
|
|
|
@ -16,6 +16,12 @@ import {
|
|||
getLastSelectedMessage,
|
||||
} from '../selectors/conversations';
|
||||
import { getDeleteMessagesProps } from '../selectors/globalModals';
|
||||
import { useItemsActions } from '../ducks/items';
|
||||
import {
|
||||
getLocalDeleteWarningShown,
|
||||
getDeleteSyncSendEnabled,
|
||||
} from '../selectors/items';
|
||||
import { LocalDeleteWarningModal } from '../../components/LocalDeleteWarningModal';
|
||||
|
||||
export const SmartDeleteMessagesModal = memo(
|
||||
function SmartDeleteMessagesModal() {
|
||||
|
@ -36,6 +42,7 @@ export const SmartDeleteMessagesModal = memo(
|
|||
[messageIds, isMe]
|
||||
);
|
||||
const canDeleteForEveryone = useSelector(getCanDeleteForEveryone);
|
||||
const isDeleteSyncSendEnabled = useSelector(getDeleteSyncSendEnabled);
|
||||
const lastSelectedMessage = useSelector(getLastSelectedMessage);
|
||||
const i18n = useSelector(getIntl);
|
||||
const { toggleDeleteMessagesModal } = useGlobalModalActions();
|
||||
|
@ -69,11 +76,25 @@ export const SmartDeleteMessagesModal = memo(
|
|||
onDelete?.();
|
||||
}, [deleteMessagesForEveryone, messageIds, onDelete]);
|
||||
|
||||
const localDeleteWarningShown = useSelector(getLocalDeleteWarningShown);
|
||||
const { putItem } = useItemsActions();
|
||||
if (!localDeleteWarningShown && isDeleteSyncSendEnabled) {
|
||||
return (
|
||||
<LocalDeleteWarningModal
|
||||
i18n={i18n}
|
||||
onClose={() => {
|
||||
putItem('localDeleteWarningShown', true);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<DeleteMessagesModal
|
||||
isMe={isMe}
|
||||
canDeleteForEveryone={canDeleteForEveryone}
|
||||
i18n={i18n}
|
||||
isDeleteSyncSendEnabled={isDeleteSyncSendEnabled}
|
||||
messageCount={messageCount}
|
||||
onClose={handleClose}
|
||||
onDeleteForMe={handleDeleteForMe}
|
||||
|
|
|
@ -67,7 +67,7 @@ describe('KeyChangeListener', () => {
|
|||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await window.Signal.Data.removeAllMessagesInConversation(convo.id, {
|
||||
await window.Signal.Data.removeMessagesInConversation(convo.id, {
|
||||
logId: ourServiceIdWithKeyChange,
|
||||
});
|
||||
await window.Signal.Data.removeConversation(convo.id);
|
||||
|
@ -104,7 +104,7 @@ describe('KeyChangeListener', () => {
|
|||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await window.Signal.Data.removeAllMessagesInConversation(groupConvo.id, {
|
||||
await window.Signal.Data.removeMessagesInConversation(groupConvo.id, {
|
||||
logId: ourServiceIdWithKeyChange,
|
||||
});
|
||||
await window.Signal.Data.removeConversation(groupConvo.id);
|
||||
|
|
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,
|
||||
CallLogEventSyncEvent,
|
||||
CallLinkUpdateSyncEvent,
|
||||
DeleteForMeSyncEvent,
|
||||
} from './messageReceiverEvents';
|
||||
import type {
|
||||
MessageToDelete,
|
||||
DeleteForMeSyncEventData,
|
||||
DeleteForMeSyncTarget,
|
||||
ConversationToDelete,
|
||||
} from './messageReceiverEvents';
|
||||
import * as log from '../logging/log';
|
||||
import * as durations from '../util/durations';
|
||||
|
@ -686,6 +693,11 @@ export default class MessageReceiver
|
|||
handler: (ev: CallLogEventSyncEvent) => void
|
||||
): void;
|
||||
|
||||
public override addEventListener(
|
||||
name: 'deleteForMeSync',
|
||||
handler: (ev: DeleteForMeSyncEvent) => void
|
||||
): void;
|
||||
|
||||
public override addEventListener(name: string, handler: EventHandler): void {
|
||||
return super.addEventListener(name, handler);
|
||||
}
|
||||
|
@ -3165,6 +3177,9 @@ export default class MessageReceiver
|
|||
if (syncMessage.callLogEvent) {
|
||||
return this.handleCallLogEvent(envelope, syncMessage.callLogEvent);
|
||||
}
|
||||
if (syncMessage.deleteForMe) {
|
||||
return this.handleDeleteForMeSync(envelope, syncMessage.deleteForMe);
|
||||
}
|
||||
|
||||
this.removeFromCache(envelope);
|
||||
const envelopeId = getEnvelopeId(envelope);
|
||||
|
@ -3615,6 +3630,118 @@ export default class MessageReceiver
|
|||
log.info('handleCallLogEvent: finished');
|
||||
}
|
||||
|
||||
private async handleDeleteForMeSync(
|
||||
envelope: ProcessedEnvelope,
|
||||
deleteSync: Proto.SyncMessage.IDeleteForMe
|
||||
): Promise<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(
|
||||
envelope: ProcessedEnvelope,
|
||||
contactSyncProto: Proto.SyncMessage.IContacts
|
||||
|
@ -3820,3 +3947,70 @@ function envelopeTypeToCiphertextType(type: number | undefined): number {
|
|||
|
||||
throw new Error(`envelopeTypeToCiphertextType: Unknown type ${type}`);
|
||||
}
|
||||
|
||||
function processMessageToDelete(
|
||||
target: Proto.SyncMessage.DeleteForMe.IAddressableMessage,
|
||||
logId: string
|
||||
): MessageToDelete | undefined {
|
||||
const sentAt = target.sentTimestamp?.toNumber();
|
||||
if (!isNumber(sentAt)) {
|
||||
log.warn(
|
||||
`${logId}/processMessageToDelete: No sentTimestamp found! Dropping AddressableMessage.`
|
||||
);
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if (target.authorAci) {
|
||||
return {
|
||||
type: 'aci' as const,
|
||||
authorAci: normalizeAci(
|
||||
target.authorAci,
|
||||
`${logId}/processMessageToDelete`
|
||||
),
|
||||
sentAt,
|
||||
};
|
||||
}
|
||||
if (target.authorE164) {
|
||||
return {
|
||||
type: 'e164' as const,
|
||||
authorE164: target.authorE164,
|
||||
sentAt,
|
||||
};
|
||||
}
|
||||
|
||||
log.warn(
|
||||
`${logId}/processMessageToDelete: No author field found! Dropping AddressableMessage.`
|
||||
);
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function processConversationToDelete(
|
||||
target: Proto.SyncMessage.DeleteForMe.IConversationIdentifier,
|
||||
logId: string
|
||||
): ConversationToDelete | undefined {
|
||||
const { threadAci, threadGroupId, threadE164 } = target;
|
||||
|
||||
if (threadAci) {
|
||||
return {
|
||||
type: 'aci' as const,
|
||||
aci: normalizeAci(threadAci, `${logId}/threadAci`),
|
||||
};
|
||||
}
|
||||
if (threadGroupId) {
|
||||
return {
|
||||
type: 'group' as const,
|
||||
groupId: Buffer.from(threadGroupId).toString('base64'),
|
||||
};
|
||||
}
|
||||
if (threadE164) {
|
||||
return {
|
||||
type: 'e164' as const,
|
||||
e164: threadE164,
|
||||
};
|
||||
}
|
||||
|
||||
log.warn(
|
||||
`${logId}/processConversationToDelete: No identifier field found! Dropping ConversationIdentifier.`
|
||||
);
|
||||
return undefined;
|
||||
}
|
||||
|
|
|
@ -82,6 +82,13 @@ import {
|
|||
} from '../types/EmbeddedContact';
|
||||
import { missingCaseError } from '../util/missingCaseError';
|
||||
import { drop } from '../util/drop';
|
||||
import type {
|
||||
ConversationToDelete,
|
||||
DeleteForMeSyncEventData,
|
||||
DeleteMessageSyncTarget,
|
||||
MessageToDelete,
|
||||
} from './messageReceiverEvents';
|
||||
import { getConversationFromTarget } from '../util/deleteForMe';
|
||||
|
||||
export type SendMetadataType = {
|
||||
[serviceId: ServiceIdString]: {
|
||||
|
@ -1475,6 +1482,91 @@ export default class MessageSender {
|
|||
};
|
||||
}
|
||||
|
||||
static getDeleteForMeSyncMessage(
|
||||
data: DeleteForMeSyncEventData
|
||||
): SingleProtoJobData {
|
||||
const myAci = window.textsecure.storage.user.getCheckedAci();
|
||||
|
||||
const deleteForMe = new Proto.SyncMessage.DeleteForMe();
|
||||
const messageDeletes: Map<
|
||||
string,
|
||||
Array<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(
|
||||
reads: ReadonlyArray<{
|
||||
senderAci?: AciString;
|
||||
|
@ -2253,3 +2345,37 @@ export default class MessageSender {
|
|||
return this.server.sendChallengeResponse(challengeResponse);
|
||||
}
|
||||
}
|
||||
|
||||
// Helpers
|
||||
|
||||
function toAddressableMessage(message: MessageToDelete) {
|
||||
const targetMessage = new Proto.SyncMessage.DeleteForMe.AddressableMessage();
|
||||
targetMessage.sentTimestamp = Long.fromNumber(message.sentAt);
|
||||
|
||||
if (message.type === 'aci') {
|
||||
targetMessage.authorAci = message.authorAci;
|
||||
} else if (message.type === 'e164') {
|
||||
targetMessage.authorE164 = message.authorE164;
|
||||
} else {
|
||||
throw missingCaseError(message);
|
||||
}
|
||||
|
||||
return targetMessage;
|
||||
}
|
||||
|
||||
function toConversationIdentifier(conversation: ConversationToDelete) {
|
||||
const targetConversation =
|
||||
new Proto.SyncMessage.DeleteForMe.ConversationIdentifier();
|
||||
|
||||
if (conversation.type === 'aci') {
|
||||
targetConversation.threadAci = conversation.aci;
|
||||
} else if (conversation.type === 'group') {
|
||||
targetConversation.threadGroupId = Bytes.fromBase64(conversation.groupId);
|
||||
} else if (conversation.type === 'e164') {
|
||||
targetConversation.threadE164 = conversation.e164;
|
||||
} else {
|
||||
throw missingCaseError(conversation);
|
||||
}
|
||||
|
||||
return targetConversation;
|
||||
}
|
||||
|
|
|
@ -3,6 +3,7 @@
|
|||
/* eslint-disable max-classes-per-file */
|
||||
|
||||
import type { PublicKey } from '@signalapp/libsignal-client';
|
||||
import { z } from 'zod';
|
||||
|
||||
import type { SignalService as Proto } from '../protobuf';
|
||||
import type { ServiceIdString, AciString } from '../types/ServiceId';
|
||||
|
@ -15,6 +16,7 @@ import type {
|
|||
import type { ContactDetailsWithAvatar } from './ContactsParser';
|
||||
import type { CallEventDetails, CallLogEvent } from '../types/CallDisposition';
|
||||
import type { CallLinkUpdateSyncType } from '../types/CallLink';
|
||||
import { isAciString } from '../util/isAciString';
|
||||
|
||||
export class EmptyEvent extends Event {
|
||||
constructor() {
|
||||
|
@ -456,6 +458,78 @@ export class CallLinkUpdateSyncEvent extends ConfirmableEvent {
|
|||
}
|
||||
}
|
||||
|
||||
const messageToDeleteSchema = z.union([
|
||||
z.object({
|
||||
type: z.literal('aci').readonly(),
|
||||
authorAci: z.string().refine(isAciString),
|
||||
sentAt: z.number(),
|
||||
}),
|
||||
z.object({
|
||||
type: z.literal('e164').readonly(),
|
||||
authorE164: z.string(),
|
||||
sentAt: z.number(),
|
||||
}),
|
||||
]);
|
||||
|
||||
export type MessageToDelete = z.infer<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<{
|
||||
event: CallLogEvent;
|
||||
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;
|
||||
lastResortKeyUpdateTime: number;
|
||||
lastResortKeyUpdateTimePNI: number;
|
||||
localDeleteWarningShown: boolean;
|
||||
masterKey: string;
|
||||
masterKeyLastRequestTime: number;
|
||||
maxPreKeyId: number;
|
||||
|
|
|
@ -23,6 +23,7 @@ export const STORAGE_UI_KEYS: ReadonlyArray<keyof StorageAccessType> = [
|
|||
'hasCompletedSafetyNumberOnboarding',
|
||||
'hasCompletedUsernameLinkOnboarding',
|
||||
'hide-menu-bar',
|
||||
'localDeleteWarningShown',
|
||||
'incoming-call-notification',
|
||||
'navTabsCollapsed',
|
||||
'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',
|
||||
|
||||
// Syncs, default non-urgent
|
||||
'deleteForMeSync',
|
||||
'fetchLatestManifestSync',
|
||||
'fetchLocalProfileSync',
|
||||
'messageRequestSync',
|
||||
|
|
|
@ -9,6 +9,7 @@ import type { SendStateByConversationId } from '../messages/MessageSendState';
|
|||
import * as Edits from '../messageModifiers/Edits';
|
||||
import * as log from '../logging/log';
|
||||
import * as Deletes from '../messageModifiers/Deletes';
|
||||
import * as DeletesForMe from '../messageModifiers/DeletesForMe';
|
||||
import * as MessageReceipts from '../messageModifiers/MessageReceipts';
|
||||
import * as Reactions from '../messageModifiers/Reactions';
|
||||
import * as ReadSyncs from '../messageModifiers/ReadSyncs';
|
||||
|
@ -29,6 +30,12 @@ import { missingCaseError } from './missingCaseError';
|
|||
import { reduce } from './iterables';
|
||||
import { strictAssert } from './assert';
|
||||
|
||||
export enum ModifyTargetMessageResult {
|
||||
Modified = 'Modified',
|
||||
NotModified = 'MotModified',
|
||||
Deleted = 'Deleted',
|
||||
}
|
||||
|
||||
// This function is called twice - once from handleDataMessage, and then again from
|
||||
// saveAndNotify, a function called at the end of handleDataMessage as a cleanup for
|
||||
// any missed out-of-order events.
|
||||
|
@ -36,7 +43,7 @@ export async function modifyTargetMessage(
|
|||
message: MessageModel,
|
||||
conversation: ConversationModel,
|
||||
options?: { isFirstRun: boolean; skipEdits: boolean }
|
||||
): Promise<void> {
|
||||
): Promise<ModifyTargetMessageResult> {
|
||||
const { isFirstRun = false, skipEdits = false } = options ?? {};
|
||||
|
||||
const logId = `modifyTargetMessage/${message.idForLogging()}`;
|
||||
|
@ -45,6 +52,15 @@ export async function modifyTargetMessage(
|
|||
const ourAci = window.textsecure.storage.user.getCheckedAci();
|
||||
const sourceServiceId = getSourceServiceId(message.attributes);
|
||||
|
||||
const syncDeletes = await DeletesForMe.forMessage(message.attributes);
|
||||
if (syncDeletes.length) {
|
||||
if (!isFirstRun) {
|
||||
await window.Signal.Data.removeMessage(message.id);
|
||||
}
|
||||
|
||||
return ModifyTargetMessageResult.Deleted;
|
||||
}
|
||||
|
||||
if (type === 'outgoing' || (type === 'story' && ourAci === sourceServiceId)) {
|
||||
const sendActions = MessageReceipts.forMessage(message).map(receipt => {
|
||||
let sendActionType: SendActionType;
|
||||
|
@ -274,4 +290,8 @@ export async function modifyTargetMessage(
|
|||
)
|
||||
);
|
||||
}
|
||||
|
||||
return changed
|
||||
? ModifyTargetMessageResult.Modified
|
||||
: ModifyTargetMessageResult.NotModified;
|
||||
}
|
||||
|
|
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