Delete for everyone: Track sends and show failure states

This commit is contained in:
Scott Nonnenberg 2022-03-04 11:22:31 -08:00 committed by GitHub
parent 688cca1806
commit 0a52318be6
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
24 changed files with 426 additions and 60 deletions

View File

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

View File

@ -104,6 +104,8 @@ const createProps = (overrideProps: Partial<Props> = {}): 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> = {}): 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 <Message {...props} direction="outgoing" />;

View File

@ -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<Props, State> {
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<Props, State> {
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<Props, State> {
>
{i18n('moreInfo')}
</MenuItem>
{showRetry ? (
{canRetry ? (
<MenuItem
attributes={{
className:
@ -1599,6 +1597,22 @@ export class Message extends React.PureComponent<Props, State> {
{i18n('retrySend')}
</MenuItem>
) : null}
{canRetryDeleteForEveryone ? (
<MenuItem
attributes={{
className:
'module-message__context--icon module-message__context__delete-message-for-everyone',
}}
onClick={(event: React.MouseEvent) => {
event.stopPropagation();
event.preventDefault();
retryDeleteForEveryone(id);
}}
>
{i18n('retryDeleteForEveryone')}
</MenuItem>
) : null}
{canForward ? (
<MenuItem
attributes={{

View File

@ -29,6 +29,8 @@ const defaultMessage: MessageDataPropsType = {
}),
canReact: true,
canReply: true,
canRetry: true,
canRetryDeleteForEveryone: true,
canDeleteForEveryone: true,
canDownload: true,
conversationColor: 'crimson',
@ -84,6 +86,7 @@ const createProps = (overrideProps: Partial<Props> = {}): Props => ({
renderReactionPicker: () => <div />,
replyToMessage: action('replyToMessage'),
retrySend: action('retrySend'),
retryDeleteForEveryone: action('retryDeleteForEveryone'),
showContactDetail: action('showContactDetail'),
showContactModal: action('showContactModal'),
showExpiredIncomingTapToViewToast: action(

View File

@ -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<Props, State> {
renderEmojiPicker,
renderReactionPicker,
replyToMessage,
retryDeleteForEveryone,
retrySend,
showContactDetail,
showContactModal,
@ -358,6 +360,7 @@ export class MessageDetail extends React.Component<Props, State> {
renderEmojiPicker={renderEmojiPicker}
renderReactionPicker={renderReactionPicker}
replyToMessage={replyToMessage}
retryDeleteForEveryone={retryDeleteForEveryone}
retrySend={retrySend}
showForwardMessageModal={showForwardMessageModal}
scrollToQuotedMessage={() => {

View File

@ -61,7 +61,9 @@ export const MessageMetadata: FunctionComponent<PropsType> = 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<PropsType> = props => {
showMessageDetail(id);
}}
>
{i18n('partiallySent')}
{deletedForEveryone
? i18n('partiallyDeleted')
: i18n('partiallySent')}
</button>
);
}
@ -136,7 +140,7 @@ export const MessageMetadata: FunctionComponent<PropsType> = props => {
<Spinner svgSize="small" size="14px" direction={direction} />
</div>
) : null}
{!deletedForEveryone &&
{(!deletedForEveryone || status === 'sending') &&
!textPending &&
direction === 'outgoing' &&
status !== 'error' &&

View File

@ -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: () => <div>*AudioAttachment*</div>,
replyToMessage: action('default--replyToMessage'),
retrySend: action('default--retrySend'),
retryDeleteForEveryone: action('default--retryDeleteForEveryone'),
scrollToQuotedMessage: action('default--scrollToQuotedMessage'),
selectMessage: action('default--selectMessage'),
showContactDetail: action('default--showContactDetail'),

View File

@ -50,6 +50,8 @@ const items: Record<string, TimelineItemType> = {
canDownload: true,
canReact: true,
canReply: true,
canRetry: true,
canRetryDeleteForEveryone: true,
conversationColor: 'forest',
conversationId: 'conversation-id',
conversationType: 'group',
@ -72,6 +74,8 @@ const items: Record<string, TimelineItemType> = {
canDownload: true,
canReact: true,
canReply: true,
canRetry: true,
canRetryDeleteForEveryone: true,
conversationColor: 'forest',
conversationId: 'conversation-id',
conversationType: 'group',
@ -108,6 +112,8 @@ const items: Record<string, TimelineItemType> = {
canDownload: true,
canReact: true,
canReply: true,
canRetry: true,
canRetryDeleteForEveryone: true,
conversationColor: 'crimson',
conversationId: 'conversation-id',
conversationType: 'group',
@ -205,6 +211,8 @@ const items: Record<string, TimelineItemType> = {
canDownload: true,
canReact: true,
canReply: true,
canRetry: true,
canRetryDeleteForEveryone: true,
conversationColor: 'plum',
conversationId: 'conversation-id',
conversationType: 'group',
@ -228,6 +236,8 @@ const items: Record<string, TimelineItemType> = {
canDownload: true,
canReact: true,
canReply: true,
canRetry: true,
canRetryDeleteForEveryone: true,
conversationColor: 'crimson',
conversationId: 'conversation-id',
conversationType: 'group',
@ -251,6 +261,8 @@ const items: Record<string, TimelineItemType> = {
canDownload: true,
canReact: true,
canReply: true,
canRetry: true,
canRetryDeleteForEveryone: true,
conversationColor: 'crimson',
conversationId: 'conversation-id',
conversationType: 'group',
@ -274,6 +286,8 @@ const items: Record<string, TimelineItemType> = {
canDownload: true,
canReact: true,
canReply: true,
canRetry: true,
canRetryDeleteForEveryone: true,
conversationColor: 'crimson',
conversationId: 'conversation-id',
conversationType: 'group',
@ -297,6 +311,8 @@ const items: Record<string, TimelineItemType> = {
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'),

View File

@ -217,6 +217,7 @@ const getActions = createSelector(
'checkForAccount',
'reactToMessage',
'replyToMessage',
'retryDeleteForEveryone',
'retrySend',
'showForwardMessageModal',
'deleteMessage',

View File

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

View File

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

View File

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

View File

@ -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<void> {
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<string, boolean>
): Array<string> {
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<void> {
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<unknown>,
log: LoggerType
): Promise<void> {
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(),
});
}

View File

@ -333,8 +333,7 @@ function getMessageRecipients({
const recipientIdentifiersWithoutMe: Array<string> = [];
const untrustedConversationIds: Array<string> = [];
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();

View File

@ -352,8 +352,7 @@ function getRecipients(
const recipientIdentifiersWithoutMe: Array<string> = [];
const untrustedConversationIds: Array<string> = [];
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) {

4
ts/model-types.d.ts vendored
View File

@ -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<string, boolean>;
deletedForEveryoneFailed?: boolean;
};
export type ConversationAttributesTypeType = 'private' | 'group';

View File

@ -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<string> {
// Members is all people in the group
getMemberConversationIds(): Set<string> {
return new Set(map(this.getMembers(), conversation => conversation.id));
}
// Recipients includes only the people we'll actually send to for this conversation
getRecipientConversationIds(): Set<string> {
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<WhatIsThis>,
preview?: Array<WhatIsThis>,
@ -3682,20 +3702,43 @@ export class ConversationModel extends window.Backbone
timestamp: number;
}): Promise<void> {
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<void> {

View File

@ -1135,7 +1135,7 @@ export class MessageModel extends window.Backbone.Model<MessageAttributesType> {
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<MessageAttributesType> {
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<MessageAttributesType> {
);
// 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<ReactionType> = {}): void {

View File

@ -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<ConversationType> &
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

View File

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

View File

@ -84,6 +84,7 @@ export type TimelinePropsType = ExternalProps &
| 'reactToMessage'
| 'removeMember'
| 'replyToMessage'
| 'retryDeleteForEveryone'
| 'retrySend'
| 'scrollToQuotedMessage'
| 'showContactDetail'

View File

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

View File

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

View File

@ -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<ConversationModel> {
reactToMessage,
replyToMessage,
retrySend,
retryDeleteForEveryone,
showContactDetail,
showContactModal,
showSafetyNumber,