Support for local deletes synced to all your devices

This commit is contained in:
Scott Nonnenberg 2024-05-29 01:56:00 +10:00 committed by GitHub
parent 06f71a7ef8
commit 11eb1782a7
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
39 changed files with 2094 additions and 72 deletions

View file

@ -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"

View 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

View file

@ -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 {

View 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;
}
}

View file

@ -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';

View file

@ -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();

View file

@ -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'

View file

@ -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;

View 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,
});

View file

@ -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>
);
}

View 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 = {};

View 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>
);
}

View file

@ -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} />;
}

View file

@ -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'),
},

View 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);
}
}

View file

@ -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 {

View file

@ -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,

View file

@ -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);
}

View file

@ -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>;

View file

@ -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;
`
);

View file

@ -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!');
}

View file

@ -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 {

View file

@ -3,6 +3,7 @@
import type { ThunkAction } from 'redux-thunk';
import {
chunk,
difference,
fromPairs,
isEqual,
@ -184,6 +185,16 @@ import { getConversationIdForLogging } from '../../util/idForLogging';
import { singleProtoJobQueue } from '../../jobs/singleProtoJobQueue';
import MessageSender from '../../textsecure/SendMessage';
import { AttachmentDownloadUrgency } from '../../jobs/AttachmentDownloadManager';
import type {
DeleteForMeSyncEventData,
MessageToDelete,
} from '../../textsecure/messageReceiverEvents';
import {
getConversationToDelete,
getMessageToDelete,
} from '../../util/deleteForMe';
import { MAX_MESSAGE_COUNT } from '../../util/deleteForMe.types';
import { isEnabled } from '../../RemoteConfig';
// State
@ -1703,21 +1714,27 @@ function deleteMessages({
throw new Error('deleteMessage: No conversation found');
}
await Promise.all(
messageIds.map(async messageId => {
const message = await __DEPRECATED$getMessageById(messageId);
if (!message) {
throw new Error(`deleteMessages: Message ${messageId} missing!`);
}
const messages = (
await Promise.all(
messageIds.map(
async (messageId): Promise<MessageToDelete | undefined> => {
const message = await __DEPRECATED$getMessageById(messageId);
if (!message) {
throw new Error(`deleteMessages: Message ${messageId} missing!`);
}
const messageConversationId = message.get('conversationId');
if (conversationId !== messageConversationId) {
throw new Error(
`deleteMessages: message conversation ${messageConversationId} doesn't match provided conversation ${conversationId}`
);
}
})
);
const messageConversationId = message.get('conversationId');
if (conversationId !== messageConversationId) {
throw new Error(
`deleteMessages: message conversation ${messageConversationId} doesn't match provided conversation ${conversationId}`
);
}
return getMessageToDelete(message.attributes);
}
)
)
).filter(isNotNil);
let nearbyMessageId: string | null = null;
@ -1743,6 +1760,34 @@ function deleteMessages({
if (nearbyMessageId != null) {
dispatch(scrollToMessage(conversationId, nearbyMessageId));
}
if (!isEnabled('desktop.deleteSync.send')) {
return;
}
if (messages.length === 0) {
return;
}
const chunks = chunk(messages, MAX_MESSAGE_COUNT);
const conversationToDelete = getConversationToDelete(
conversation.attributes
);
const timestamp = Date.now();
await Promise.all(
chunks.map(async items => {
const data: DeleteForMeSyncEventData = items.map(item => ({
conversation: conversationToDelete,
message: item,
timestamp,
type: 'delete-message' as const,
}));
await singleProtoJobQueue.add(
MessageSender.getDeleteForMeSyncMessage(data)
);
})
);
};
}
@ -1770,7 +1815,7 @@ function destroyMessages(
undefined
);
await conversation.destroyMessages();
await conversation.destroyMessages({ source: 'local-delete' });
drop(conversation.updateLastMessage());
},
});

View file

@ -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)
);

View file

@ -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}
/>

View file

@ -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}

View file

@ -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);

View 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);
});
});
});

View file

@ -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;
}

View file

@ -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;
}

View file

@ -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;

View file

@ -79,6 +79,7 @@ export type StorageAccessType = {
lastAttemptedToRefreshProfilesAt: number;
lastResortKeyUpdateTime: number;
lastResortKeyUpdateTimePNI: number;
localDeleteWarningShown: boolean;
masterKey: string;
masterKeyLastRequestTime: number;
maxPreKeyId: number;

View file

@ -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
View 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;
}

View file

@ -0,0 +1,4 @@
// Copyright 2020 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
export const MAX_MESSAGE_COUNT = 500;

View file

@ -55,6 +55,7 @@ export const sendTypesEnum = z.enum([
'pniIdentitySync',
// Syncs, default non-urgent
'deleteForMeSync',
'fetchLatestManifestSync',
'fetchLocalProfileSync',
'messageRequestSync',

View file

@ -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
View 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}`);
})
);
}
}
}

View file

@ -0,0 +1,4 @@
// Copyright 2020 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
export const MAX_SYNC_TASK_ATTEMPTS = 5;