From 0a52318be64694b4ad6f3e2ee03867705c7c9edf Mon Sep 17 00:00:00 2001 From: Scott Nonnenberg Date: Fri, 4 Mar 2022 11:22:31 -0800 Subject: [PATCH] Delete for everyone: Track sends and show failure states --- _locales/en/messages.json | 12 ++ .../conversation/Message.stories.tsx | 44 +++++- ts/components/conversation/Message.tsx | 34 +++-- .../conversation/MessageDetail.stories.tsx | 3 + ts/components/conversation/MessageDetail.tsx | 3 + .../conversation/MessageMetadata.tsx | 10 +- ts/components/conversation/Quote.stories.tsx | 3 + .../conversation/Timeline.stories.tsx | 17 +++ ts/components/conversation/Timeline.tsx | 1 + .../conversation/TimelineItem.stories.tsx | 1 + ts/groups.ts | 8 - ts/jobs/helpers/areAllErrorsUnregistered.ts | 2 +- ts/jobs/helpers/sendDeleteForEveryone.ts | 144 ++++++++++++++++-- ts/jobs/helpers/sendNormalMessage.ts | 7 +- ts/jobs/helpers/sendReaction.ts | 7 +- ts/model-types.d.ts | 4 + ts/models/conversations.ts | 60 +++++++- ts/models/messages.ts | 7 +- ts/state/selectors/message.ts | 53 ++++++- ts/state/smart/MessageDetail.tsx | 2 + ts/state/smart/Timeline.tsx | 1 + ts/util/deleteForEveryone.ts | 5 +- ts/util/retryDeleteForEveryone.ts | 55 +++++++ ts/views/conversation_view.ts | 3 + 24 files changed, 426 insertions(+), 60 deletions(-) create mode 100644 ts/util/retryDeleteForEveryone.ts diff --git a/_locales/en/messages.json b/_locales/en/messages.json index 97a2ce712..32da4f196 100644 --- a/_locales/en/messages.json +++ b/_locales/en/messages.json @@ -1172,6 +1172,10 @@ "message": "Retry Send", "description": "Shown on the drop-down menu for an individual message, but only if it is an outgoing message that failed to send" }, + "retryDeleteForEveryone": { + "message": "Retry Delete for Everyone", + "description": "Shown on the drop-down menu for an individual message, but only if a previous delete for everyone failed to send" + }, "forwardMessage": { "message": "Forward message", "description": "Shown on the drop-down menu for an individual message, forwards a message" @@ -1983,6 +1987,10 @@ "message": "Send failed", "description": "Shown on outgoing message if it fails to send" }, + "deleteFailed": { + "message": "Delete failed", + "description": "Shown on a message which was deleted for everyone if the delete wasn't successfully sent to anyone" + }, "sendPaused": { "message": "Send paused", "description": "Shown on outgoing message if it cannot be sent immediately" @@ -1991,6 +1999,10 @@ "message": "Partially sent, click for details", "description": "Shown on outgoing message if it is partially sent" }, + "partiallyDeleted": { + "message": "Partially deleted, click to retry", + "description": "Shown on a message which was deleted for everyone if the delete wasn't successfully sent to everyone" + }, "showMore": { "message": "Details", "description": "Displays the details of a key change" diff --git a/ts/components/conversation/Message.stories.tsx b/ts/components/conversation/Message.stories.tsx index 6d91e35c0..6899950de 100644 --- a/ts/components/conversation/Message.stories.tsx +++ b/ts/components/conversation/Message.stories.tsx @@ -104,6 +104,8 @@ const createProps = (overrideProps: Partial = {}): Props => ({ canReply: true, canDownload: true, canDeleteForEveryone: overrideProps.canDeleteForEveryone || false, + canRetry: overrideProps.canRetry || false, + canRetryDeleteForEveryone: overrideProps.canRetryDeleteForEveryone || false, checkForAccount: action('checkForAccount'), clearSelectedMessage: action('clearSelectedMessage'), collapseMetadata: overrideProps.collapseMetadata, @@ -165,6 +167,7 @@ const createProps = (overrideProps: Partial = {}): Props => ({ renderAudioAttachment, replyToMessage: action('replyToMessage'), retrySend: action('retrySend'), + retryDeleteForEveryone: action('retryDeleteForEveryone'), scrollToQuotedMessage: action('scrollToQuotedMessage'), selectMessage: action('selectMessage'), showContactDetail: action('showContactDetail'), @@ -574,13 +577,23 @@ story.add('Sticker', () => { }); story.add('Deleted', () => { - const props = createProps({ + const propsSent = createProps({ conversationType: 'group', deletedForEveryone: true, status: 'sent', }); + const propsSending = createProps({ + conversationType: 'group', + deletedForEveryone: true, + status: 'sending', + }); - return renderBothDirections(props); + return ( + <> + {renderBothDirections(propsSent)} + {renderBothDirections(propsSending)} + + ); }); story.add('Deleted with expireTimer', () => { @@ -596,6 +609,30 @@ story.add('Deleted with expireTimer', () => { return renderBothDirections(props); }); +story.add('Deleted with error', () => { + const propsPartialError = createProps({ + timestamp: Date.now() - 60 * 1000, + canDeleteForEveryone: true, + conversationType: 'group', + deletedForEveryone: true, + status: 'partial-sent', + }); + const propsError = createProps({ + timestamp: Date.now() - 60 * 1000, + canDeleteForEveryone: true, + conversationType: 'group', + deletedForEveryone: true, + status: 'error', + }); + + return ( + <> + {renderBothDirections(propsPartialError)} + {renderBothDirections(propsError)} + + ); +}); + story.add('Can delete for everyone', () => { const props = createProps({ status: 'read', @@ -609,6 +646,7 @@ story.add('Can delete for everyone', () => { story.add('Error', () => { const props = createProps({ status: 'error', + canRetry: true, text: 'I hope you get this.', }); @@ -1298,6 +1336,8 @@ story.add('All the context menus', () => { ], status: 'partial-sent', canDeleteForEveryone: true, + canRetry: true, + canRetryDeleteForEveryone: true, }); return ; diff --git a/ts/components/conversation/Message.tsx b/ts/components/conversation/Message.tsx index 490175165..5c4a1bdf1 100644 --- a/ts/components/conversation/Message.tsx +++ b/ts/components/conversation/Message.tsx @@ -197,6 +197,8 @@ export type PropsData = { deletedForEveryone?: boolean; + canRetry: boolean; + canRetryDeleteForEveryone: boolean; canReact: boolean; canReply: boolean; canDownload: boolean; @@ -234,6 +236,7 @@ export type PropsActions = { { emoji, remove }: { emoji: string; remove: boolean } ) => void; replyToMessage: (id: string) => void; + retryDeleteForEveryone: (id: string) => void; retrySend: (id: string) => void; showForwardMessageModal: (id: string) => void; deleteMessage: (id: string) => void; @@ -424,7 +427,7 @@ export class Message extends React.PureComponent { public override componentDidMount(): void { const { conversationId } = this.props; - window.ConversationController.onConvoMessageMount(conversationId); + window.ConversationController?.onConvoMessageMount(conversationId); this.startSelectedTimer(); this.startDeleteForEveryoneTimerIfApplicable(); @@ -1486,29 +1489,24 @@ export class Message extends React.PureComponent { canDownload, canReact, canReply, + canRetry, + canRetryDeleteForEveryone, deleteMessage, deleteMessageForEveryone, deletedForEveryone, - direction, i18n, id, isSticker, isTapToView, replyToMessage, retrySend, + retryDeleteForEveryone, showForwardMessageModal, showMessageDetail, - status, text, } = this.props; const canForward = !isTapToView && !deletedForEveryone; - - const showRetry = - (status === 'paused' || - status === 'error' || - status === 'partial-sent') && - direction === 'outgoing'; const multipleAttachments = attachments && attachments.length > 1; const shouldShowAdditional = @@ -1583,7 +1581,7 @@ export class Message extends React.PureComponent { > {i18n('moreInfo')} - {showRetry ? ( + {canRetry ? ( { {i18n('retrySend')} ) : null} + {canRetryDeleteForEveryone ? ( + { + event.stopPropagation(); + event.preventDefault(); + + retryDeleteForEveryone(id); + }} + > + {i18n('retryDeleteForEveryone')} + + ) : null} {canForward ? ( = {}): Props => ({ renderReactionPicker: () =>
, replyToMessage: action('replyToMessage'), retrySend: action('retrySend'), + retryDeleteForEveryone: action('retryDeleteForEveryone'), showContactDetail: action('showContactDetail'), showContactModal: action('showContactModal'), showExpiredIncomingTapToViewToast: action( diff --git a/ts/components/conversation/MessageDetail.tsx b/ts/components/conversation/MessageDetail.tsx index c17c5505c..622261112 100644 --- a/ts/components/conversation/MessageDetail.tsx +++ b/ts/components/conversation/MessageDetail.tsx @@ -81,6 +81,7 @@ export type PropsBackboneActions = Pick< | 'renderEmojiPicker' | 'renderReactionPicker' | 'replyToMessage' + | 'retryDeleteForEveryone' | 'retrySend' | 'showContactDetail' | 'showContactModal' @@ -303,6 +304,7 @@ export class MessageDetail extends React.Component { renderEmojiPicker, renderReactionPicker, replyToMessage, + retryDeleteForEveryone, retrySend, showContactDetail, showContactModal, @@ -358,6 +360,7 @@ export class MessageDetail extends React.Component { renderEmojiPicker={renderEmojiPicker} renderReactionPicker={renderReactionPicker} replyToMessage={replyToMessage} + retryDeleteForEveryone={retryDeleteForEveryone} retrySend={retrySend} showForwardMessageModal={showForwardMessageModal} scrollToQuotedMessage={() => { diff --git a/ts/components/conversation/MessageMetadata.tsx b/ts/components/conversation/MessageMetadata.tsx index c1450add7..8c7ed4821 100644 --- a/ts/components/conversation/MessageMetadata.tsx +++ b/ts/components/conversation/MessageMetadata.tsx @@ -61,7 +61,9 @@ export const MessageMetadata: FunctionComponent = props => { if (isError || isPartiallySent || isPaused) { let statusInfo: React.ReactChild; if (isError) { - statusInfo = i18n('sendFailed'); + statusInfo = deletedForEveryone + ? i18n('deleteFailed') + : i18n('sendFailed'); } else if (isPaused) { statusInfo = i18n('sendPaused'); } else { @@ -76,7 +78,9 @@ export const MessageMetadata: FunctionComponent = props => { showMessageDetail(id); }} > - {i18n('partiallySent')} + {deletedForEveryone + ? i18n('partiallyDeleted') + : i18n('partiallySent')} ); } @@ -136,7 +140,7 @@ export const MessageMetadata: FunctionComponent = props => {
) : null} - {!deletedForEveryone && + {(!deletedForEveryone || status === 'sending') && !textPending && direction === 'outgoing' && status !== 'error' && diff --git a/ts/components/conversation/Quote.stories.tsx b/ts/components/conversation/Quote.stories.tsx index c1ff1ef7e..8c4dd2187 100644 --- a/ts/components/conversation/Quote.stories.tsx +++ b/ts/components/conversation/Quote.stories.tsx @@ -39,6 +39,8 @@ const defaultMessageProps: MessagesProps = { }), canReact: true, canReply: true, + canRetry: true, + canRetryDeleteForEveryone: true, canDeleteForEveryone: true, canDownload: true, checkForAccount: action('checkForAccount'), @@ -78,6 +80,7 @@ const defaultMessageProps: MessagesProps = { renderAudioAttachment: () =>
*AudioAttachment*
, replyToMessage: action('default--replyToMessage'), retrySend: action('default--retrySend'), + retryDeleteForEveryone: action('default--retryDeleteForEveryone'), scrollToQuotedMessage: action('default--scrollToQuotedMessage'), selectMessage: action('default--selectMessage'), showContactDetail: action('default--showContactDetail'), diff --git a/ts/components/conversation/Timeline.stories.tsx b/ts/components/conversation/Timeline.stories.tsx index f47b0e219..327e50da0 100644 --- a/ts/components/conversation/Timeline.stories.tsx +++ b/ts/components/conversation/Timeline.stories.tsx @@ -50,6 +50,8 @@ const items: Record = { canDownload: true, canReact: true, canReply: true, + canRetry: true, + canRetryDeleteForEveryone: true, conversationColor: 'forest', conversationId: 'conversation-id', conversationType: 'group', @@ -72,6 +74,8 @@ const items: Record = { canDownload: true, canReact: true, canReply: true, + canRetry: true, + canRetryDeleteForEveryone: true, conversationColor: 'forest', conversationId: 'conversation-id', conversationType: 'group', @@ -108,6 +112,8 @@ const items: Record = { canDownload: true, canReact: true, canReply: true, + canRetry: true, + canRetryDeleteForEveryone: true, conversationColor: 'crimson', conversationId: 'conversation-id', conversationType: 'group', @@ -205,6 +211,8 @@ const items: Record = { canDownload: true, canReact: true, canReply: true, + canRetry: true, + canRetryDeleteForEveryone: true, conversationColor: 'plum', conversationId: 'conversation-id', conversationType: 'group', @@ -228,6 +236,8 @@ const items: Record = { canDownload: true, canReact: true, canReply: true, + canRetry: true, + canRetryDeleteForEveryone: true, conversationColor: 'crimson', conversationId: 'conversation-id', conversationType: 'group', @@ -251,6 +261,8 @@ const items: Record = { canDownload: true, canReact: true, canReply: true, + canRetry: true, + canRetryDeleteForEveryone: true, conversationColor: 'crimson', conversationId: 'conversation-id', conversationType: 'group', @@ -274,6 +286,8 @@ const items: Record = { canDownload: true, canReact: true, canReply: true, + canRetry: true, + canRetryDeleteForEveryone: true, conversationColor: 'crimson', conversationId: 'conversation-id', conversationType: 'group', @@ -297,6 +311,8 @@ const items: Record = { canDownload: true, canReact: true, canReply: true, + canRetry: true, + canRetryDeleteForEveryone: true, conversationColor: 'crimson', conversationId: 'conversation-id', conversationType: 'group', @@ -340,6 +356,7 @@ const actions = () => ({ reactToMessage: action('reactToMessage'), replyToMessage: action('replyToMessage'), + retryDeleteForEveryone: action('retryDeleteForEveryone'), retrySend: action('retrySend'), deleteMessage: action('deleteMessage'), deleteMessageForEveryone: action('deleteMessageForEveryone'), diff --git a/ts/components/conversation/Timeline.tsx b/ts/components/conversation/Timeline.tsx index d113de479..c070f9147 100644 --- a/ts/components/conversation/Timeline.tsx +++ b/ts/components/conversation/Timeline.tsx @@ -217,6 +217,7 @@ const getActions = createSelector( 'checkForAccount', 'reactToMessage', 'replyToMessage', + 'retryDeleteForEveryone', 'retrySend', 'showForwardMessageModal', 'deleteMessage', diff --git a/ts/components/conversation/TimelineItem.stories.tsx b/ts/components/conversation/TimelineItem.stories.tsx index a2e5a3ccc..a02acd332 100644 --- a/ts/components/conversation/TimelineItem.stories.tsx +++ b/ts/components/conversation/TimelineItem.stories.tsx @@ -63,6 +63,7 @@ const getDefaultProps = () => ({ clearSelectedMessage: action('clearSelectedMessage'), contactSupport: action('contactSupport'), replyToMessage: action('replyToMessage'), + retryDeleteForEveryone: action('retryDeleteForEveryone'), retrySend: action('retrySend'), deleteMessage: action('deleteMessage'), deleteMessageForEveryone: action('deleteMessageForEveryone'), diff --git a/ts/groups.ts b/ts/groups.ts index dcb48ce81..02fa38d00 100644 --- a/ts/groups.ts +++ b/ts/groups.ts @@ -657,14 +657,6 @@ export async function buildAddMembersChange( const profileKey = contact.get('profileKey'); const profileKeyCredential = contact.get('profileKeyCredential'); - if (!profileKey) { - assert( - false, - `buildAddMembersChange/${logId}: member is missing profile key; skipping` - ); - return; - } - const member = new Proto.Member(); member.userId = encryptUuid(clientZkGroupCipher, uuid); member.role = MEMBER_ROLE_ENUM.DEFAULT; diff --git a/ts/jobs/helpers/areAllErrorsUnregistered.ts b/ts/jobs/helpers/areAllErrorsUnregistered.ts index 32719b9e4..1dc3fce8e 100644 --- a/ts/jobs/helpers/areAllErrorsUnregistered.ts +++ b/ts/jobs/helpers/areAllErrorsUnregistered.ts @@ -11,7 +11,7 @@ import { isGroup } from '../../util/whatTypeOfConversation'; export function areAllErrorsUnregistered( conversation: ConversationAttributesType, error: unknown -): boolean { +): error is SendMessageProtoError { return Boolean( isGroup(conversation) && error instanceof SendMessageProtoError && diff --git a/ts/jobs/helpers/sendDeleteForEveryone.ts b/ts/jobs/helpers/sendDeleteForEveryone.ts index a1fb1dd7a..b3ca6c360 100644 --- a/ts/jobs/helpers/sendDeleteForEveryone.ts +++ b/ts/jobs/helpers/sendDeleteForEveryone.ts @@ -3,6 +3,7 @@ import { isNumber } from 'lodash'; +import * as Errors from '../../types/errors'; import { getSendOptions } from '../../util/getSendOptions'; import { isDirectConversation, @@ -26,10 +27,14 @@ import { getUntrustedConversationIds } from './getUntrustedConversationIds'; import { handleMessageSend } from '../../util/handleMessageSend'; import { isConversationAccepted } from '../../util/isConversationAccepted'; import { isConversationUnregistered } from '../../util/isConversationUnregistered'; +import { getMessageById } from '../../messages/getMessageById'; +import { isNotNil } from '../../util/isNotNil'; +import type { CallbackResultType } from '../../textsecure/Types.d'; +import type { MessageModel } from '../../models/messages'; +import { SendMessageProtoError } from '../../textsecure/Errors'; +import { strictAssert } from '../../util/assert'; +import type { LoggerType } from '../../types/Logging'; -// Note: because we don't have a recipient map, if some sends fail, we will resend this -// message to folks that got it on the first go-round. This is okay, because a delete -// for everyone has no effect when applied the second time on a message. export async function sendDeleteForEveryone( conversation: ConversationModel, { @@ -41,12 +46,25 @@ export async function sendDeleteForEveryone( }: ConversationQueueJobBundle, data: DeleteForEveryoneJobData ): Promise { - if (!shouldContinue) { - log.info('Ran out of time. Giving up on sending delete for everyone'); + const { + messageId, + recipients: recipientsFromJob, + revision, + targetTimestamp, + } = data; + + const message = await getMessageById(messageId); + if (!message) { + log.error(`Failed to fetch message ${messageId}. Failing job.`); + return; + } + + if (!shouldContinue) { + log.info('Ran out of time. Giving up on sending delete for everyone'); + updateMessageWithFailure(message, [new Error('Ran out of time!')], log); return; } - const { messageId, recipients, revision, targetTimestamp } = data; const sendType = 'deleteForEveryone'; const { ContentHint } = Proto.UnidentifiedSenderMessage.Message; const contentHint = ContentHint.RESENDABLE; @@ -54,6 +72,13 @@ export async function sendDeleteForEveryone( const logId = `deleteForEveryone/${conversation.idForLogging()}`; + const deletedForEveryoneSendStatus = message.get( + 'deletedForEveryoneSendStatus' + ); + const recipients = deletedForEveryoneSendStatus + ? getRecipients(deletedForEveryoneSendStatus) + : recipientsFromJob; + const untrustedConversationIds = getUntrustedConversationIds(recipients); if (untrustedConversationIds.length) { window.reduxActions.conversations.conversationStoppedByMissingVerification({ @@ -89,13 +114,10 @@ export async function sendDeleteForEveryone( recipients: conversation.getRecipients(), timestamp, }); - - if (!proto.dataMessage) { - log.error( - "ContentMessage proto didn't have a data message; cancelling job." - ); - return; - } + strictAssert( + proto.dataMessage, + 'ContentMessage must have dataMessage' + ); await handleMessageSend( window.textsecure.messaging.sendSyncMessage({ @@ -110,23 +132,39 @@ export async function sendDeleteForEveryone( }), { messageIds, sendType } ); + await updateMessageWithSuccessfulSends(message); } else if (isDirectConversation(conversation.attributes)) { if (!isConversationAccepted(conversation.attributes)) { log.info( `conversation ${conversation.idForLogging()} is not accepted; refusing to send` ); + updateMessageWithFailure( + message, + [new Error('Message request was not accepted')], + log + ); return; } if (isConversationUnregistered(conversation.attributes)) { log.info( `conversation ${conversation.idForLogging()} is unregistered; refusing to send` ); + updateMessageWithFailure( + message, + [new Error('Contact no longer has a Signal account')], + log + ); return; } if (conversation.isBlocked()) { log.info( `conversation ${conversation.idForLogging()} is blocked; refusing to send` ); + updateMessageWithFailure( + message, + [new Error('Contact is blocked')], + log + ); return; } @@ -151,6 +189,8 @@ export async function sendDeleteForEveryone( sendType, timestamp, }); + + await updateMessageWithSuccessfulSends(message); } else { if (isGroupV2(conversation.attributes) && !isNumber(revision)) { log.error('No revision provided, but conversation is GroupV2'); @@ -185,12 +225,20 @@ export async function sendDeleteForEveryone( sendType, timestamp, }); + + await updateMessageWithSuccessfulSends(message); } } catch (error: unknown) { + if (error instanceof SendMessageProtoError) { + await updateMessageWithSuccessfulSends(message, error); + } + + const errors = maybeExpandErrors(error); await handleMultipleSendErrors({ - errors: maybeExpandErrors(error), + errors, isFinalAttempt, log, + markFailed: () => updateMessageWithFailure(message, errors, log), timeRemaining, toThrow: error, }); @@ -198,3 +246,71 @@ export async function sendDeleteForEveryone( } ); } + +function getRecipients( + sendStatusByConversationId: Record +): Array { + return Object.entries(sendStatusByConversationId) + .filter(([_, isSent]) => !isSent) + .map(([conversationId]) => { + const recipient = window.ConversationController.get(conversationId); + if (!recipient) { + return null; + } + return recipient.get('uuid'); + }) + .filter(isNotNil); +} + +async function updateMessageWithSuccessfulSends( + message: MessageModel, + result?: CallbackResultType | SendMessageProtoError +): Promise { + if (!result) { + message.set({ + deletedForEveryoneSendStatus: {}, + deletedForEveryoneFailed: undefined, + }); + await window.Signal.Data.saveMessage(message.attributes, { + ourUuid: window.textsecure.storage.user.getCheckedUuid().toString(), + }); + + return; + } + + const deletedForEveryoneSendStatus = { + ...message.get('deletedForEveryoneSendStatus'), + }; + + result.successfulIdentifiers?.forEach(identifier => { + const conversation = window.ConversationController.get(identifier); + if (!conversation) { + return; + } + deletedForEveryoneSendStatus[conversation.id] = true; + }); + + message.set({ + deletedForEveryoneSendStatus, + deletedForEveryoneFailed: undefined, + }); + await window.Signal.Data.saveMessage(message.attributes, { + ourUuid: window.textsecure.storage.user.getCheckedUuid().toString(), + }); +} + +async function updateMessageWithFailure( + message: MessageModel, + errors: ReadonlyArray, + log: LoggerType +): Promise { + log.error( + 'updateMessageWithFailure: Setting this set of errors', + errors.map(Errors.toLogFormat) + ); + + message.set({ deletedForEveryoneFailed: true }); + await window.Signal.Data.saveMessage(message.attributes, { + ourUuid: window.textsecure.storage.user.getCheckedUuid().toString(), + }); +} diff --git a/ts/jobs/helpers/sendNormalMessage.ts b/ts/jobs/helpers/sendNormalMessage.ts index ea2920143..f336a1b47 100644 --- a/ts/jobs/helpers/sendNormalMessage.ts +++ b/ts/jobs/helpers/sendNormalMessage.ts @@ -333,8 +333,7 @@ function getMessageRecipients({ const recipientIdentifiersWithoutMe: Array = []; const untrustedConversationIds: Array = []; - const currentConversationRecipients = - conversation.getRecipientConversationIds(); + const currentConversationRecipients = conversation.getMemberConversationIds(); Object.entries(message.get('sendStateByConversationId') || {}).forEach( ([recipientConversationId, sendState]) => { @@ -360,6 +359,10 @@ function getMessageRecipients({ if (recipient.isUntrusted()) { untrustedConversationIds.push(recipientConversationId); + return; + } + if (recipient.isUnregistered()) { + return; } const recipientIdentifier = recipient.getSendTarget(); diff --git a/ts/jobs/helpers/sendReaction.ts b/ts/jobs/helpers/sendReaction.ts index 8e9e40bd5..3be12c174 100644 --- a/ts/jobs/helpers/sendReaction.ts +++ b/ts/jobs/helpers/sendReaction.ts @@ -352,8 +352,7 @@ function getRecipients( const recipientIdentifiersWithoutMe: Array = []; const untrustedConversationIds: Array = []; - const currentConversationRecipients = - conversation.getRecipientConversationIds(); + const currentConversationRecipients = conversation.getMemberConversationIds(); for (const id of reactionUtil.getUnsentConversationIds(reaction)) { const recipient = window.ConversationController.get(id); @@ -375,6 +374,10 @@ function getRecipients( untrustedConversationIds.push(recipientIdentifier); continue; } + if (recipient.isUnregistered()) { + untrustedConversationIds.push(recipientIdentifier); + continue; + } allRecipientIdentifiers.push(recipientIdentifier); if (!isRecipientMe) { diff --git a/ts/model-types.d.ts b/ts/model-types.d.ts index 68763ac85..4f63d40dc 100644 --- a/ts/model-types.d.ts +++ b/ts/model-types.d.ts @@ -213,6 +213,10 @@ export type MessageAttributesType = { // Should only be present for outgoing messages sendStateByConversationId?: SendStateByConversationId; + + // Should only be present for messages deleted for everyone + deletedForEveryoneSendStatus?: Record; + deletedForEveryoneFailed?: boolean; }; export type ConversationAttributesTypeType = 'private' | 'group'; diff --git a/ts/models/conversations.ts b/ts/models/conversations.ts index ff908932b..9d88c6c28 100644 --- a/ts/models/conversations.ts +++ b/ts/models/conversations.ts @@ -98,8 +98,9 @@ import { conversationJobQueue, conversationQueueJobEnum, } from '../jobs/conversationJobQueue'; +import type { ConversationQueueJobData } from '../jobs/conversationJobQueue'; import { readReceiptsJobQueue } from '../jobs/readReceiptsJobQueue'; -import { Deletes } from '../messageModifiers/Deletes'; +import { DeleteModel } from '../messageModifiers/Deletes'; import type { ReactionModel } from '../messageModifiers/Reactions'; import { isAnnouncementGroupReady } from '../util/isAnnouncementGroupReady'; import { getProfile } from '../util/getProfile'; @@ -1760,7 +1761,7 @@ export class ConversationModel extends window.Backbone sortConversationTitles(left, right, this.intlCollator) ) .map(member => member.format()) - .filter((member): member is ConversationType => member !== null) + .filter(isNotNil) : undefined; const { customColor, customColorId } = this.getCustomColorData(); @@ -3517,10 +3518,29 @@ export class ConversationModel extends window.Backbone ); } - getRecipientConversationIds(): Set { + // Members is all people in the group + getMemberConversationIds(): Set { return new Set(map(this.getMembers(), conversation => conversation.id)); } + // Recipients includes only the people we'll actually send to for this conversation + getRecipientConversationIds(): Set { + const recipients = this.getRecipients(); + const conversationIds = recipients.map(identifier => { + const conversation = window.ConversationController.getOrCreate( + identifier, + 'private' + ); + strictAssert( + conversation, + 'getRecipientConversationIds should have created conversation!' + ); + return conversation.id; + }); + + return new Set(conversationIds); + } + async getQuoteAttachment( attachments?: Array, preview?: Array, @@ -3682,20 +3702,43 @@ export class ConversationModel extends window.Backbone timestamp: number; }): Promise { const { timestamp: targetTimestamp, id: messageId } = options; - const timestamp = Date.now(); + const message = await getMessageById(messageId); + if (!message) { + throw new Error('sendDeleteForEveryoneMessage: Cannot find message!'); + } + const messageModel = window.MessageController.register(messageId, message); + const timestamp = Date.now(); if (timestamp - targetTimestamp > THREE_HOURS) { throw new Error('Cannot send DOE for a message older than three hours'); } + messageModel.set({ + deletedForEveryoneSendStatus: zipObject( + this.getRecipientConversationIds(), + repeat(false) + ), + }); + try { - await conversationJobQueue.add({ + const jobData: ConversationQueueJobData = { type: conversationQueueJobEnum.enum.DeleteForEveryone, conversationId: this.id, messageId, recipients: this.getRecipients(), revision: this.get('revision'), targetTimestamp, + }; + await conversationJobQueue.add(jobData, async jobToInsert => { + log.info( + `sendDeleteForEveryoneMessage: saving message ${this.idForLogging()} and job ${ + jobToInsert.id + }` + ); + await window.Signal.Data.saveMessage(messageModel.attributes, { + jobToInsert, + ourUuid: window.textsecure.storage.user.getCheckedUuid().toString(), + }); }); } catch (error) { log.error( @@ -3705,11 +3748,12 @@ export class ConversationModel extends window.Backbone throw error; } - const deleteModel = Deletes.getSingleton().add({ + const deleteModel = new DeleteModel({ targetSentTimestamp: targetTimestamp, - fromId: window.ConversationController.getOurConversationId(), + serverTimestamp: Date.now(), + fromId: window.ConversationController.getOurConversationIdOrThrow(), }); - Deletes.getSingleton().onDelete(deleteModel); + await window.Signal.Util.deleteForEveryone(messageModel, deleteModel); } async sendProfileKeyUpdate(): Promise { diff --git a/ts/models/messages.ts b/ts/models/messages.ts index 44d490a5f..f6df26b85 100644 --- a/ts/models/messages.ts +++ b/ts/models/messages.ts @@ -1135,7 +1135,7 @@ export class MessageModel extends window.Backbone.Model { const conversation = this.getConversation()!; const currentConversationRecipients = - conversation.getRecipientConversationIds(); + conversation.getMemberConversationIds(); // Determine retry recipients and get their most up-to-date addressing information const oldSendStateByConversationId = @@ -3100,7 +3100,7 @@ export class MessageModel extends window.Backbone.Model { targetTimestamp: reaction.get('targetTimestamp'), timestamp: reaction.get('timestamp'), isSentByConversationId: zipObject( - conversation.getRecipientConversationIds(), + conversation.getMemberConversationIds(), repeat(false) ), }; @@ -3244,8 +3244,7 @@ export class MessageModel extends window.Backbone.Model { ); // Update the conversation's last message in case this was the last message - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - this.getConversation()!.updateLastMessage(); + this.getConversation()?.updateLastMessage(); } clearNotifications(reaction: Partial = {}): void { diff --git a/ts/state/selectors/message.ts b/ts/state/selectors/message.ts index 4a7099d35..25176f5d7 100644 --- a/ts/state/selectors/message.ts +++ b/ts/state/selectors/message.ts @@ -82,8 +82,9 @@ import { } from '../../messages/MessageSendState'; import * as log from '../../logging/log'; import { getConversationColorAttributes } from '../../util/getConversationColorAttributes'; +import { DAY, HOUR } from '../../util/durations'; -const THREE_HOURS = 3 * 60 * 60 * 1000; +const THREE_HOURS = 3 * HOUR; type FormattedContact = Partial & Pick< @@ -510,6 +511,8 @@ type ShallowPropsType = Pick< | 'canDownload' | 'canReact' | 'canReply' + | 'canRetry' + | 'canRetryDeleteForEveryone' | 'contact' | 'contactNameColor' | 'conversationColor' @@ -592,6 +595,8 @@ const getShallowPropsForMessage = createSelectorCreator(memoizeByRoot, isEqual)( canDownload: canDownload(message, conversationSelector), canReact: canReact(message, ourConversationId, conversationSelector), canReply: canReply(message, ourConversationId, conversationSelector), + canRetry: hasErrors(message), + canRetryDeleteForEveryone: canRetryDeleteForEveryone(message), contact: getPropsForEmbeddedContact(message, regionCode, accountSelector), contactNameColor, conversationColor, @@ -1284,7 +1289,12 @@ function createNonBreakingLastSeparator(text?: string): string { export function getMessagePropStatus( message: Pick< MessageWithUIFieldsType, - 'type' | 'errors' | 'sendStateByConversationId' + | 'deletedForEveryone' + | 'deletedForEveryoneFailed' + | 'deletedForEveryoneSendStatus' + | 'errors' + | 'sendStateByConversationId' + | 'type' >, ourConversationId: string | undefined ): LastMessageStatus | undefined { @@ -1296,7 +1306,30 @@ export function getMessagePropStatus( return 'paused'; } - const { sendStateByConversationId = {} } = message; + const { + deletedForEveryone, + deletedForEveryoneFailed, + deletedForEveryoneSendStatus, + sendStateByConversationId = {}, + } = message; + + // Note: we only do anything here if deletedForEveryoneSendStatus exists, because old + // messages deleted for everyone won't have send status. + if (deletedForEveryone && deletedForEveryoneSendStatus) { + if (deletedForEveryoneFailed) { + const anySuccessfulSends = Object.values( + deletedForEveryoneSendStatus + ).some(item => item); + + return anySuccessfulSends ? 'partial-sent' : 'error'; + } + const missingSends = Object.values(deletedForEveryoneSendStatus).some( + item => !item + ); + if (missingSends) { + return 'sending'; + } + } if ( ourConversationId && @@ -1531,6 +1564,20 @@ export function canDeleteForEveryone( ); } +export function canRetryDeleteForEveryone( + message: Pick< + MessageWithUIFieldsType, + 'deletedForEveryone' | 'deletedForEveryoneFailed' | 'sent_at' + > +): boolean { + return Boolean( + message.deletedForEveryone && + message.deletedForEveryoneFailed && + // Is it too old to delete? + isMoreRecentThan(message.sent_at, DAY) + ); +} + export function canDownload( message: MessageWithUIFieldsType, conversationSelector: GetConversationByIdType diff --git a/ts/state/smart/MessageDetail.tsx b/ts/state/smart/MessageDetail.tsx index f98977fba..7a20bb91f 100644 --- a/ts/state/smart/MessageDetail.tsx +++ b/ts/state/smart/MessageDetail.tsx @@ -48,6 +48,7 @@ const mapStateToProps = ( openLink, reactToMessage, replyToMessage, + retryDeleteForEveryone, retrySend, showContactDetail, showContactModal, @@ -93,6 +94,7 @@ const mapStateToProps = ( renderEmojiPicker, renderReactionPicker, replyToMessage, + retryDeleteForEveryone, retrySend, showContactDetail, showContactModal, diff --git a/ts/state/smart/Timeline.tsx b/ts/state/smart/Timeline.tsx index f35fee3ae..8033f8ec4 100644 --- a/ts/state/smart/Timeline.tsx +++ b/ts/state/smart/Timeline.tsx @@ -84,6 +84,7 @@ export type TimelinePropsType = ExternalProps & | 'reactToMessage' | 'removeMember' | 'replyToMessage' + | 'retryDeleteForEveryone' | 'retrySend' | 'scrollToQuotedMessage' | 'showContactDetail' diff --git a/ts/util/deleteForEveryone.ts b/ts/util/deleteForEveryone.ts index 372d6d0cb..67782f4ed 100644 --- a/ts/util/deleteForEveryone.ts +++ b/ts/util/deleteForEveryone.ts @@ -4,8 +4,7 @@ import type { DeleteModel } from '../messageModifiers/Deletes'; import type { MessageModel } from '../models/messages'; import * as log from '../logging/log'; - -const ONE_DAY = 24 * 60 * 60 * 1000; +import { DAY } from './durations'; export async function deleteForEveryone( message: MessageModel, @@ -19,7 +18,7 @@ export async function deleteForEveryone( // are less than one day apart const delta = Math.abs(doe.get('serverTimestamp') - messageTimestamp); - if (delta > ONE_DAY) { + if (delta > DAY) { log.info('Received late DOE. Dropping.', { fromId: doe.get('fromId'), targetSentTimestamp: doe.get('targetSentTimestamp'), diff --git a/ts/util/retryDeleteForEveryone.ts b/ts/util/retryDeleteForEveryone.ts new file mode 100644 index 000000000..196a8419d --- /dev/null +++ b/ts/util/retryDeleteForEveryone.ts @@ -0,0 +1,55 @@ +// Copyright 2022 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +import * as log from '../logging/log'; +import * as Errors from '../types/errors'; + +import type { ConversationQueueJobData } from '../jobs/conversationJobQueue'; +import { + conversationJobQueue, + conversationQueueJobEnum, +} from '../jobs/conversationJobQueue'; +import { isOlderThan } from './timestamp'; +import { DAY } from './durations'; + +export async function retryDeleteForEveryone(messageId: string): Promise { + const message = window.MessageController.getById(messageId); + if (!message) { + throw new Error(`retryDeleteForEveryone: Message ${messageId} missing!`); + } + + if (isOlderThan(message.get('sent_at'), DAY)) { + throw new Error( + 'retryDeleteForEveryone: Message too old to retry delete for everyone!' + ); + } + + try { + const conversation = message.getConversation(); + if (!conversation) { + throw new Error( + `retryDeleteForEveryone: Conversation for ${messageId} missing!` + ); + } + + const jobData: ConversationQueueJobData = { + type: conversationQueueJobEnum.enum.DeleteForEveryone, + conversationId: conversation.id, + messageId, + recipients: conversation.getRecipients(), + revision: conversation.get('revision'), + targetTimestamp: message.get('sent_at'), + }; + + log.info( + `retryDeleteForEveryone: Adding job for message ${message.idForLogging()}!` + ); + await conversationJobQueue.add(jobData); + } catch (error) { + log.error( + 'retryDeleteForEveryone: Failed to queue delete for everyone', + Errors.toLogFormat(error) + ); + throw error; + } +} diff --git a/ts/views/conversation_view.ts b/ts/views/conversation_view.ts index f51b0b12d..675dde77e 100644 --- a/ts/views/conversation_view.ts +++ b/ts/views/conversation_view.ts @@ -114,6 +114,7 @@ import { viewSyncJobQueue } from '../jobs/viewSyncJobQueue'; import { viewedReceiptsJobQueue } from '../jobs/viewedReceiptsJobQueue'; import { RecordingState } from '../state/ducks/audioRecorder'; import { UUIDKind } from '../types/UUID'; +import { retryDeleteForEveryone } from '../util/retryDeleteForEveryone'; type AttachmentOptions = { messageId: string; @@ -167,6 +168,7 @@ type MessageActionsType = { ) => unknown; replyToMessage: (messageId: string) => unknown; retrySend: (messageId: string) => unknown; + retryDeleteForEveryone: (messageId: string) => unknown; showContactDetail: (options: { contact: EmbeddedContactType; signalAccount?: string; @@ -874,6 +876,7 @@ export class ConversationView extends window.Backbone.View { reactToMessage, replyToMessage, retrySend, + retryDeleteForEveryone, showContactDetail, showContactModal, showSafetyNumber,