Delete for everyone: Track sends and show failure states
This commit is contained in:
parent
688cca1806
commit
0a52318be6
24 changed files with 426 additions and 60 deletions
|
@ -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"
|
||||
|
|
|
@ -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" />;
|
||||
|
|
|
@ -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={{
|
||||
|
|
|
@ -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(
|
||||
|
|
|
@ -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={() => {
|
||||
|
|
|
@ -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' &&
|
||||
|
|
|
@ -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'),
|
||||
|
|
|
@ -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'),
|
||||
|
|
|
@ -217,6 +217,7 @@ const getActions = createSelector(
|
|||
'checkForAccount',
|
||||
'reactToMessage',
|
||||
'replyToMessage',
|
||||
'retryDeleteForEveryone',
|
||||
'retrySend',
|
||||
'showForwardMessageModal',
|
||||
'deleteMessage',
|
||||
|
|
|
@ -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'),
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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 &&
|
||||
|
|
|
@ -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(),
|
||||
});
|
||||
}
|
||||
|
|
|
@ -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();
|
||||
|
|
|
@ -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
4
ts/model-types.d.ts
vendored
|
@ -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';
|
||||
|
|
|
@ -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> {
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -84,6 +84,7 @@ export type TimelinePropsType = ExternalProps &
|
|||
| 'reactToMessage'
|
||||
| 'removeMember'
|
||||
| 'replyToMessage'
|
||||
| 'retryDeleteForEveryone'
|
||||
| 'retrySend'
|
||||
| 'scrollToQuotedMessage'
|
||||
| 'showContactDetail'
|
||||
|
|
|
@ -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'),
|
||||
|
|
55
ts/util/retryDeleteForEveryone.ts
Normal file
55
ts/util/retryDeleteForEveryone.ts
Normal 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;
|
||||
}
|
||||
}
|
|
@ -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,
|
||||
|
|
Loading…
Reference in a new issue