diff --git a/_locales/en/messages.json b/_locales/en/messages.json index 22a5a2150451..919a893d44df 100644 --- a/_locales/en/messages.json +++ b/_locales/en/messages.json @@ -5010,6 +5010,10 @@ "messageformat": "What devices would you like to delete {count, plural, one {this message} other {these messages}} from?", "description": "within note to self conversation > delete selected messages > confirmation modal > description" }, + "icu:DeleteMessagesModal--description--noteToSelf--deleteSync": { + "messageformat": "{count, plural, one {This message} other {These messages}} will be deleted from all your devices.", + "description": "within note to self conversation > delete selected messages > confirmation modal > description" + }, "icu:DeleteMessagesModal--deleteForMe": { "messageformat": "Delete for me", "description": "delete selected messages > confirmation modal > delete for me" @@ -5026,6 +5030,10 @@ "messageformat": "Delete from all devices", "description": "within note to self conversation > delete selected messages > confirmation modal > delete from all devices (same as delete for everyone)" }, + "icu:DeleteMessagesModal--noteToSelf--deleteSync": { + "messageformat": "Delete", + "description": "When delete sync is enabled, there is only one Delete option in Note to Self" + }, "icu:DeleteMessagesModal__toast--TooManyMessagesToDeleteForEveryone": { "messageformat": "You can only select up to {count, plural, one {# message} other {# messages}} to delete for everyone", "description": "delete selected messages > confirmation modal > deleted for everyone (disabled) > toast > too many messages to 'delete for everyone'" @@ -6292,6 +6300,18 @@ "messageformat": "Your connections can see your name and photo, and can see posts to \"My Story\" unless you hide it from them", "description": "Additional information about signal connections and the stories they can see" }, + "icu:LocalDeleteWarningModal__header": { + "messageformat": "\"Delete for Me\" now deletes from all of your devices", + "description": "Emphasized text at the top of the explainer dialog you get when you first delete a message or conversation" + }, + "icu:LocalDeleteWarningModal__description": { + "messageformat": "When you delete a message in a chat, the message will be deleted from your phone and all linked devices.", + "description": "More detailed description of new delete behavior shown in explainer dialog" + }, + "icu:LocalDeleteWarningModal__confirm": { + "messageformat": "Got it", + "description": "Button to dismiss the dialog explaining that 'delete for me' now syncs between devices" + }, "icu:Stories__title": { "messageformat": "Stories", "description": "Title for the stories list" diff --git a/images/local-delete-sync.svg b/images/local-delete-sync.svg new file mode 100644 index 000000000000..dee360ed29e2 --- /dev/null +++ b/images/local-delete-sync.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/protos/SignalService.proto b/protos/SignalService.proto index 72d04e7fa16a..cf8cb3da340d 100644 --- a/protos/SignalService.proto +++ b/protos/SignalService.proto @@ -633,6 +633,43 @@ message SyncMessage { optional uint64 timestamp = 2; } + message DeleteForMe { + message ConversationIdentifier { + oneof identifier { + string threadAci = 1; + bytes threadGroupId = 2; + string threadE164 = 3; + } + } + + message AddressableMessage { + oneof author { + string authorAci = 1; + string authorE164 = 2; + } + optional uint64 sentTimestamp = 3; + } + + message MessageDeletes { + optional ConversationIdentifier conversation = 1; + repeated AddressableMessage messages = 2; + } + + message ConversationDelete { + optional ConversationIdentifier conversation = 1; + repeated AddressableMessage mostRecentMessages = 2; + optional bool isFullDelete = 3; + } + + message LocalOnlyConversationDelete { + optional ConversationIdentifier conversation = 1; + } + + repeated MessageDeletes messageDeletes = 1; + repeated ConversationDelete conversationDeletes = 2; + repeated LocalOnlyConversationDelete localOnlyConversationDeletes = 3; + } + optional Sent sent = 1; optional Contacts contacts = 2; reserved /* groups */ 3; @@ -654,6 +691,7 @@ message SyncMessage { optional CallEvent callEvent = 19; optional CallLinkUpdate callLinkUpdate = 20; optional CallLogEvent callLogEvent = 21; + optional DeleteForMe deleteForMe = 22; } message AttachmentPointer { diff --git a/stylesheets/components/LocalDeleteWarningModal.scss b/stylesheets/components/LocalDeleteWarningModal.scss new file mode 100644 index 000000000000..bd0b228688e4 --- /dev/null +++ b/stylesheets/components/LocalDeleteWarningModal.scss @@ -0,0 +1,35 @@ +// Copyright 2022 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +.LocalDeleteWarningModal__width-container { + max-width: 440px; +} + +.LocalDeleteWarningModal__image { + margin-block: 18px; + text-align: center; +} + +.LocalDeleteWarningModal__header { + @include font-title-2; + + margin-block: 18px; + margin-inline: 8px; + text-align: center; +} + +.LocalDeleteWarningModal__description { + margin-block: 12px; + margin-inline: 8px; + text-align: center; +} + +.LocalDeleteWarningModal__button { + display: flex; + justify-content: center; + margin-top: 49px; + + button { + padding-inline: 26px; + } +} diff --git a/stylesheets/manifest.scss b/stylesheets/manifest.scss index d64a537257c9..7ea68475830f 100644 --- a/stylesheets/manifest.scss +++ b/stylesheets/manifest.scss @@ -113,6 +113,7 @@ @import './components/LeftPaneSearchInput.scss'; @import './components/Lightbox.scss'; @import './components/ListTile.scss'; +@import './components/LocalDeleteWarningModal.scss'; @import './components/MediaEditor.scss'; @import './components/MediaQualitySelector.scss'; @import './components/MessageAudio.scss'; diff --git a/ts/CI/benchmarkConversationOpen.ts b/ts/CI/benchmarkConversationOpen.ts index e3f8aabcc0bf..c841958f8f58 100644 --- a/ts/CI/benchmarkConversationOpen.ts +++ b/ts/CI/benchmarkConversationOpen.ts @@ -50,7 +50,7 @@ export async function populateConversationWithMessages({ ); log.info(`${logId}: destroying all messages in ${conversationId}`); - await conversation.destroyMessages(); + await conversation.destroyMessages({ source: 'local-delete' }); log.info(`${logId}: adding ${messageCount} messages to ${conversationId}`); let timestamp = Date.now(); diff --git a/ts/RemoteConfig.ts b/ts/RemoteConfig.ts index 8a7751e3be2e..881b4e541df1 100644 --- a/ts/RemoteConfig.ts +++ b/ts/RemoteConfig.ts @@ -18,6 +18,8 @@ export type ConfigKeyType = | 'desktop.calling.adhoc' | 'desktop.clientExpiration' | 'desktop.backup.credentialFetch' + | 'desktop.deleteSync.send' + | 'desktop.deleteSync.receive' | 'desktop.groupMultiTypingIndicators' | 'desktop.internalUser' | 'desktop.mediaQuality.levels' diff --git a/ts/background.ts b/ts/background.ts index 06371538c0c7..e6df5203b56b 100644 --- a/ts/background.ts +++ b/ts/background.ts @@ -86,6 +86,7 @@ import type { FetchLatestEvent, InvalidPlaintextEvent, KeysEvent, + DeleteForMeSyncEvent, MessageEvent, MessageEventData, MessageRequestResponseEvent, @@ -111,21 +112,21 @@ import type { BadgesStateType } from './state/ducks/badges'; import { areAnyCallsActiveOrRinging } from './state/selectors/calling'; import { badgeImageFileDownloader } from './badges/badgeImageFileDownloader'; import * as Deletes from './messageModifiers/Deletes'; -import type { EditAttributesType } from './messageModifiers/Edits'; import * as Edits from './messageModifiers/Edits'; -import type { ReactionAttributesType } from './messageModifiers/Reactions'; import * as MessageReceipts from './messageModifiers/MessageReceipts'; import * as MessageRequests from './messageModifiers/MessageRequests'; import * as Reactions from './messageModifiers/Reactions'; import * as ReadSyncs from './messageModifiers/ReadSyncs'; -import * as ViewSyncs from './messageModifiers/ViewSyncs'; import * as ViewOnceOpenSyncs from './messageModifiers/ViewOnceOpenSyncs'; +import * as ViewSyncs from './messageModifiers/ViewSyncs'; import type { DeleteAttributesType } from './messageModifiers/Deletes'; +import type { EditAttributesType } from './messageModifiers/Edits'; import type { MessageReceiptAttributesType } from './messageModifiers/MessageReceipts'; import type { MessageRequestAttributesType } from './messageModifiers/MessageRequests'; +import type { ReactionAttributesType } from './messageModifiers/Reactions'; import type { ReadSyncAttributesType } from './messageModifiers/ReadSyncs'; -import type { ViewSyncAttributesType } from './messageModifiers/ViewSyncs'; import type { ViewOnceOpenSyncAttributesType } from './messageModifiers/ViewOnceOpenSyncs'; +import type { ViewSyncAttributesType } from './messageModifiers/ViewSyncs'; import { ReadStatus } from './messages/MessageReadStatus'; import type { SendStateByConversationId } from './messages/MessageSendState'; import { SendStatus } from './messages/MessageSendState'; @@ -201,6 +202,8 @@ import { getThemeType } from './util/getThemeType'; import { AttachmentDownloadManager } from './jobs/AttachmentDownloadManager'; import { onCallLinkUpdateSync } from './util/onCallLinkUpdateSync'; import { CallMode } from './types/Calling'; +import { queueSyncTasks } from './util/syncTasks'; +import { isEnabled } from './RemoteConfig'; export function isOverHourIntoPast(timestamp: number): boolean { return isNumber(timestamp) && isOlderThan(timestamp, HOUR); @@ -558,6 +561,24 @@ export async function startApp(): Promise { storage: window.storage, serverTrustRoot: window.getServerTrustRoot(), }); + const onFirstEmpty = async () => { + log.info('onFirstEmpty: Starting'); + + // We want to remove this handler on the next tick so we don't interfere with + // the other handlers being notified of this instance of the 'empty' event. + setTimeout(() => { + messageReceiver?.removeEventListener('empty', onFirstEmpty); + }, 1); + + log.info('onFirstEmpty: Fetching sync tasks'); + const syncTasks = await window.Signal.Data.getAllSyncTasks(); + + log.info(`onFirstEmpty: Queuing ${syncTasks.length} sync tasks`); + await queueSyncTasks(syncTasks, window.Signal.Data.removeSyncTaskById); + + log.info('onFirstEmpty: Done'); + }; + messageReceiver.addEventListener('empty', onFirstEmpty); function queuedEventListener( handler: (event: E) => Promise | void, @@ -691,6 +712,10 @@ export async function startApp(): Promise { 'callLogEventSync', queuedEventListener(onCallLogEventSync, false) ); + messageReceiver.addEventListener( + 'deleteForMeSync', + queuedEventListener(onDeleteForMeSync, false) + ); if (!window.storage.get('defaultConversationColor')) { drop( @@ -3384,6 +3409,41 @@ export async function startApp(): Promise { drop(MessageReceipts.onReceipt(attributes)); } + + async function onDeleteForMeSync(ev: DeleteForMeSyncEvent) { + const { confirm, timestamp, envelopeId, deleteForMeSync } = ev; + const logId = `onDeleteForMeSync(${timestamp})`; + + if (!isEnabled('desktop.deleteSync.receive')) { + confirm(); + return; + } + + // The user clearly knows about this feature; they did it on another device! + drop(window.storage.put('localDeleteWarningShown', true)); + + log.info(`${logId}: Saving ${deleteForMeSync.length} sync tasks`); + + const now = Date.now(); + const syncTasks = deleteForMeSync.map(item => ({ + id: generateUuid(), + attempts: 1, + createdAt: now, + data: item, + envelopeId, + sentAt: timestamp, + type: item.type, + })); + await window.Signal.Data.saveSyncTasks(syncTasks); + + confirm(); + + log.info(`${logId}: Queuing ${syncTasks.length} sync tasks`); + + await queueSyncTasks(syncTasks, window.Signal.Data.removeSyncTaskById); + + log.info(`${logId}: Done`); + } } window.startApp = startApp; diff --git a/ts/components/DeleteMessagesModal.stories.tsx b/ts/components/DeleteMessagesModal.stories.tsx new file mode 100644 index 000000000000..e1436ab36ce4 --- /dev/null +++ b/ts/components/DeleteMessagesModal.stories.tsx @@ -0,0 +1,78 @@ +// Copyright 2022 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +import React from 'react'; +import type { Meta, StoryFn } from '@storybook/react'; +import { action } from '@storybook/addon-actions'; + +import enMessages from '../../_locales/en/messages.json'; +import { setupI18n } from '../util/setupI18n'; +import DeleteMessagesModal from './DeleteMessagesModal'; +import type { DeleteMessagesModalProps } from './DeleteMessagesModal'; + +const i18n = setupI18n('en', enMessages); + +export default { + title: 'Components/DeleteMessagesModal', + component: DeleteMessagesModal, + args: { + i18n, + isMe: false, + isDeleteSyncSendEnabled: false, + canDeleteForEveryone: true, + messageCount: 1, + onClose: action('onClose'), + onDeleteForMe: action('onDeleteForMe'), + onDeleteForEveryone: action('onDeleteForEveryone'), + showToast: action('showToast'), + }, +} satisfies Meta; + +function createProps(args: Partial) { + return { + i18n, + isMe: false, + isDeleteSyncSendEnabled: false, + canDeleteForEveryone: true, + messageCount: 1, + onClose: action('onClose'), + onDeleteForMe: action('onDeleteForMe'), + onDeleteForEveryone: action('onDeleteForEveryone'), + showToast: action('showToast'), + ...args, + }; +} + +// eslint-disable-next-line react/function-component-definition +const Template: StoryFn = args => { + return ; +}; + +export const OneMessage = Template.bind({}); + +export const ThreeMessages = Template.bind({}); +ThreeMessages.args = createProps({ + messageCount: 3, +}); + +export const IsMe = Template.bind({}); +IsMe.args = createProps({ + isMe: true, +}); + +export const IsMeThreeMessages = Template.bind({}); +IsMeThreeMessages.args = createProps({ + isMe: true, + messageCount: 3, +}); + +export const DeleteSyncEnabled = Template.bind({}); +DeleteSyncEnabled.args = createProps({ + isDeleteSyncSendEnabled: true, +}); + +export const IsMeDeleteSyncEnabled = Template.bind({}); +IsMeDeleteSyncEnabled.args = createProps({ + isDeleteSyncSendEnabled: true, + isMe: true, +}); diff --git a/ts/components/DeleteMessagesModal.tsx b/ts/components/DeleteMessagesModal.tsx index e12c248270ae..4bf2bd81018e 100644 --- a/ts/components/DeleteMessagesModal.tsx +++ b/ts/components/DeleteMessagesModal.tsx @@ -8,8 +8,9 @@ import type { LocalizerType } from '../types/Util'; import type { ShowToastAction } from '../state/ducks/toast'; import { ToastType } from '../types/Toast'; -type DeleteMessagesModalProps = Readonly<{ +export type DeleteMessagesModalProps = Readonly<{ isMe: boolean; + isDeleteSyncSendEnabled: boolean; canDeleteForEveryone: boolean; i18n: LocalizerType; messageCount: number; @@ -23,6 +24,7 @@ const MAX_DELETE_FOR_EVERYONE = 30; export default function DeleteMessagesModal({ isMe, + isDeleteSyncSendEnabled, canDeleteForEveryone, i18n, messageCount, @@ -33,15 +35,22 @@ export default function DeleteMessagesModal({ }: DeleteMessagesModalProps): JSX.Element { const actions: Array = []; + const syncNoteToSelfDelete = isMe && isDeleteSyncSendEnabled; + + let deleteForMeText = i18n('icu:DeleteMessagesModal--deleteForMe'); + if (syncNoteToSelfDelete) { + deleteForMeText = i18n('icu:DeleteMessagesModal--noteToSelf--deleteSync'); + } else if (isMe) { + deleteForMeText = i18n('icu:DeleteMessagesModal--deleteFromThisDevice'); + } + actions.push({ action: onDeleteForMe, style: 'negative', - text: isMe - ? i18n('icu:DeleteMessagesModal--deleteFromThisDevice') - : i18n('icu:DeleteMessagesModal--deleteForMe'), + text: deleteForMeText, }); - if (canDeleteForEveryone) { + if (canDeleteForEveryone && !syncNoteToSelfDelete) { const tooManyMessages = messageCount > MAX_DELETE_FOR_EVERYONE; actions.push({ 'aria-disabled': tooManyMessages, @@ -63,6 +72,20 @@ export default function DeleteMessagesModal({ }); } + let descriptionText = i18n('icu:DeleteMessagesModal--description', { + count: messageCount, + }); + if (syncNoteToSelfDelete) { + descriptionText = i18n( + 'icu:DeleteMessagesModal--description--noteToSelf--deleteSync', + { count: messageCount } + ); + } else if (isMe) { + descriptionText = i18n('icu:DeleteMessagesModal--description--noteToSelf', { + count: messageCount, + }); + } + return ( - {isMe - ? i18n('icu:DeleteMessagesModal--description--noteToSelf', { - count: messageCount, - }) - : i18n('icu:DeleteMessagesModal--description', { - count: messageCount, - })} + {descriptionText} ); } diff --git a/ts/components/LocalDeleteWarningModal.stories.tsx b/ts/components/LocalDeleteWarningModal.stories.tsx new file mode 100644 index 000000000000..7a07dd871c62 --- /dev/null +++ b/ts/components/LocalDeleteWarningModal.stories.tsx @@ -0,0 +1,30 @@ +// Copyright 2022 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +import type { Meta, StoryFn } from '@storybook/react'; +import React from 'react'; + +import { action } from '@storybook/addon-actions'; +import type { PropsType } from './LocalDeleteWarningModal'; +import enMessages from '../../_locales/en/messages.json'; +import { LocalDeleteWarningModal } from './LocalDeleteWarningModal'; +import { setupI18n } from '../util/setupI18n'; + +const i18n = setupI18n('en', enMessages); + +export default { + title: 'Components/LocalDeleteWarningModal', + component: LocalDeleteWarningModal, + args: { + i18n, + onClose: action('onClose'), + }, +} satisfies Meta; + +// eslint-disable-next-line react/function-component-definition +const Template: StoryFn = args => ( + +); + +export const Modal = Template.bind({}); +Modal.args = {}; diff --git a/ts/components/LocalDeleteWarningModal.tsx b/ts/components/LocalDeleteWarningModal.tsx new file mode 100644 index 000000000000..83051d9fce5c --- /dev/null +++ b/ts/components/LocalDeleteWarningModal.tsx @@ -0,0 +1,53 @@ +// Copyright 2022 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +import React from 'react'; + +import type { LocalizerType } from '../types/Util'; +import { Button, ButtonVariant } from './Button'; +import { I18n } from './I18n'; +import { Modal } from './Modal'; + +export type PropsType = { + i18n: LocalizerType; + onClose: () => unknown; +}; + +export function LocalDeleteWarningModal({ + i18n, + onClose, +}: PropsType): JSX.Element { + return ( + +
+
+ +
+ +
+ +
+ +
+ +
+ +
+ +
+
+
+ ); +} diff --git a/ts/components/conversation/ConversationHeader.stories.tsx b/ts/components/conversation/ConversationHeader.stories.tsx index 34e9dc0158aa..ef395d9c5605 100644 --- a/ts/components/conversation/ConversationHeader.stories.tsx +++ b/ts/components/conversation/ConversationHeader.stories.tsx @@ -46,6 +46,10 @@ const commonProps = { i18n, + localDeleteWarningShown: true, + isDeleteSyncSendEnabled: true, + setLocalDeleteWarningShown: action('setLocalDeleteWarningShown'), + onConversationAccept: action('onConversationAccept'), onConversationArchive: action('onConversationArchive'), onConversationBlock: action('onConversationBlock'), @@ -412,3 +416,32 @@ export function Unaccepted(): JSX.Element { ); } + +export function NeedsDeleteConfirmation(): JSX.Element { + const [localDeleteWarningShown, setLocalDeleteWarningShown] = + React.useState(false); + const props = { + ...commonProps, + conversation: getDefaultConversation(), + localDeleteWarningShown, + setLocalDeleteWarningShown: () => setLocalDeleteWarningShown(true), + }; + const theme = useContext(StorybookThemeContext); + + return ; +} + +export function NeedsDeleteConfirmationButNotEnabled(): JSX.Element { + const [localDeleteWarningShown, setLocalDeleteWarningShown] = + React.useState(false); + const props = { + ...commonProps, + conversation: getDefaultConversation(), + localDeleteWarningShown, + isDeleteSyncSendEnabled: false, + setLocalDeleteWarningShown: () => setLocalDeleteWarningShown(true), + }; + const theme = useContext(StorybookThemeContext); + + return ; +} diff --git a/ts/components/conversation/ConversationHeader.tsx b/ts/components/conversation/ConversationHeader.tsx index a3ee8cc4755b..866cdff35cdd 100644 --- a/ts/components/conversation/ConversationHeader.tsx +++ b/ts/components/conversation/ConversationHeader.tsx @@ -38,6 +38,7 @@ import { MessageRequestState, } from './MessageRequestActionsConfirmation'; import type { MinimalConversation } from '../../hooks/useMinimalConversation'; +import { LocalDeleteWarningModal } from '../LocalDeleteWarningModal'; function HeaderInfoTitle({ name, @@ -92,6 +93,8 @@ export type PropsDataType = { conversationName: ContactNameData; hasPanelShowing?: boolean; hasStories?: HasStories; + localDeleteWarningShown: boolean; + isDeleteSyncSendEnabled: boolean; isMissingMandatoryProfileSharing?: boolean; isSelectMode: boolean; isSignalConversation?: boolean; @@ -102,6 +105,8 @@ export type PropsDataType = { }; export type PropsActionsType = { + setLocalDeleteWarningShown: () => void; + onConversationAccept: () => void; onConversationArchive: () => void; onConversationBlock: () => void; @@ -147,10 +152,12 @@ export const ConversationHeader = memo(function ConversationHeader({ hasPanelShowing, hasStories, i18n, + isDeleteSyncSendEnabled, isMissingMandatoryProfileSharing, isSelectMode, isSignalConversation, isSMSOnly, + localDeleteWarningShown, onConversationAccept, onConversationArchive, onConversationBlock, @@ -174,6 +181,7 @@ export const ConversationHeader = memo(function ConversationHeader({ onViewRecentMedia, onViewUserStories, outgoingCallButtonStyle, + setLocalDeleteWarningShown, sharedGroupNames, theme, }: PropsType): JSX.Element | null { @@ -223,13 +231,16 @@ export const ConversationHeader = memo(function ConversationHeader({ {hasDeleteMessagesConfirmation && ( { + isDeleteSyncSendEnabled={isDeleteSyncSendEnabled} + localDeleteWarningShown={localDeleteWarningShown} + onDestroyMessages={() => { setHasDeleteMessagesConfirmation(false); onConversationDeleteMessages(); }} onClose={() => { setHasDeleteMessagesConfirmation(false); }} + setLocalDeleteWarningShown={setLocalDeleteWarningShown} /> )} {hasLeaveGroupConfirmation && ( @@ -923,14 +934,29 @@ function CannotLeaveGroupBecauseYouAreLastAdminAlert({ } function DeleteMessagesConfirmationDialog({ + isDeleteSyncSendEnabled, i18n, - onDestoryMessages, + localDeleteWarningShown, + onDestroyMessages, onClose, + setLocalDeleteWarningShown, }: { + isDeleteSyncSendEnabled: boolean; i18n: LocalizerType; - onDestoryMessages: () => void; + localDeleteWarningShown: boolean; + onDestroyMessages: () => void; onClose: () => void; + setLocalDeleteWarningShown: () => void; }) { + if (!localDeleteWarningShown && isDeleteSyncSendEnabled) { + return ( + + ); + } + return ( (); + +async function remove(item: DeleteForMeAttributesType): Promise { + await removeSyncTaskById(item.syncTaskId); + deletes.delete(item.envelopeId); +} + +export async function forMessage( + messageAttributes: MessageAttributesType +): Promise> { + const sentTimestamps = getMessageSentTimestampSet(messageAttributes); + const deleteValues = Array.from(deletes.values()); + + const matchingDeletes = deleteValues.filter(item => { + const itemConversation = getConversationFromTarget(item.conversation); + const query = getMessageQueryFromTarget(item.message); + + if (!itemConversation) { + return false; + } + + return doesMessageMatch({ + conversationId: itemConversation.id, + message: messageAttributes, + query, + sentTimestamps, + }); + }); + + if (!matchingDeletes.length) { + return []; + } + + log.info('Found early DeleteForMe for message'); + await Promise.all( + matchingDeletes.map(async item => { + await remove(item); + }) + ); + return matchingDeletes; +} + +export async function onDelete(item: DeleteForMeAttributesType): Promise { + try { + const conversation = getConversationFromTarget(item.conversation); + const message = getMessageQueryFromTarget(item.message); + + const logId = `DeletesForMe.onDelete(sentAt=${message.sentAt},timestamp=${item.timestamp},envelopeId=${item.envelopeId})`; + + deletes.set(item.envelopeId, item); + + if (!conversation) { + log.warn(`${logId}: Conversation not found!`); + await remove(item); + return; + } + + // Do not await, since this a can deadlock the queue + drop( + conversation.queueJob('DeletesForMe.onDelete', async () => { + log.info(`${logId}: Starting...`); + + const result = await deleteMessage( + conversation.id, + item.message, + logId + ); + if (result) { + await remove(item); + } + + log.info(`${logId}: Complete (result=${result})`); + }) + ); + } catch (error) { + log.error( + `DeletesForMe.onDelete(task=${item.syncTaskId},envelopeId=${item.envelopeId}): Error`, + Errors.toLogFormat(error) + ); + await remove(item); + } +} diff --git a/ts/models/conversations.ts b/ts/models/conversations.ts index de9f33920c67..0f9f4aa97c86 100644 --- a/ts/models/conversations.ts +++ b/ts/models/conversations.ts @@ -165,6 +165,12 @@ import OS from '../util/os/osMain'; import { getMessageAuthorText } from '../util/getMessageAuthorText'; import { downscaleOutgoingAttachment } from '../util/attachments'; import { MessageRequestResponseEvent } from '../types/MessageRequestResponseEvent'; +import type { MessageToDelete } from '../textsecure/messageReceiverEvents'; +import { + getConversationToDelete, + getMessageToDelete, +} from '../util/deleteForMe'; +import { isEnabled } from '../RemoteConfig'; import { getCallHistorySelector } from '../state/selectors/callHistory'; /* eslint-disable more/no-then */ @@ -186,6 +192,7 @@ const { getOlderMessagesByConversation, getMessageMetricsForConversation, getMessageById, + getMostRecentAddressableMessages, getNewerMessagesByConversation, } = window.Signal.Data; @@ -2234,7 +2241,7 @@ export class ConversationModel extends window.Backbone } if (isDelete) { - await this.destroyMessages(); + await this.destroyMessages({ source: 'message-request' }); void this.updateLastMessage(); } @@ -4449,7 +4456,6 @@ export class ConversationModel extends window.Backbone source: providedSource, fromSync = false, isInitialSync = false, - fromGroupUpdate = false, }: { reason: string; receivedAt?: number; @@ -4458,7 +4464,6 @@ export class ConversationModel extends window.Backbone source?: string; fromSync?: boolean; isInitialSync?: boolean; - fromGroupUpdate?: boolean; } ): Promise { const isSetByOther = providedSource || providedSentAt !== undefined; @@ -4554,7 +4559,7 @@ export class ConversationModel extends window.Backbone (isInitialSync && isFromSyncOperation) || isFromMe || isNoteToSelf; const id = generateGuid(); - const model = new window.Whisper.Message({ + const attributes = { id, conversationId: this.id, expirationTimerUpdate: { @@ -4562,7 +4567,6 @@ export class ConversationModel extends window.Backbone source, sourceServiceId, fromSync, - fromGroupUpdate, }, flags: Proto.DataMessage.Flags.EXPIRATION_TIMER_UPDATE, readStatus: shouldBeRead ? ReadStatus.Read : ReadStatus.Unread, @@ -4570,18 +4574,18 @@ export class ConversationModel extends window.Backbone received_at: receivedAt ?? incrementMessageCounter(), seenStatus: shouldBeRead ? SeenStatus.Seen : SeenStatus.Unseen, sent_at: sentAt, - type: 'timer-notification', - // TODO: DESKTOP-722 - } as unknown as MessageAttributesType); + timestamp: sentAt, + type: 'timer-notification' as const, + }; - await window.Signal.Data.saveMessage(model.attributes, { + await window.Signal.Data.saveMessage(attributes, { ourAci: window.textsecure.storage.user.getCheckedAci(), forceSave: true, }); const message = window.MessageCache.__DEPRECATED$register( id, - model, + new window.Whisper.Message(attributes), 'updateExpirationTimer' ); @@ -4589,7 +4593,7 @@ export class ConversationModel extends window.Backbone void this.updateUnread(); log.info( - `${logId}: added a notification received_at=${model.get('received_at')}` + `${logId}: added a notification received_at=${message.get('received_at')}` ); return message; @@ -4978,7 +4982,29 @@ export class ConversationModel extends window.Backbone this.contactCollection!.reset(members); } - async destroyMessages(): Promise { + async destroyMessages({ + source, + }: { + source: 'message-request' | 'local-delete-sync' | 'local-delete'; + }): Promise { + const logId = `destroyMessages(${this.idForLogging()})/${source}`; + + log.info(`${logId}: Queuing job on conversation`); + + await this.queueJob(logId, async () => { + log.info(`${logId}: Starting...`); + + await this.destroyMessagesInner({ logId, source }); + }); + } + + async destroyMessagesInner({ + logId, + source, + }: { + logId: string; + source: 'message-request' | 'local-delete-sync' | 'local-delete'; + }): Promise { this.set({ lastMessage: null, lastMessageAuthor: null, @@ -4988,9 +5014,50 @@ export class ConversationModel extends window.Backbone }); window.Signal.Data.updateConversation(this.attributes); - await window.Signal.Data.removeAllMessagesInConversation(this.id, { + if (source === 'local-delete' && isEnabled('desktop.deleteSync.send')) { + log.info(`${logId}: Preparing sync message`); + const timestamp = Date.now(); + + const addressableMessages = await getMostRecentAddressableMessages( + this.id + ); + const mostRecentMessages: Array = addressableMessages + .map(getMessageToDelete) + .filter(isNotNil) + .slice(0, 5); + + if (mostRecentMessages.length > 0) { + await singleProtoJobQueue.add( + MessageSender.getDeleteForMeSyncMessage([ + { + type: 'delete-conversation', + conversation: getConversationToDelete(this.attributes), + isFullDelete: true, + mostRecentMessages, + timestamp, + }, + ]) + ); + } else { + await singleProtoJobQueue.add( + MessageSender.getDeleteForMeSyncMessage([ + { + type: 'delete-local-conversation', + conversation: getConversationToDelete(this.attributes), + timestamp, + }, + ]) + ); + } + + log.info(`${logId}: Sync message queue complete`); + } + + log.info(`${logId}: Starting delete`); + await window.Signal.Data.removeMessagesInConversation(this.id, { logId: this.idForLogging(), }); + log.info(`${logId}: Delete complete`); } getTitle(options?: { isShort?: boolean }): string { diff --git a/ts/models/messages.ts b/ts/models/messages.ts index 4b24ef30e426..57fe79cc9037 100644 --- a/ts/models/messages.ts +++ b/ts/models/messages.ts @@ -68,7 +68,11 @@ import { } from '../util/whatTypeOfConversation'; import { handleMessageSend } from '../util/handleMessageSend'; import { getSendOptions } from '../util/getSendOptions'; -import { modifyTargetMessage } from '../util/modifyTargetMessage'; +import { + modifyTargetMessage, + ModifyTargetMessageResult, +} from '../util/modifyTargetMessage'; + import { getMessagePropStatus, hasErrors, @@ -2177,7 +2181,6 @@ export class MessageModel extends window.Backbone.Model { receivedAt: message.get('received_at'), receivedAtMS: message.get('received_at_ms'), sentAt: message.get('sent_at'), - fromGroupUpdate: isGroupUpdate(message.attributes), reason: idLog, }); } else if ( @@ -2297,7 +2300,11 @@ export class MessageModel extends window.Backbone.Model { } const isFirstRun = true; - await this.modifyTargetMessage(conversation, isFirstRun); + const result = await this.modifyTargetMessage(conversation, isFirstRun); + if (result === ModifyTargetMessageResult.Deleted) { + confirm(); + return; + } log.info(`${idLog}: Batching save`); void this.saveAndNotify(conversation, confirm); @@ -2320,10 +2327,16 @@ export class MessageModel extends window.Backbone.Model { // Once the message is saved to DB, we queue attachment downloads await this.handleAttachmentDownloadsForNewMessage(conversation); - conversation.trigger('newmessage', this); - + // We'd like to check for deletions before scheduling downloads, but if an edit comes + // in, we want to have kicked off attachment downloads for the original message. const isFirstRun = false; - await this.modifyTargetMessage(conversation, isFirstRun); + const result = await this.modifyTargetMessage(conversation, isFirstRun); + if (result === ModifyTargetMessageResult.Deleted) { + confirm(); + return; + } + + conversation.trigger('newmessage', this); if (await shouldReplyNotifyUser(this.attributes, conversation)) { await conversation.notify(this); @@ -2377,7 +2390,7 @@ export class MessageModel extends window.Backbone.Model { async modifyTargetMessage( conversation: ConversationModel, isFirstRun: boolean - ): Promise { + ): Promise { return modifyTargetMessage(this, conversation, { isFirstRun, skipEdits: false, diff --git a/ts/sql/Client.ts b/ts/sql/Client.ts index b9085d4ae68b..9dabfb0c4682 100644 --- a/ts/sql/Client.ts +++ b/ts/sql/Client.ts @@ -115,7 +115,7 @@ const exclusiveInterface: ClientExclusiveInterface = { flushUpdateConversationBatcher, shutdown, - removeAllMessagesInConversation, + removeMessagesInConversation, removeOtherData, cleanupOrphanedAttachments, @@ -592,6 +592,21 @@ async function removeMessage(id: string): Promise { } } +export async function deleteAndCleanup( + messages: Array, + logId: string +): Promise { + const ids = messages.map(message => message.id); + + log.info(`deleteAndCleanup/${logId}: Deleting ${ids.length} messages...`); + await channels.removeMessages(ids); + + log.info(`deleteAndCleanup/${logId}: Cleanup for ${ids.length} messages...`); + await _cleanupMessages(messages); + + log.info(`deleteAndCleanup/${logId}: Complete`); +} + async function _cleanupMessages( messages: ReadonlyArray ): Promise { @@ -664,12 +679,14 @@ async function getConversationRangeCenteredOnMessage( }; } -async function removeAllMessagesInConversation( +async function removeMessagesInConversation( conversationId: string, { logId, + receivedAt, }: { logId: string; + receivedAt?: number; } ): Promise { let messages; @@ -685,6 +702,7 @@ async function removeAllMessagesInConversation( conversationId, limit: chunkSize, includeStoryReplies: true, + receivedAt, storyId: undefined, }); @@ -692,15 +710,8 @@ async function removeAllMessagesInConversation( return; } - const ids = messages.map(message => message.id); - - log.info(`removeAllMessagesInConversation/${logId}: Cleanup...`); // eslint-disable-next-line no-await-in-loop - await _cleanupMessages(messages); - - log.info(`removeAllMessagesInConversation/${logId}: Deleting...`); - // eslint-disable-next-line no-await-in-loop - await channels.removeMessages(ids); + await deleteAndCleanup(messages, logId); } while (messages.length > 0); } diff --git a/ts/sql/Interface.ts b/ts/sql/Interface.ts index 5b4102dcbe08..e975bab57f4b 100644 --- a/ts/sql/Interface.ts +++ b/ts/sql/Interface.ts @@ -32,6 +32,7 @@ import type { import type { CallLinkStateType, CallLinkType } from '../types/CallLink'; import type { AttachmentDownloadJobType } from '../types/AttachmentDownload'; import type { GroupSendEndorsementsData } from '../types/GroupSendEndorsements'; +import type { SyncTaskType } from '../util/syncTasks'; export type AdjacentMessagesByConversationOptionsType = Readonly<{ conversationId: string; @@ -716,6 +717,15 @@ export type DataInterface = { ourAci: AciString, opts: EditedMessageType ) => Promise; + getMostRecentAddressableMessages: ( + conversationId: string, + limit?: number + ) => Promise>; + + removeSyncTaskById: (id: string) => Promise; + saveSyncTasks: (tasks: Array) => Promise; + getAllSyncTasks: () => Promise>; + getUnprocessedCount: () => Promise; getUnprocessedByIdsAndIncrementAttempts: ( ids: ReadonlyArray @@ -1043,10 +1053,11 @@ export type ClientExclusiveInterface = { // Client-side only shutdown: () => Promise; - removeAllMessagesInConversation: ( + removeMessagesInConversation: ( conversationId: string, options: { logId: string; + receivedAt?: number; } ) => Promise; removeOtherData: () => Promise; diff --git a/ts/sql/Server.ts b/ts/sql/Server.ts index 7908282eaf0e..0ad994e9c7ea 100644 --- a/ts/sql/Server.ts +++ b/ts/sql/Server.ts @@ -184,6 +184,9 @@ import { attachmentDownloadJobSchema, type AttachmentDownloadJobType, } from '../types/AttachmentDownload'; +import { MAX_SYNC_TASK_ATTEMPTS } from '../util/syncTasks.types'; +import type { SyncTaskType } from '../util/syncTasks'; +import { isMoreRecentThan } from '../util/timestamp'; type ConversationRow = Readonly<{ json: string; @@ -360,6 +363,11 @@ const dataInterface: ServerInterface = { getMessagesBetween, getNearbyMessageFromDeletedSet, saveEditedMessage, + getMostRecentAddressableMessages, + + removeSyncTaskById, + saveSyncTasks, + getAllSyncTasks, getUnprocessedCount, getUnprocessedByIdsAndIncrementAttempts, @@ -2066,6 +2074,131 @@ function hasUserInitiatedMessages(conversationId: string): boolean { return exists !== 0; } +async function getMostRecentAddressableMessages( + conversationId: string, + limit = 5 +): Promise> { + const db = getReadonlyInstance(); + return getMostRecentAddressableMessagesSync(db, conversationId, limit); +} + +export function getMostRecentAddressableMessagesSync( + db: Database, + conversationId: string, + limit = 5 +): Array { + const [query, parameters] = sql` + SELECT json FROM messages + INDEXED BY messages_by_date_addressable + WHERE + conversationId IS ${conversationId} AND + isAddressableMessage = 1 + ORDER BY received_at DESC, sent_at DESC + LIMIT ${limit}; + `; + + const rows = db.prepare(query).all(parameters); + + return rows.map(row => jsonToObject(row.json)); +} + +async function removeSyncTaskById(id: string): Promise { + const db = await getWritableInstance(); + removeSyncTaskByIdSync(db, id); +} +export function removeSyncTaskByIdSync(db: Database, id: string): void { + const [query, parameters] = sql` + DELETE FROM syncTasks + WHERE id IS ${id} + `; + + db.prepare(query).run(parameters); +} +async function saveSyncTasks(tasks: Array): Promise { + const db = await getWritableInstance(); + return saveSyncTasksSync(db, tasks); +} +export function saveSyncTasksSync( + db: Database, + tasks: Array +): void { + return db.transaction(() => { + tasks.forEach(task => assertSync(saveSyncTaskSync(db, task))); + })(); +} +export function saveSyncTaskSync(db: Database, task: SyncTaskType): void { + const { id, attempts, createdAt, data, envelopeId, sentAt, type } = task; + + const [query, parameters] = sql` + INSERT INTO syncTasks ( + id, + attempts, + createdAt, + data, + envelopeId, + sentAt, + type + ) VALUES ( + ${id}, + ${attempts}, + ${createdAt}, + ${objectToJSON(data)}, + ${envelopeId}, + ${sentAt}, + ${type} + ) + `; + + db.prepare(query).run(parameters); +} +async function getAllSyncTasks(): Promise> { + const db = await getWritableInstance(); + return getAllSyncTasksSync(db); +} +export function getAllSyncTasksSync(db: Database): Array { + return db.transaction(() => { + const [selectAllQuery] = sql` + SELECT * FROM syncTasks ORDER BY createdAt ASC, sentAt ASC, id ASC + `; + + const rows = db.prepare(selectAllQuery).all(); + + const tasks: Array = rows.map(row => ({ + ...row, + data: jsonToObject(row.data), + })); + + const [query] = sql` + UPDATE syncTasks + SET attempts = attempts + 1 + `; + db.prepare(query).run(); + + const [toDelete, toReturn] = partition(tasks, task => { + if ( + isNormalNumber(task.attempts) && + task.attempts < MAX_SYNC_TASK_ATTEMPTS + ) { + return false; + } + if (isMoreRecentThan(task.createdAt, durations.WEEK)) { + return false; + } + + return true; + }); + + if (toDelete.length > 0) { + log.warn(`getAllSyncTasks: Removing ${toDelete.length} expired tasks`); + toDelete.forEach(task => { + assertSync(removeSyncTaskByIdSync(db, task.id)); + }); + } + + return toReturn; + })(); +} + function saveMessageSync( db: Database, data: MessageType, @@ -6036,6 +6169,7 @@ async function removeAll(): Promise { DELETE FROM storyDistributionMembers; DELETE FROM storyDistributions; DELETE FROM storyReads; + DELETE FROM syncTasks; DELETE FROM unprocessed; DELETE FROM uninstalled_sticker_packs; @@ -6078,6 +6212,7 @@ async function removeAllConfiguration(): Promise { DELETE FROM sendLogRecipients; DELETE FROM sessions; DELETE FROM signedPreKeys; + DELETE FROM syncTasks; DELETE FROM unprocessed; ` ); diff --git a/ts/sql/migrations/1060-addressable-messages-and-sync-tasks.ts b/ts/sql/migrations/1060-addressable-messages-and-sync-tasks.ts new file mode 100644 index 000000000000..c18dc785d2db --- /dev/null +++ b/ts/sql/migrations/1060-addressable-messages-and-sync-tasks.ts @@ -0,0 +1,56 @@ +// Copyright 2024 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +import type { Database } from '@signalapp/better-sqlite3'; + +import type { LoggerType } from '../../types/Logging'; + +export const version = 1060; + +export function updateToSchemaVersion1060( + currentVersion: number, + db: Database, + logger: LoggerType +): void { + if (currentVersion >= 1060) { + return; + } + + db.transaction(() => { + db.exec(` + ALTER TABLE messages + ADD COLUMN isAddressableMessage INTEGER + GENERATED ALWAYS AS ( + type IS NULL + OR + type IN ( + 'incoming', + 'outgoing' + ) + ); + + CREATE INDEX messages_by_date_addressable + ON messages ( + conversationId, isAddressableMessage, received_at, sent_at + ); + + CREATE TABLE syncTasks( + id TEXT PRIMARY KEY NOT NULL, + attempts INTEGER NOT NULL, + createdAt INTEGER NOT NULL, + data TEXT NOT NULL, + envelopeId TEXT NOT NULL, + sentAt INTEGER NOT NULL, + type TEXT NOT NULL + ) STRICT; + + CREATE INDEX syncTasks_order ON syncTasks ( + createdAt, sentAt, id + ) + `); + })(); + + db.pragma('user_version = 1060'); + + logger.info('updateToSchemaVersion1060: success!'); +} diff --git a/ts/sql/migrations/index.ts b/ts/sql/migrations/index.ts index d4426756b0c3..3b1ce12122c1 100644 --- a/ts/sql/migrations/index.ts +++ b/ts/sql/migrations/index.ts @@ -80,10 +80,11 @@ import { updateToSchemaVersion1010 } from './1010-call-links-table'; import { updateToSchemaVersion1020 } from './1020-self-merges'; import { updateToSchemaVersion1030 } from './1030-unblock-event'; import { updateToSchemaVersion1040 } from './1040-undownloaded-backed-up-media'; +import { updateToSchemaVersion1050 } from './1050-group-send-endorsements'; import { - updateToSchemaVersion1050, + updateToSchemaVersion1060, version as MAX_VERSION, -} from './1050-group-send-endorsements'; +} from './1060-addressable-messages-and-sync-tasks'; function updateToSchemaVersion1( currentVersion: number, @@ -2025,12 +2026,14 @@ export const SCHEMA_VERSIONS = [ updateToSchemaVersion970, updateToSchemaVersion980, updateToSchemaVersion990, + updateToSchemaVersion1000, updateToSchemaVersion1010, updateToSchemaVersion1020, updateToSchemaVersion1030, updateToSchemaVersion1040, updateToSchemaVersion1050, + updateToSchemaVersion1060, ]; export class DBVersionFromFutureError extends Error { diff --git a/ts/state/ducks/conversations.ts b/ts/state/ducks/conversations.ts index 0a940d8ec855..4521a2119c4c 100644 --- a/ts/state/ducks/conversations.ts +++ b/ts/state/ducks/conversations.ts @@ -3,6 +3,7 @@ import type { ThunkAction } from 'redux-thunk'; import { + chunk, difference, fromPairs, isEqual, @@ -184,6 +185,16 @@ import { getConversationIdForLogging } from '../../util/idForLogging'; import { singleProtoJobQueue } from '../../jobs/singleProtoJobQueue'; import MessageSender from '../../textsecure/SendMessage'; import { AttachmentDownloadUrgency } from '../../jobs/AttachmentDownloadManager'; +import type { + DeleteForMeSyncEventData, + MessageToDelete, +} from '../../textsecure/messageReceiverEvents'; +import { + getConversationToDelete, + getMessageToDelete, +} from '../../util/deleteForMe'; +import { MAX_MESSAGE_COUNT } from '../../util/deleteForMe.types'; +import { isEnabled } from '../../RemoteConfig'; // State @@ -1703,21 +1714,27 @@ function deleteMessages({ throw new Error('deleteMessage: No conversation found'); } - await Promise.all( - messageIds.map(async messageId => { - const message = await __DEPRECATED$getMessageById(messageId); - if (!message) { - throw new Error(`deleteMessages: Message ${messageId} missing!`); - } + const messages = ( + await Promise.all( + messageIds.map( + async (messageId): Promise => { + const message = await __DEPRECATED$getMessageById(messageId); + if (!message) { + throw new Error(`deleteMessages: Message ${messageId} missing!`); + } - const messageConversationId = message.get('conversationId'); - if (conversationId !== messageConversationId) { - throw new Error( - `deleteMessages: message conversation ${messageConversationId} doesn't match provided conversation ${conversationId}` - ); - } - }) - ); + const messageConversationId = message.get('conversationId'); + if (conversationId !== messageConversationId) { + throw new Error( + `deleteMessages: message conversation ${messageConversationId} doesn't match provided conversation ${conversationId}` + ); + } + + return getMessageToDelete(message.attributes); + } + ) + ) + ).filter(isNotNil); let nearbyMessageId: string | null = null; @@ -1743,6 +1760,34 @@ function deleteMessages({ if (nearbyMessageId != null) { dispatch(scrollToMessage(conversationId, nearbyMessageId)); } + + if (!isEnabled('desktop.deleteSync.send')) { + return; + } + if (messages.length === 0) { + return; + } + + const chunks = chunk(messages, MAX_MESSAGE_COUNT); + const conversationToDelete = getConversationToDelete( + conversation.attributes + ); + const timestamp = Date.now(); + + await Promise.all( + chunks.map(async items => { + const data: DeleteForMeSyncEventData = items.map(item => ({ + conversation: conversationToDelete, + message: item, + timestamp, + type: 'delete-message' as const, + })); + + await singleProtoJobQueue.add( + MessageSender.getDeleteForMeSyncMessage(data) + ); + }) + ); }; } @@ -1770,7 +1815,7 @@ function destroyMessages( undefined ); - await conversation.destroyMessages(); + await conversation.destroyMessages({ source: 'local-delete' }); drop(conversation.updateLastMessage()); }, }); diff --git a/ts/state/selectors/items.ts b/ts/state/selectors/items.ts index d5c1e664e707..88d2367ba26f 100644 --- a/ts/state/selectors/items.ts +++ b/ts/state/selectors/items.ts @@ -127,6 +127,13 @@ export const isInternalUser = createSelector( } ); +export const getDeleteSyncSendEnabled = createSelector( + getRemoteConfig, + (remoteConfig: ConfigMapType): boolean => { + return isRemoteConfigFlagEnabled(remoteConfig, 'desktop.deleteSync.send'); + } +); + // Note: ts/util/stories is the other place this check is done export const getStoriesEnabled = createSelector( getItems, @@ -242,3 +249,9 @@ export const getShowStickerPickerHint = createSelector( return state.showStickerPickerHint ?? false; } ); + +export const getLocalDeleteWarningShown = createSelector( + getItems, + (state: ItemsStateType): boolean => + Boolean(state.localDeleteWarningShown ?? false) +); diff --git a/ts/state/smart/ConversationHeader.tsx b/ts/state/smart/ConversationHeader.tsx index 7c34e433fd28..ee3157a3880a 100644 --- a/ts/state/smart/ConversationHeader.tsx +++ b/ts/state/smart/ConversationHeader.tsx @@ -40,6 +40,11 @@ import { } from '../selectors/conversations'; import { getHasStoriesSelector } from '../selectors/stories2'; import { getIntl, getTheme, getUserACI } from '../selectors/user'; +import { useItemsActions } from '../ducks/items'; +import { + getDeleteSyncSendEnabled, + getLocalDeleteWarningShown, +} from '../selectors/items'; export type OwnProps = { id: string; @@ -146,6 +151,7 @@ export const SmartConversationHeader = memo(function SmartConversationHeader({ const conversationName = useContactNameData(conversation); strictAssert(conversationName, 'conversationName is required'); + const isDeleteSyncSendEnabled = useSelector(getDeleteSyncSendEnabled); const isMissingMandatoryProfileSharing = getIsMissingRequiredProfileSharing(conversation); @@ -248,6 +254,11 @@ export const SmartConversationHeader = memo(function SmartConversationHeader({ const minimalConversation = useMinimalConversation(conversation); + const localDeleteWarningShown = useSelector(getLocalDeleteWarningShown); + const { putItem } = useItemsActions(); + const setLocalDeleteWarningShown = () => + putItem('localDeleteWarningShown', true); + return ( diff --git a/ts/state/smart/DeleteMessagesModal.tsx b/ts/state/smart/DeleteMessagesModal.tsx index ad7b8ded25c0..3e8dbbf52ee2 100644 --- a/ts/state/smart/DeleteMessagesModal.tsx +++ b/ts/state/smart/DeleteMessagesModal.tsx @@ -16,6 +16,12 @@ import { getLastSelectedMessage, } from '../selectors/conversations'; import { getDeleteMessagesProps } from '../selectors/globalModals'; +import { useItemsActions } from '../ducks/items'; +import { + getLocalDeleteWarningShown, + getDeleteSyncSendEnabled, +} from '../selectors/items'; +import { LocalDeleteWarningModal } from '../../components/LocalDeleteWarningModal'; export const SmartDeleteMessagesModal = memo( function SmartDeleteMessagesModal() { @@ -36,6 +42,7 @@ export const SmartDeleteMessagesModal = memo( [messageIds, isMe] ); const canDeleteForEveryone = useSelector(getCanDeleteForEveryone); + const isDeleteSyncSendEnabled = useSelector(getDeleteSyncSendEnabled); const lastSelectedMessage = useSelector(getLastSelectedMessage); const i18n = useSelector(getIntl); const { toggleDeleteMessagesModal } = useGlobalModalActions(); @@ -69,11 +76,25 @@ export const SmartDeleteMessagesModal = memo( onDelete?.(); }, [deleteMessagesForEveryone, messageIds, onDelete]); + const localDeleteWarningShown = useSelector(getLocalDeleteWarningShown); + const { putItem } = useItemsActions(); + if (!localDeleteWarningShown && isDeleteSyncSendEnabled) { + return ( + { + putItem('localDeleteWarningShown', true); + }} + /> + ); + } + return ( { }); afterEach(async () => { - await window.Signal.Data.removeAllMessagesInConversation(convo.id, { + await window.Signal.Data.removeMessagesInConversation(convo.id, { logId: ourServiceIdWithKeyChange, }); await window.Signal.Data.removeConversation(convo.id); @@ -104,7 +104,7 @@ describe('KeyChangeListener', () => { }); afterEach(async () => { - await window.Signal.Data.removeAllMessagesInConversation(groupConvo.id, { + await window.Signal.Data.removeMessagesInConversation(groupConvo.id, { logId: ourServiceIdWithKeyChange, }); await window.Signal.Data.removeConversation(groupConvo.id); diff --git a/ts/test-node/sql/migration_1060_test.ts b/ts/test-node/sql/migration_1060_test.ts new file mode 100644 index 000000000000..babcdb2414ee --- /dev/null +++ b/ts/test-node/sql/migration_1060_test.ts @@ -0,0 +1,302 @@ +// Copyright 2024 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +import { assert } from 'chai'; +import type { Database } from '@signalapp/better-sqlite3'; +import SQL from '@signalapp/better-sqlite3'; +import { v4 as generateGuid } from 'uuid'; + +import { + getAllSyncTasksSync, + getMostRecentAddressableMessagesSync, + removeSyncTaskByIdSync, + saveSyncTasksSync, +} from '../../sql/Server'; +import { insertData, updateToVersion } from './helpers'; +import { MAX_SYNC_TASK_ATTEMPTS } from '../../util/syncTasks.types'; +import { WEEK } from '../../util/durations'; + +import type { MessageAttributesType } from '../../model-types'; +import type { SyncTaskType } from '../../util/syncTasks'; + +/* eslint-disable camelcase */ + +function generateMessage(json: MessageAttributesType) { + const { conversationId, received_at, sent_at, type } = json; + + return { + conversationId, + json, + received_at, + sent_at, + type, + }; +} + +describe('SQL/updateToSchemaVersion1060', () => { + let db: Database; + beforeEach(() => { + db = new SQL(':memory:'); + updateToVersion(db, 1060); + }); + + afterEach(() => { + db.close(); + }); + + describe('Addressable Messages', () => { + describe('Storing of new attachment jobs', () => { + it('returns only incoming/outgoing messages', () => { + const conversationId = generateGuid(); + const otherConversationId = generateGuid(); + + insertData(db, 'messages', [ + generateMessage({ + id: '1', + conversationId, + type: 'incoming', + received_at: 1, + sent_at: 1, + timestamp: 1, + }), + generateMessage({ + id: '2', + conversationId, + type: 'story', + received_at: 2, + sent_at: 2, + timestamp: 2, + }), + generateMessage({ + id: '3', + conversationId, + type: 'outgoing', + received_at: 3, + sent_at: 3, + timestamp: 3, + }), + generateMessage({ + id: '4', + conversationId, + type: 'group-v1-migration', + received_at: 4, + sent_at: 4, + timestamp: 4, + }), + generateMessage({ + id: '5', + conversationId, + type: 'group-v2-change', + received_at: 5, + sent_at: 5, + timestamp: 5, + }), + generateMessage({ + id: '6', + conversationId, + type: 'incoming', + received_at: 6, + sent_at: 6, + timestamp: 6, + }), + generateMessage({ + id: '7', + conversationId, + type: 'profile-change', + received_at: 7, + sent_at: 7, + timestamp: 7, + }), + generateMessage({ + id: '8', + conversationId: otherConversationId, + type: 'incoming', + received_at: 8, + sent_at: 8, + timestamp: 8, + }), + ]); + + const messages = getMostRecentAddressableMessagesSync( + db, + conversationId + ); + + assert.lengthOf(messages, 3); + assert.deepEqual(messages, [ + { + id: '6', + conversationId, + type: 'incoming', + received_at: 6, + sent_at: 6, + timestamp: 6, + }, + { + id: '3', + conversationId, + type: 'outgoing', + received_at: 3, + sent_at: 3, + timestamp: 3, + }, + { + id: '1', + conversationId, + type: 'incoming', + received_at: 1, + sent_at: 1, + timestamp: 1, + }, + ]); + }); + + it('ensures that index is used for getMostRecentAddressableMessagesSync, with storyId', () => { + const { detail } = db + .prepare( + ` + EXPLAIN QUERY PLAN + SELECT json FROM messages + INDEXED BY messages_by_date_addressable + WHERE + conversationId IS 'not-important' AND + isAddressableMessage = 1 + ORDER BY received_at DESC, sent_at DESC + LIMIT 5; + ` + ) + .get(); + + assert.notInclude(detail, 'B-TREE'); + assert.notInclude(detail, 'SCAN'); + assert.include( + detail, + 'SEARCH messages USING INDEX messages_by_date_addressable (conversationId=? AND isAddressableMessage=?)' + ); + }); + }); + }); + + describe('Sync Tasks', () => { + it('creates tasks in bulk, and fetches all', () => { + const now = Date.now(); + const expected: Array = [ + { + id: generateGuid(), + attempts: 1, + createdAt: now + 1, + data: { + jsonField: 'one', + data: 1, + }, + envelopeId: 'envelope-id-1', + sentAt: 1, + type: 'delete-conversation', + }, + { + id: generateGuid(), + attempts: 2, + createdAt: now + 2, + data: { + jsonField: 'two', + data: 2, + }, + envelopeId: 'envelope-id-2', + sentAt: 2, + type: 'delete-conversation', + }, + { + id: generateGuid(), + attempts: 3, + createdAt: now + 3, + data: { + jsonField: 'three', + data: 3, + }, + envelopeId: 'envelope-id-3', + sentAt: 3, + type: 'delete-conversation', + }, + ]; + + saveSyncTasksSync(db, expected); + + const actual = getAllSyncTasksSync(db); + assert.deepEqual(expected, actual, 'before delete'); + + removeSyncTaskByIdSync(db, expected[1].id); + + const actualAfterDelete = getAllSyncTasksSync(db); + assert.deepEqual( + [ + { ...expected[0], attempts: 2 }, + { ...expected[2], attempts: 4 }, + ], + actualAfterDelete, + 'after delete' + ); + }); + + it('getAllSyncTasksSync expired tasks', () => { + const now = Date.now(); + const twoWeeksAgo = now - WEEK * 2; + const expected: Array = [ + { + id: generateGuid(), + attempts: MAX_SYNC_TASK_ATTEMPTS, + createdAt: twoWeeksAgo, + data: { + jsonField: 'expired', + data: 1, + }, + envelopeId: 'envelope-id-1', + sentAt: 1, + type: 'delete-conversation', + }, + { + id: generateGuid(), + attempts: 2, + createdAt: twoWeeksAgo, + data: { + jsonField: 'old-but-few-attemts', + data: 2, + }, + envelopeId: 'envelope-id-2', + sentAt: 2, + type: 'delete-conversation', + }, + { + id: generateGuid(), + attempts: MAX_SYNC_TASK_ATTEMPTS * 2, + createdAt: now, + data: { + jsonField: 'new-but-many-attempts', + data: 3, + }, + envelopeId: 'envelope-id-3', + sentAt: 3, + type: 'delete-conversation', + }, + { + id: generateGuid(), + attempts: MAX_SYNC_TASK_ATTEMPTS - 1, + createdAt: now + 1, + data: { + jsonField: 'new-and-fresh', + data: 4, + }, + envelopeId: 'envelope-id-4', + sentAt: 4, + type: 'delete-conversation', + }, + ]; + + saveSyncTasksSync(db, expected); + + const actual = getAllSyncTasksSync(db); + + assert.lengthOf(actual, 3); + assert.deepEqual([expected[1], expected[2], expected[3]], actual); + }); + }); +}); diff --git a/ts/textsecure/MessageReceiver.ts b/ts/textsecure/MessageReceiver.ts index d9cf0981d0c0..d5c780ef55f3 100644 --- a/ts/textsecure/MessageReceiver.ts +++ b/ts/textsecure/MessageReceiver.ts @@ -130,6 +130,13 @@ import { StoryRecipientUpdateEvent, CallLogEventSyncEvent, CallLinkUpdateSyncEvent, + DeleteForMeSyncEvent, +} from './messageReceiverEvents'; +import type { + MessageToDelete, + DeleteForMeSyncEventData, + DeleteForMeSyncTarget, + ConversationToDelete, } from './messageReceiverEvents'; import * as log from '../logging/log'; import * as durations from '../util/durations'; @@ -686,6 +693,11 @@ export default class MessageReceiver handler: (ev: CallLogEventSyncEvent) => void ): void; + public override addEventListener( + name: 'deleteForMeSync', + handler: (ev: DeleteForMeSyncEvent) => void + ): void; + public override addEventListener(name: string, handler: EventHandler): void { return super.addEventListener(name, handler); } @@ -3165,6 +3177,9 @@ export default class MessageReceiver if (syncMessage.callLogEvent) { return this.handleCallLogEvent(envelope, syncMessage.callLogEvent); } + if (syncMessage.deleteForMe) { + return this.handleDeleteForMeSync(envelope, syncMessage.deleteForMe); + } this.removeFromCache(envelope); const envelopeId = getEnvelopeId(envelope); @@ -3615,6 +3630,118 @@ export default class MessageReceiver log.info('handleCallLogEvent: finished'); } + private async handleDeleteForMeSync( + envelope: ProcessedEnvelope, + deleteSync: Proto.SyncMessage.IDeleteForMe + ): Promise { + const logId = getEnvelopeId(envelope); + log.info('MessageReceiver.handleDeleteForMeSync', logId); + + logUnexpectedUrgentValue(envelope, 'deleteForMeSync'); + + const { timestamp } = envelope; + let eventData: DeleteForMeSyncEventData = []; + + try { + if (deleteSync.messageDeletes?.length) { + const messageDeletes: Array = + deleteSync.messageDeletes + .flatMap((item): Array | undefined => { + const messages = item.messages + ?.map(message => processMessageToDelete(message, logId)) + .filter(isNotNil); + const conversation = item.conversation + ? processConversationToDelete(item.conversation, logId) + : undefined; + + if (messages?.length && conversation) { + // We want each message in its own task + return messages.map(innerItem => { + return { + type: 'delete-message' as const, + message: innerItem, + conversation, + timestamp, + }; + }); + } + + return undefined; + }) + .filter(isNotNil); + + eventData = eventData.concat(messageDeletes); + } + if (deleteSync.conversationDeletes?.length) { + const conversationDeletes: Array = + deleteSync.conversationDeletes + .map(item => { + const mostRecentMessages = item.mostRecentMessages + ?.map(message => processMessageToDelete(message, logId)) + .filter(isNotNil); + const conversation = item.conversation + ? processConversationToDelete(item.conversation, logId) + : undefined; + + if (mostRecentMessages?.length && conversation) { + return { + type: 'delete-conversation' as const, + conversation, + isFullDelete: Boolean(item.isFullDelete), + mostRecentMessages, + timestamp, + }; + } + + return undefined; + }) + .filter(isNotNil); + + eventData = eventData.concat(conversationDeletes); + } + if (deleteSync.localOnlyConversationDeletes?.length) { + const localOnlyConversationDeletes: Array = + deleteSync.localOnlyConversationDeletes + .map(item => { + const conversation = item.conversation + ? processConversationToDelete(item.conversation, logId) + : undefined; + + if (conversation) { + return { + type: 'delete-local-conversation' as const, + conversation, + timestamp, + }; + } + + return undefined; + }) + .filter(isNotNil); + + eventData = eventData.concat(localOnlyConversationDeletes); + } + if (!eventData.length) { + throw new Error(`${logId}: Nothing found in sync message!`); + } + } catch (error: unknown) { + this.removeFromCache(envelope); + + throw error; + } + + const deleteSyncEventSync = new DeleteForMeSyncEvent( + eventData, + timestamp, + envelope.id, + this.removeFromCache.bind(this, envelope) + ); + + await this.dispatchAndWait(logId, deleteSyncEventSync); + + log.info('handleDeleteForMeSync: finished'); + } + private async handleContacts( envelope: ProcessedEnvelope, contactSyncProto: Proto.SyncMessage.IContacts @@ -3820,3 +3947,70 @@ function envelopeTypeToCiphertextType(type: number | undefined): number { throw new Error(`envelopeTypeToCiphertextType: Unknown type ${type}`); } + +function processMessageToDelete( + target: Proto.SyncMessage.DeleteForMe.IAddressableMessage, + logId: string +): MessageToDelete | undefined { + const sentAt = target.sentTimestamp?.toNumber(); + if (!isNumber(sentAt)) { + log.warn( + `${logId}/processMessageToDelete: No sentTimestamp found! Dropping AddressableMessage.` + ); + return undefined; + } + + if (target.authorAci) { + return { + type: 'aci' as const, + authorAci: normalizeAci( + target.authorAci, + `${logId}/processMessageToDelete` + ), + sentAt, + }; + } + if (target.authorE164) { + return { + type: 'e164' as const, + authorE164: target.authorE164, + sentAt, + }; + } + + log.warn( + `${logId}/processMessageToDelete: No author field found! Dropping AddressableMessage.` + ); + return undefined; +} + +function processConversationToDelete( + target: Proto.SyncMessage.DeleteForMe.IConversationIdentifier, + logId: string +): ConversationToDelete | undefined { + const { threadAci, threadGroupId, threadE164 } = target; + + if (threadAci) { + return { + type: 'aci' as const, + aci: normalizeAci(threadAci, `${logId}/threadAci`), + }; + } + if (threadGroupId) { + return { + type: 'group' as const, + groupId: Buffer.from(threadGroupId).toString('base64'), + }; + } + if (threadE164) { + return { + type: 'e164' as const, + e164: threadE164, + }; + } + + log.warn( + `${logId}/processConversationToDelete: No identifier field found! Dropping ConversationIdentifier.` + ); + return undefined; +} diff --git a/ts/textsecure/SendMessage.ts b/ts/textsecure/SendMessage.ts index d3e0e02323f7..c914986942ff 100644 --- a/ts/textsecure/SendMessage.ts +++ b/ts/textsecure/SendMessage.ts @@ -82,6 +82,13 @@ import { } from '../types/EmbeddedContact'; import { missingCaseError } from '../util/missingCaseError'; import { drop } from '../util/drop'; +import type { + ConversationToDelete, + DeleteForMeSyncEventData, + DeleteMessageSyncTarget, + MessageToDelete, +} from './messageReceiverEvents'; +import { getConversationFromTarget } from '../util/deleteForMe'; export type SendMetadataType = { [serviceId: ServiceIdString]: { @@ -1475,6 +1482,91 @@ export default class MessageSender { }; } + static getDeleteForMeSyncMessage( + data: DeleteForMeSyncEventData + ): SingleProtoJobData { + const myAci = window.textsecure.storage.user.getCheckedAci(); + + const deleteForMe = new Proto.SyncMessage.DeleteForMe(); + const messageDeletes: Map< + string, + Array + > = new Map(); + + data.forEach(item => { + if (item.type === 'delete-message') { + const conversation = getConversationFromTarget(item.conversation); + if (!conversation) { + throw new Error( + 'getDeleteForMeSyncMessage: Failed to find conversation for delete-message' + ); + } + const existing = messageDeletes.get(conversation.id); + if (existing) { + existing.push(item); + } else { + messageDeletes.set(conversation.id, [item]); + } + } else if (item.type === 'delete-conversation') { + const mostRecentMessages = + item.mostRecentMessages.map(toAddressableMessage); + const conversation = toConversationIdentifier(item.conversation); + + deleteForMe.conversationDeletes = deleteForMe.conversationDeletes || []; + deleteForMe.conversationDeletes.push({ + mostRecentMessages, + conversation, + isFullDelete: true, + }); + } else if (item.type === 'delete-local-conversation') { + const conversation = toConversationIdentifier(item.conversation); + + deleteForMe.localOnlyConversationDeletes = + deleteForMe.localOnlyConversationDeletes || []; + deleteForMe.localOnlyConversationDeletes.push({ + conversation, + }); + } else { + throw missingCaseError(item); + } + }); + + if (messageDeletes.size > 0) { + for (const items of messageDeletes.values()) { + const first = items[0]; + if (!first) { + throw new Error('Failed to fetch first from items'); + } + const messages = items.map(item => toAddressableMessage(item.message)); + const conversation = toConversationIdentifier(first.conversation); + + deleteForMe.messageDeletes = deleteForMe.messageDeletes || []; + deleteForMe.messageDeletes.push({ + messages, + conversation, + }); + } + } + + const syncMessage = this.createSyncMessage(); + syncMessage.deleteForMe = deleteForMe; + const contentMessage = new Proto.Content(); + contentMessage.syncMessage = syncMessage; + + const { ContentHint } = Proto.UnidentifiedSenderMessage.Message; + + return { + contentHint: ContentHint.RESENDABLE, + serviceId: myAci, + isSyncMessage: true, + protoBase64: Bytes.toBase64( + Proto.Content.encode(contentMessage).finish() + ), + type: 'deleteForMeSync', + urgent: false, + }; + } + async syncReadMessages( reads: ReadonlyArray<{ senderAci?: AciString; @@ -2253,3 +2345,37 @@ export default class MessageSender { return this.server.sendChallengeResponse(challengeResponse); } } + +// Helpers + +function toAddressableMessage(message: MessageToDelete) { + const targetMessage = new Proto.SyncMessage.DeleteForMe.AddressableMessage(); + targetMessage.sentTimestamp = Long.fromNumber(message.sentAt); + + if (message.type === 'aci') { + targetMessage.authorAci = message.authorAci; + } else if (message.type === 'e164') { + targetMessage.authorE164 = message.authorE164; + } else { + throw missingCaseError(message); + } + + return targetMessage; +} + +function toConversationIdentifier(conversation: ConversationToDelete) { + const targetConversation = + new Proto.SyncMessage.DeleteForMe.ConversationIdentifier(); + + if (conversation.type === 'aci') { + targetConversation.threadAci = conversation.aci; + } else if (conversation.type === 'group') { + targetConversation.threadGroupId = Bytes.fromBase64(conversation.groupId); + } else if (conversation.type === 'e164') { + targetConversation.threadE164 = conversation.e164; + } else { + throw missingCaseError(conversation); + } + + return targetConversation; +} diff --git a/ts/textsecure/messageReceiverEvents.ts b/ts/textsecure/messageReceiverEvents.ts index dbfec138515a..c00068508c95 100644 --- a/ts/textsecure/messageReceiverEvents.ts +++ b/ts/textsecure/messageReceiverEvents.ts @@ -3,6 +3,7 @@ /* eslint-disable max-classes-per-file */ import type { PublicKey } from '@signalapp/libsignal-client'; +import { z } from 'zod'; import type { SignalService as Proto } from '../protobuf'; import type { ServiceIdString, AciString } from '../types/ServiceId'; @@ -15,6 +16,7 @@ import type { import type { ContactDetailsWithAvatar } from './ContactsParser'; import type { CallEventDetails, CallLogEvent } from '../types/CallDisposition'; import type { CallLinkUpdateSyncType } from '../types/CallLink'; +import { isAciString } from '../util/isAciString'; export class EmptyEvent extends Event { constructor() { @@ -456,6 +458,78 @@ export class CallLinkUpdateSyncEvent extends ConfirmableEvent { } } +const messageToDeleteSchema = z.union([ + z.object({ + type: z.literal('aci').readonly(), + authorAci: z.string().refine(isAciString), + sentAt: z.number(), + }), + z.object({ + type: z.literal('e164').readonly(), + authorE164: z.string(), + sentAt: z.number(), + }), +]); + +export type MessageToDelete = z.infer; + +const conversationToDeleteSchema = z.union([ + z.object({ + type: z.literal('group').readonly(), + groupId: z.string(), + }), + z.object({ + type: z.literal('aci').readonly(), + aci: z.string().refine(isAciString), + }), + z.object({ + type: z.literal('e164').readonly(), + e164: z.string(), + }), +]); + +export type ConversationToDelete = z.infer; + +export const deleteMessageSchema = z.object({ + type: z.literal('delete-message').readonly(), + conversation: conversationToDeleteSchema, + message: messageToDeleteSchema, + timestamp: z.number(), +}); +export type DeleteMessageSyncTarget = z.infer; +export const deleteConversationSchema = z.object({ + type: z.literal('delete-conversation').readonly(), + conversation: conversationToDeleteSchema, + mostRecentMessages: z.array(messageToDeleteSchema), + isFullDelete: z.boolean(), + timestamp: z.number(), +}); +export const deleteLocalConversationSchema = z.object({ + type: z.literal('delete-local-conversation').readonly(), + conversation: conversationToDeleteSchema, + timestamp: z.number(), +}); +export const deleteForMeSyncTargetSchema = z.union([ + deleteMessageSchema, + deleteConversationSchema, + deleteLocalConversationSchema, +]); + +export type DeleteForMeSyncTarget = z.infer; + +export type DeleteForMeSyncEventData = ReadonlyArray; + +export class DeleteForMeSyncEvent extends ConfirmableEvent { + constructor( + public readonly deleteForMeSync: DeleteForMeSyncEventData, + public readonly timestamp: number, + public readonly envelopeId: string, + confirm: ConfirmCallback + ) { + super('deleteForMeSync', confirm); + } +} + export type CallLogEventSyncEventData = Readonly<{ event: CallLogEvent; timestamp: number; diff --git a/ts/types/Storage.d.ts b/ts/types/Storage.d.ts index 14079cede7c1..14ca43d69ad6 100644 --- a/ts/types/Storage.d.ts +++ b/ts/types/Storage.d.ts @@ -79,6 +79,7 @@ export type StorageAccessType = { lastAttemptedToRefreshProfilesAt: number; lastResortKeyUpdateTime: number; lastResortKeyUpdateTimePNI: number; + localDeleteWarningShown: boolean; masterKey: string; masterKeyLastRequestTime: number; maxPreKeyId: number; diff --git a/ts/types/StorageUIKeys.ts b/ts/types/StorageUIKeys.ts index 7b3b3532e2a9..018e06edce9b 100644 --- a/ts/types/StorageUIKeys.ts +++ b/ts/types/StorageUIKeys.ts @@ -23,6 +23,7 @@ export const STORAGE_UI_KEYS: ReadonlyArray = [ 'hasCompletedSafetyNumberOnboarding', 'hasCompletedUsernameLinkOnboarding', 'hide-menu-bar', + 'localDeleteWarningShown', 'incoming-call-notification', 'navTabsCollapsed', 'notification-draw-attention', diff --git a/ts/util/deleteForMe.ts b/ts/util/deleteForMe.ts new file mode 100644 index 000000000000..65c78d4f3ea5 --- /dev/null +++ b/ts/util/deleteForMe.ts @@ -0,0 +1,261 @@ +// Copyright 2020 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +import { last, sortBy } from 'lodash'; + +import * as log from '../logging/log'; +import { isAciString } from './isAciString'; +import { isGroup, isGroupV2 } from './whatTypeOfConversation'; +import { + getConversationIdForLogging, + getMessageIdForLogging, +} from './idForLogging'; +import { missingCaseError } from './missingCaseError'; +import { getMessageSentTimestampSet } from './getMessageSentTimestampSet'; +import { getAuthor } from '../messages/helpers'; +import dataInterface, { deleteAndCleanup } from '../sql/Client'; + +import type { + ConversationAttributesType, + MessageAttributesType, +} from '../model-types'; +import type { ConversationModel } from '../models/conversations'; +import type { + ConversationToDelete, + MessageToDelete, +} from '../textsecure/messageReceiverEvents'; +import type { AciString } from '../types/ServiceId'; + +const { + getMessagesBySentAt, + getMostRecentAddressableMessages, + removeMessagesInConversation, +} = dataInterface; + +export function doesMessageMatch({ + conversationId, + message, + query, + sentTimestamps, +}: { + message: MessageAttributesType; + conversationId: string; + query: MessageQuery; + sentTimestamps: ReadonlySet; +}): boolean { + const author = getAuthor(message); + + const conversationMatches = message.conversationId === conversationId; + const aciMatches = + query.authorAci && author?.attributes.serviceId === query.authorAci; + const e164Matches = + query.authorE164 && author?.attributes.e164 === query.authorE164; + const timestampMatches = sentTimestamps.has(query.sentAt); + + return Boolean( + conversationMatches && timestampMatches && (aciMatches || e164Matches) + ); +} + +export async function findMatchingMessage( + conversationId: string, + query: MessageQuery +): Promise { + const sentAtMatches = await getMessagesBySentAt(query.sentAt); + + if (!sentAtMatches.length) { + return undefined; + } + + return sentAtMatches.find(message => { + const sentTimestamps = getMessageSentTimestampSet(message); + return doesMessageMatch({ + conversationId, + message, + query, + sentTimestamps, + }); + }); +} + +export async function deleteMessage( + conversationId: string, + targetMessage: MessageToDelete, + logId: string +): Promise { + const query = getMessageQueryFromTarget(targetMessage); + const found = await findMatchingMessage(conversationId, query); + + if (!found) { + log.warn(`${logId}: Couldn't find matching message`); + return false; + } + + await deleteAndCleanup([found], logId); + + return true; +} + +export async function deleteConversation( + conversation: ConversationModel, + mostRecentMessages: Array, + isFullDelete: boolean, + logId: string +): Promise { + const queries = mostRecentMessages.map(getMessageQueryFromTarget); + const found = await Promise.all( + queries.map(query => findMatchingMessage(conversation.id, query)) + ); + + const sorted = sortBy(found, 'received_at'); + const newestMessage = last(sorted); + if (newestMessage) { + const { received_at: receivedAt } = newestMessage; + + await removeMessagesInConversation(conversation.id, { + receivedAt, + logId: `${logId}(receivedAt=${receivedAt})`, + }); + } + + if (!newestMessage) { + log.warn(`${logId}: Found no target messages for delete`); + } + + if (isFullDelete) { + log.info(`${logId}: isFullDelete=true, proceeding to local-only delete`); + return deleteLocalOnlyConversation(conversation, logId); + } + + return true; +} + +export async function deleteLocalOnlyConversation( + conversation: ConversationModel, + logId: string +): Promise { + const limit = 1; + const messages = await getMostRecentAddressableMessages( + conversation.id, + limit + ); + if (messages.length > 0) { + log.warn( + `${logId}: Attempted local-only delete but found an addressable message` + ); + return false; + } + + // This will delete all messages and remove the conversation from the left pane. + // We need to call destroyMessagesInner, since we're already in conversation.queueJob() + await conversation.destroyMessagesInner({ + logId, + source: 'local-delete-sync', + }); + + return true; +} + +export function getConversationFromTarget( + targetConversation: ConversationToDelete +): ConversationModel | undefined { + const { type } = targetConversation; + + if (type === 'aci') { + return window.ConversationController.get(targetConversation.aci); + } + if (type === 'group') { + return window.ConversationController.get(targetConversation.groupId); + } + if (type === 'e164') { + return window.ConversationController.get(targetConversation.e164); + } + + throw missingCaseError(type); +} + +type MessageQuery = { + sentAt: number; + authorAci?: AciString; + authorE164?: string; +}; + +export function getMessageQueryFromTarget( + targetMessage: MessageToDelete +): MessageQuery { + const { type, sentAt } = targetMessage; + + if (type === 'aci') { + if (!isAciString(targetMessage.authorAci)) { + throw new Error('Provided authorAci was not an ACI!'); + } + return { sentAt, authorAci: targetMessage.authorAci }; + } + if (type === 'e164') { + return { sentAt, authorE164: targetMessage.authorE164 }; + } + + throw missingCaseError(type); +} + +export function getConversationToDelete( + attributes: ConversationAttributesType +): ConversationToDelete { + const { groupId, serviceId: aci, e164 } = attributes; + const idForLogging = getConversationIdForLogging(attributes); + const logId = `getConversationToDelete(${idForLogging})`; + + if (isGroupV2(attributes) && groupId) { + return { + type: 'group', + groupId, + }; + } + if (isGroup(attributes)) { + throw new Error(`${logId}: is a group, but not groupV2 or no groupId!`); + } + if (aci && isAciString(aci)) { + return { + type: 'aci', + aci, + }; + } + if (e164) { + return { + type: 'e164', + e164, + }; + } + + throw new Error(`${logId}: No valid identifier found!`); +} + +export function getMessageToDelete( + attributes: MessageAttributesType +): MessageToDelete | undefined { + const logId = `getMessageToDelete(${getMessageIdForLogging(attributes)})`; + const { sent_at: sentAt } = attributes; + + const author = getAuthor(attributes); + const authorAci = author?.get('serviceId'); + const authorE164 = author?.get('e164'); + + if (authorAci && isAciString(authorAci)) { + return { + type: 'aci' as const, + authorAci, + sentAt, + }; + } + if (authorE164) { + return { + type: 'e164' as const, + authorE164, + sentAt, + }; + } + + log.warn(`${logId}: Message was missing source ACI/e164`); + + return undefined; +} diff --git a/ts/util/deleteForMe.types.ts b/ts/util/deleteForMe.types.ts new file mode 100644 index 000000000000..32898d672048 --- /dev/null +++ b/ts/util/deleteForMe.types.ts @@ -0,0 +1,4 @@ +// Copyright 2020 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +export const MAX_MESSAGE_COUNT = 500; diff --git a/ts/util/handleMessageSend.ts b/ts/util/handleMessageSend.ts index e9554fb82a49..bf027b700e6c 100644 --- a/ts/util/handleMessageSend.ts +++ b/ts/util/handleMessageSend.ts @@ -55,6 +55,7 @@ export const sendTypesEnum = z.enum([ 'pniIdentitySync', // Syncs, default non-urgent + 'deleteForMeSync', 'fetchLatestManifestSync', 'fetchLocalProfileSync', 'messageRequestSync', diff --git a/ts/util/modifyTargetMessage.ts b/ts/util/modifyTargetMessage.ts index 36724137ea9e..f2f63b4fca49 100644 --- a/ts/util/modifyTargetMessage.ts +++ b/ts/util/modifyTargetMessage.ts @@ -9,6 +9,7 @@ import type { SendStateByConversationId } from '../messages/MessageSendState'; import * as Edits from '../messageModifiers/Edits'; import * as log from '../logging/log'; import * as Deletes from '../messageModifiers/Deletes'; +import * as DeletesForMe from '../messageModifiers/DeletesForMe'; import * as MessageReceipts from '../messageModifiers/MessageReceipts'; import * as Reactions from '../messageModifiers/Reactions'; import * as ReadSyncs from '../messageModifiers/ReadSyncs'; @@ -29,6 +30,12 @@ import { missingCaseError } from './missingCaseError'; import { reduce } from './iterables'; import { strictAssert } from './assert'; +export enum ModifyTargetMessageResult { + Modified = 'Modified', + NotModified = 'MotModified', + Deleted = 'Deleted', +} + // This function is called twice - once from handleDataMessage, and then again from // saveAndNotify, a function called at the end of handleDataMessage as a cleanup for // any missed out-of-order events. @@ -36,7 +43,7 @@ export async function modifyTargetMessage( message: MessageModel, conversation: ConversationModel, options?: { isFirstRun: boolean; skipEdits: boolean } -): Promise { +): Promise { const { isFirstRun = false, skipEdits = false } = options ?? {}; const logId = `modifyTargetMessage/${message.idForLogging()}`; @@ -45,6 +52,15 @@ export async function modifyTargetMessage( const ourAci = window.textsecure.storage.user.getCheckedAci(); const sourceServiceId = getSourceServiceId(message.attributes); + const syncDeletes = await DeletesForMe.forMessage(message.attributes); + if (syncDeletes.length) { + if (!isFirstRun) { + await window.Signal.Data.removeMessage(message.id); + } + + return ModifyTargetMessageResult.Deleted; + } + if (type === 'outgoing' || (type === 'story' && ourAci === sourceServiceId)) { const sendActions = MessageReceipts.forMessage(message).map(receipt => { let sendActionType: SendActionType; @@ -274,4 +290,8 @@ export async function modifyTargetMessage( ) ); } + + return changed + ? ModifyTargetMessageResult.Modified + : ModifyTargetMessageResult.NotModified; } diff --git a/ts/util/syncTasks.ts b/ts/util/syncTasks.ts new file mode 100644 index 000000000000..ce883a4e3c6d --- /dev/null +++ b/ts/util/syncTasks.ts @@ -0,0 +1,138 @@ +// Copyright 2020 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +import { z } from 'zod'; +import type { ZodSchema } from 'zod'; + +import * as log from '../logging/log'; +import * as DeletesForMe from '../messageModifiers/DeletesForMe'; +import { + deleteMessageSchema, + deleteConversationSchema, + deleteLocalConversationSchema, +} from '../textsecure/messageReceiverEvents'; + +import { + deleteConversation, + deleteLocalOnlyConversation, + getConversationFromTarget, +} from './deleteForMe'; +import { drop } from './drop'; + +const syncTaskDataSchema = z.union([ + deleteMessageSchema, + deleteConversationSchema, + deleteLocalConversationSchema, +]); +export type SyncTaskData = z.infer; + +export type SyncTaskType = Readonly<{ + id: string; + attempts: number; + createdAt: number; + data: unknown; + envelopeId: string; + sentAt: number; + type: SyncTaskData['type']; +}>; + +const SCHEMAS_BY_TYPE: Record = { + 'delete-message': deleteMessageSchema, + 'delete-conversation': deleteConversationSchema, + 'delete-local-conversation': deleteLocalConversationSchema, +}; + +function toLogId(task: SyncTaskType) { + return `task=${task.id},timestamp:${task},type=${task.type},envelopeId=${task.envelopeId}`; +} + +export async function queueSyncTasks( + tasks: Array, + removeSyncTaskById: (id: string) => Promise +): Promise { + const logId = 'queueSyncTasks'; + + for (let i = 0, max = tasks.length; i < max; i += 1) { + const task = tasks[i]; + const { id, envelopeId, type, sentAt, data } = task; + const innerLogId = `${logId}(${toLogId(task)})`; + + const schema = SCHEMAS_BY_TYPE[type]; + if (!schema) { + log.error(`${innerLogId}: Schema not found. Deleting.`); + // eslint-disable-next-line no-await-in-loop + await removeSyncTaskById(id); + return; + } + const parseResult = syncTaskDataSchema.safeParse(data); + if (!parseResult.success) { + log.error( + `${innerLogId}: Failed to parse. Deleting. Error: ${parseResult.error}` + ); + // eslint-disable-next-line no-await-in-loop + await removeSyncTaskById(id); + return; + } + + const { data: parsed } = parseResult; + + if (parsed.type === 'delete-message') { + // eslint-disable-next-line no-await-in-loop + await DeletesForMe.onDelete({ + conversation: parsed.conversation, + envelopeId, + message: parsed.message, + syncTaskId: id, + timestamp: sentAt, + }); + } else if (parsed.type === 'delete-conversation') { + const { + conversation: targetConversation, + mostRecentMessages, + isFullDelete, + } = parsed; + const conversation = getConversationFromTarget(targetConversation); + if (!conversation) { + log.error(`${innerLogId}: Conversation not found!`); + continue; + } + drop( + conversation.queueJob(innerLogId, async () => { + log.info(`${logId}: Starting...`); + const result = await deleteConversation( + conversation, + mostRecentMessages, + isFullDelete, + innerLogId + ); + if (result) { + await removeSyncTaskById(id); + } + log.info(`${logId}: Done, result=${result}`); + }) + ); + } else if (parsed.type === 'delete-local-conversation') { + const { conversation: targetConversation } = parsed; + const conversation = getConversationFromTarget(targetConversation); + if (!conversation) { + log.error(`${innerLogId}: Conversation not found!`); + continue; + } + drop( + conversation.queueJob(innerLogId, async () => { + log.info(`${logId}: Starting...`); + const result = await deleteLocalOnlyConversation( + conversation, + innerLogId + ); + + // Note: we remove even with a 'false' result because we're only gonna + // get more messages in this conversation from here! + await removeSyncTaskById(id); + + log.info(`${logId}: Done; result=${result}`); + }) + ); + } + } +} diff --git a/ts/util/syncTasks.types.ts b/ts/util/syncTasks.types.ts new file mode 100644 index 000000000000..f8a32125d8c2 --- /dev/null +++ b/ts/util/syncTasks.types.ts @@ -0,0 +1,4 @@ +// Copyright 2020 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +export const MAX_SYNC_TASK_ATTEMPTS = 5;