diff --git a/_locales/en/messages.json b/_locales/en/messages.json index 837c9ac75bf2..e65adc6ed034 100644 --- a/_locales/en/messages.json +++ b/_locales/en/messages.json @@ -943,7 +943,12 @@ "message": "Delete" }, "deleteWarning": { - "message": "Are you sure? Clicking 'delete' will permanently remove this message from this device only." + "message": "Clicking 'delete' will permanently remove this message from your devices only.", + "description": "Text shown in the confirmation dialog for deleting a message locally" + }, + "deleteForEveryoneWarning": { + "message": "This message will be permanently deleted for everyone in the conversation. Members will be able to see that you deleted a message.", + "description": "Text shown in the confirmation dialog for deleting a message for everyone" }, "deleteThisMessage": { "message": "Delete this message" @@ -1014,9 +1019,13 @@ "description": "Shown on the drop-down menu for an individual message, but only if it is an outgoing message that failed to send" }, "deleteMessage": { - "message": "Delete Message", + "message": "Delete message for me", "description": "Shown on the drop-down menu for an individual message, deletes single message" }, + "deleteMessageForEveryone": { + "message": "Delete message for everyone", + "description": "Shown on the drop-down menu for an individual message, deletes single message for everyone" + }, "deleteMessages": { "message": "Delete messages", "description": "Menu item for deleting messages, title case." diff --git a/js/deletes.js b/js/deletes.js index 36446c06b871..da2a1aa5ebb0 100644 --- a/js/deletes.js +++ b/js/deletes.js @@ -25,7 +25,7 @@ return []; }, - async onDelete(del) { + onDelete(del) { try { // The contact the delete message came from const fromContact = ConversationController.get(del.get('fromId')); diff --git a/ts/components/conversation/Message.stories.tsx b/ts/components/conversation/Message.stories.tsx index c8c931232d4f..077e6e8b9bad 100644 --- a/ts/components/conversation/Message.stories.tsx +++ b/ts/components/conversation/Message.stories.tsx @@ -45,12 +45,14 @@ const createProps = (overrideProps: Partial = {}): Props => ({ authorTitle: text('authorTitle', overrideProps.authorTitle || ''), bodyRanges: overrideProps.bodyRanges, canReply: true, + canDeleteForEveryone: overrideProps.canDeleteForEveryone || false, clearSelectedMessage: action('clearSelectedMessage'), collapseMetadata: overrideProps.collapseMetadata, conversationId: text('conversationId', overrideProps.conversationId || ''), conversationType: overrideProps.conversationType || 'direct', deletedForEveryone: overrideProps.deletedForEveryone, deleteMessage: action('deleteMessage'), + deleteMessageForEveryone: action('deleteMessageForEveryone'), disableMenu: overrideProps.disableMenu, disableScroll: overrideProps.disableScroll, direction: overrideProps.direction || 'incoming', @@ -324,6 +326,16 @@ story.add('Deleted', () => { return renderBothDirections(props); }); +story.add('Can delete for everyone', () => { + const props = createProps({ + status: 'read', + text: 'I hope you get this.', + canDeleteForEveryone: true, + }); + + return ; +}); + story.add('Error', () => { const props = createProps({ status: 'error', diff --git a/ts/components/conversation/Message.tsx b/ts/components/conversation/Message.tsx index 70b6b54a3b66..fdeed6b104ca 100644 --- a/ts/components/conversation/Message.tsx +++ b/ts/components/conversation/Message.tsx @@ -53,6 +53,7 @@ interface Trigger { const MINIMUM_LINK_PREVIEW_IMAGE_WIDTH = 200; const STICKER_SIZE = 200; const SELECTED_TIMEOUT = 1000; +const THREE_HOURS = 3 * 60 * 60 * 1000; interface LinkPreviewType { title: string; @@ -134,6 +135,7 @@ export type PropsData = { deletedForEveryone?: boolean; canReply: boolean; + canDeleteForEveryone: boolean; bodyRanges?: BodyRangesType; }; @@ -154,6 +156,7 @@ export type PropsActions = { replyToMessage: (id: string) => void; retrySend: (id: string) => void; deleteMessage: (id: string) => void; + deleteMessageForEveryone: (id: string) => void; showMessageDetail: (id: string) => void; openConversation: (conversationId: string, messageId?: string) => void; @@ -200,6 +203,7 @@ interface State { isWide: boolean; containerWidth: number; + canDeleteForEveryone: boolean; } const EXPIRATION_CHECK_MINIMUM = 2000; @@ -226,9 +230,13 @@ export class Message extends React.PureComponent { public selectedTimeout: NodeJS.Timeout | undefined; + public deleteForEveryoneTimeout: NodeJS.Timeout | undefined; + public constructor(props: Props) { super(props); + const { canDeleteForEveryone } = props; + this.wideMl = window.matchMedia('(min-width: 926px)'); this.wideMl.addEventListener('change', this.handleWideMlChange); @@ -246,6 +254,7 @@ export class Message extends React.PureComponent { isWide: this.wideMl.matches, containerWidth: 0, + canDeleteForEveryone, }; } @@ -322,6 +331,7 @@ export class Message extends React.PureComponent { public componentDidMount(): void { this.startSelectedTimer(); + this.startDeleteForEveryoneTimer(); const { isSelected } = this.props; if (isSelected) { @@ -353,6 +363,9 @@ export class Message extends React.PureComponent { if (this.expiredTimeout) { clearTimeout(this.expiredTimeout); } + if (this.deleteForEveryoneTimeout) { + clearTimeout(this.deleteForEveryoneTimeout); + } this.toggleReactionViewer(true); this.toggleReactionPicker(true); @@ -360,7 +373,7 @@ export class Message extends React.PureComponent { } public componentDidUpdate(prevProps: Props): void { - const { isSelected } = this.props; + const { canDeleteForEveryone, isSelected } = this.props; this.startSelectedTimer(); @@ -369,6 +382,10 @@ export class Message extends React.PureComponent { } this.checkExpired(); + + if (canDeleteForEveryone !== prevProps.canDeleteForEveryone) { + this.startDeleteForEveryoneTimer(); + } } public startSelectedTimer(): void { @@ -388,6 +405,29 @@ export class Message extends React.PureComponent { } } + public startDeleteForEveryoneTimer(): void { + if (this.deleteForEveryoneTimeout) { + clearTimeout(this.deleteForEveryoneTimeout); + } + + const { canDeleteForEveryone } = this.props; + + if (!canDeleteForEveryone) { + return; + } + + const { timestamp } = this.props; + const timeToDeletion = timestamp - Date.now() + THREE_HOURS; + + if (timeToDeletion <= 0) { + this.setState({ canDeleteForEveryone: false }); + } else { + this.deleteForEveryoneTimeout = setTimeout(() => { + this.setState({ canDeleteForEveryone: false }); + }, timeToDeletion); + } + } + public checkExpired(): void { const now = Date.now(); const { isExpired, expirationTimestamp, expirationLength } = this.props; @@ -1285,6 +1325,7 @@ export class Message extends React.PureComponent { attachments, canReply, deleteMessage, + deleteMessageForEveryone, direction, i18n, id, @@ -1296,6 +1337,8 @@ export class Message extends React.PureComponent { status, } = this.props; + const { canDeleteForEveryone } = this.state; + const showRetry = status === 'error' && direction === 'outgoing'; const multipleAttachments = attachments && attachments.length > 1; @@ -1386,6 +1429,21 @@ export class Message extends React.PureComponent { > {i18n('deleteMessage')} + {canDeleteForEveryone ? ( + { + event.stopPropagation(); + event.preventDefault(); + + deleteMessageForEveryone(id); + }} + > + {i18n('deleteMessageForEveryone')} + + ) : null} ); diff --git a/ts/components/conversation/MessageDetail.stories.tsx b/ts/components/conversation/MessageDetail.stories.tsx index 4d933aceccd8..4e98224cffb2 100644 --- a/ts/components/conversation/MessageDetail.stories.tsx +++ b/ts/components/conversation/MessageDetail.stories.tsx @@ -16,10 +16,12 @@ const story = storiesOf('Components/Conversation/MessageDetail', module); const defaultMessage: MessageProps = { authorTitle: 'Max', canReply: true, + canDeleteForEveryone: true, clearSelectedMessage: () => null, conversationId: 'my-convo', conversationType: 'direct', deleteMessage: action('deleteMessage'), + deleteMessageForEveryone: action('deleteMessageForEveryone'), direction: 'incoming', displayTapToViewMessage: () => null, downloadAttachment: () => null, diff --git a/ts/components/conversation/Quote.stories.tsx b/ts/components/conversation/Quote.stories.tsx index 25a2f7115f4a..fd8c919c22f4 100644 --- a/ts/components/conversation/Quote.stories.tsx +++ b/ts/components/conversation/Quote.stories.tsx @@ -19,10 +19,12 @@ const story = storiesOf('Components/Conversation/Quote', module); const defaultMessageProps: MessagesProps = { authorTitle: 'Person X', canReply: true, + canDeleteForEveryone: true, clearSelectedMessage: () => null, conversationId: 'conversationId', conversationType: 'direct', // override deleteMessage: () => null, + deleteMessageForEveryone: () => null, direction: 'incoming', displayTapToViewMessage: () => null, downloadAttachment: () => null, diff --git a/ts/components/conversation/Timeline.stories.tsx b/ts/components/conversation/Timeline.stories.tsx index 3008c65d7332..5f98e7d52d00 100644 --- a/ts/components/conversation/Timeline.stories.tsx +++ b/ts/components/conversation/Timeline.stories.tsx @@ -223,6 +223,7 @@ const actions = () => ({ replyToMessage: action('replyToMessage'), retrySend: action('retrySend'), deleteMessage: action('deleteMessage'), + deleteMessageForEveryone: action('deleteMessageForEveryone'), showMessageDetail: action('showMessageDetail'), openConversation: action('openConversation'), showContactDetail: action('showContactDetail'), diff --git a/ts/components/conversation/TimelineItem.stories.tsx b/ts/components/conversation/TimelineItem.stories.tsx index a11843a43f79..944fd4a86be7 100644 --- a/ts/components/conversation/TimelineItem.stories.tsx +++ b/ts/components/conversation/TimelineItem.stories.tsx @@ -40,6 +40,7 @@ const getDefaultProps = () => ({ replyToMessage: action('replyToMessage'), retrySend: action('retrySend'), deleteMessage: action('deleteMessage'), + deleteMessageForEveryone: action('deleteMessageForEveryone'), showMessageDetail: action('showMessageDetail'), openConversation: action('openConversation'), showContactDetail: action('showContactDetail'), diff --git a/ts/model-types.d.ts b/ts/model-types.d.ts index aa200b0063e4..3b42aad4800c 100644 --- a/ts/model-types.d.ts +++ b/ts/model-types.d.ts @@ -50,6 +50,7 @@ export type MessageAttributesType = { dataMessage: ArrayBuffer | null; decrypted_at: number; deletedForEveryone: boolean; + deletedForEveryoneTimestamp?: number; delivered: number; delivered_to: Array; errors: Array | null; diff --git a/ts/models/conversations.ts b/ts/models/conversations.ts index d2bcaa72defe..8c386506eae4 100644 --- a/ts/models/conversations.ts +++ b/ts/models/conversations.ts @@ -63,6 +63,8 @@ const COLORS = [ 'ultramarine', ]; +const THREE_HOURS = 3 * 60 * 60 * 1000; + interface CustomError extends Error { identifier?: string; number?: string; @@ -1811,6 +1813,106 @@ export class ConversationModel extends window.Backbone.Model< window.reduxActions.stickers.useSticker(packId, stickerId); } + async sendDeleteForEveryoneMessage(targetTimestamp: number): Promise { + const timestamp = Date.now(); + + if (timestamp - targetTimestamp > THREE_HOURS) { + throw new Error('Cannot send DOE for a message older than three hours'); + } + + const deleteModel = window.Whisper.Deletes.add({ + targetSentTimestamp: targetTimestamp, + fromId: window.ConversationController.getOurConversationId(), + }); + + window.Whisper.Deletes.onDelete(deleteModel); + + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + const destination = this.getSendTarget()!; + const recipients = this.getRecipients(); + + let profileKey: ArrayBuffer | undefined; + if (this.get('profileSharing')) { + profileKey = window.storage.get('profileKey'); + } + + return this.queueJob(async () => { + window.log.info( + 'Sending deleteForEveryone to conversation', + this.idForLogging(), + 'with timestamp', + timestamp + ); + + const attributes = ({ + id: window.getGuid(), + type: 'outgoing', + conversationId: this.get('id'), + sent_at: timestamp, + received_at: timestamp, + recipients, + deletedForEveryoneTimestamp: targetTimestamp, + // TODO: DESKTOP-722 + } as unknown) as typeof window.Whisper.MessageAttributesType; + + if (this.isPrivate()) { + attributes.destination = destination; + } + + // We are only creating this model so we can use its sync message + // sending functionality. It will not be saved to the datbase. + const message = new window.Whisper.Message(attributes); + + // We're offline! + if (!window.textsecure.messaging) { + throw new Error('Cannot send DOE while offline!'); + } + + const options = this.getSendOptions(); + + const promise = (() => { + if (this.isPrivate()) { + return window.textsecure.messaging.sendMessageToIdentifier( + destination, + undefined, // body + [], // attachments + undefined, // quote + [], // preview + undefined, // sticker + undefined, // reaction + targetTimestamp, + timestamp, + undefined, // expireTimer + profileKey, + options + ); + } + + return window.textsecure.messaging.sendMessageToGroup( + { + groupV1: this.getGroupV1Info(), + groupV2: this.getGroupV2Info(), + deletedForEveryoneTimestamp: targetTimestamp, + timestamp, + profileKey, + }, + options + ); + })(); + + return message.send(this.wrapSend(promise)); + }).catch(error => { + window.log.error( + 'Error sending deleteForEveryone', + deleteModel, + targetTimestamp, + error + ); + + throw error; + }); + } + async sendReactionMessage( reaction: { emoji: string; remove: boolean }, target: { @@ -1882,6 +1984,7 @@ export class ConversationModel extends window.Backbone.Model< [], // preview undefined, // sticker outgoingReaction, + undefined, // deletedForEveryoneTimestamp timestamp, expireTimer, profileKey @@ -1901,6 +2004,7 @@ export class ConversationModel extends window.Backbone.Model< [], // preview undefined, // sticker outgoingReaction, + undefined, // deletedForEveryoneTimestamp timestamp, expireTimer, profileKey, @@ -2072,6 +2176,7 @@ export class ConversationModel extends window.Backbone.Model< preview, sticker, null, // reaction + undefined, // deletedForEveryoneTimestamp now, expireTimer, profileKey @@ -2108,6 +2213,7 @@ export class ConversationModel extends window.Backbone.Model< preview, sticker, null, // reaction + undefined, // deletedForEveryoneTimestamp now, expireTimer, profileKey, @@ -2553,6 +2659,7 @@ export class ConversationModel extends window.Backbone.Model< [], // preview undefined, // sticker undefined, // reaction + undefined, // deletedForEveryoneTimestamp message.get('sent_at'), expireTimer, profileKey, diff --git a/ts/models/messages.ts b/ts/models/messages.ts index 147fd459e77d..21ed9e06aca2 100644 --- a/ts/models/messages.ts +++ b/ts/models/messages.ts @@ -63,6 +63,8 @@ const PLACEHOLDER_CONTACT = { title: window.i18n('unknownContact'), }; +const THREE_HOURS = 3 * 60 * 60 * 1000; + window.AccountCache = Object.create(null); window.AccountJobs = Object.create(null); @@ -373,6 +375,9 @@ export class MessageModel extends window.Backbone.Model { deleteMessage: (messageId: string) => { this.trigger('delete', messageId); }, + deleteMessageForEveryone: (messageId: string) => { + this.trigger('delete-for-everyone', messageId); + }, showVisualAttachment: (options: unknown) => { this.trigger('show-visual-attachment', options); }, @@ -730,6 +735,7 @@ export class MessageModel extends window.Backbone.Model { status: this.getMessagePropStatus(), contact: this.getPropsForEmbeddedContact(), canReply: this.canReply(), + canDeleteForEveryone: this.canDeleteForEveryone(), authorTitle: contact.title, authorColor, authorName: contact.name, @@ -1897,6 +1903,7 @@ export class MessageModel extends window.Backbone.Model { previewWithData, stickerWithData, null, + this.get('deletedForEveryoneTimestamp'), this.get('sent_at'), this.get('expireTimer'), profileKey @@ -1966,6 +1973,25 @@ export class MessageModel extends window.Backbone.Model { ); } + canDeleteForEveryone(): boolean { + // is someone else's message + if (this.isIncoming()) { + return false; + } + + // has already been deleted for everyone + if (this.get('deletedForEveryone')) { + return false; + } + + // is too old to delete + if (Date.now() - this.get('sent_at') > THREE_HOURS) { + return false; + } + + return true; + } + canReply(): boolean { // eslint-disable-next-line @typescript-eslint/no-non-null-assertion const isAccepted = this.getConversation()!.getAccepted(); @@ -2051,6 +2077,7 @@ export class MessageModel extends window.Backbone.Model { previewWithData, stickerWithData, null, + this.get('deletedForEveryoneTimestamp'), this.get('sent_at'), this.get('expireTimer'), profileKey, diff --git a/ts/textsecure/SendMessage.ts b/ts/textsecure/SendMessage.ts index 6e28b088b787..52bbbbfda778 100644 --- a/ts/textsecure/SendMessage.ts +++ b/ts/textsecure/SendMessage.ts @@ -118,6 +118,7 @@ type MessageOptionsType = { recipients: Array; sticker?: any; reaction?: any; + deletedForEveryoneTimestamp?: number; timestamp: number; }; @@ -157,6 +158,8 @@ class Message { attachmentPointers?: Array; + deletedForEveryoneTimestamp?: number; + constructor(options: MessageOptionsType) { this.attachments = options.attachments || []; this.body = options.body; @@ -172,6 +175,7 @@ class Message { this.sticker = options.sticker; this.reaction = options.reaction; this.timestamp = options.timestamp; + this.deletedForEveryoneTimestamp = options.deletedForEveryoneTimestamp; if (!(this.recipients instanceof Array)) { throw new Error('Invalid recipient list'); @@ -330,6 +334,11 @@ class Message { if (this.profileKey) { proto.profileKey = this.profileKey; } + if (this.deletedForEveryoneTimestamp) { + proto.delete = { + targetSentTimestamp: this.deletedForEveryoneTimestamp, + }; + } this.dataMessage = proto; return proto; @@ -1412,6 +1421,7 @@ export default class MessageSender { preview: Array, sticker: unknown, reaction: unknown, + deletedForEveryoneTimestamp: number | undefined, timestamp: number, expireTimer: number | undefined, profileKey?: ArrayBuffer, @@ -1427,6 +1437,7 @@ export default class MessageSender { preview, sticker, reaction, + deletedForEveryoneTimestamp, expireTimer, profileKey, flags, @@ -1457,6 +1468,7 @@ export default class MessageSender { preview: Array | undefined, sticker: unknown, reaction: unknown, + deletedForEveryoneTimestamp: number | undefined, timestamp: number, expireTimer: number | undefined, profileKey?: ArrayBuffer, @@ -1472,6 +1484,7 @@ export default class MessageSender { preview, sticker, reaction, + deletedForEveryoneTimestamp, expireTimer, profileKey, }, @@ -1575,6 +1588,7 @@ export default class MessageSender { quote, reaction, sticker, + deletedForEveryoneTimestamp, timestamp, }: { attachments?: Array; @@ -1587,6 +1601,7 @@ export default class MessageSender { quote?: any; reaction?: any; sticker?: any; + deletedForEveryoneTimestamp?: number; timestamp: number; }, options?: SendOptionsType @@ -1625,6 +1640,7 @@ export default class MessageSender { reaction, expireTimer, profileKey, + deletedForEveryoneTimestamp, groupV2, group: groupV1 ? { diff --git a/ts/util/lint/exceptions.json b/ts/util/lint/exceptions.json index f608d5ac0e86..76ba09ad1f9d 100644 --- a/ts/util/lint/exceptions.json +++ b/ts/util/lint/exceptions.json @@ -13057,7 +13057,7 @@ "rule": "React-createRef", "path": "ts/components/conversation/Message.js", "line": " this.audioRef = react_1.default.createRef();", - "lineNumber": 58, + "lineNumber": 59, "reasonCategory": "usageTrusted", "updated": "2020-08-28T16:12:19.904Z" }, @@ -13065,7 +13065,7 @@ "rule": "React-createRef", "path": "ts/components/conversation/Message.js", "line": " this.focusRef = react_1.default.createRef();", - "lineNumber": 59, + "lineNumber": 60, "reasonCategory": "usageTrusted", "updated": "2020-09-11T17:24:56.124Z", "reasonDetail": "Used for managing focus only" @@ -13074,7 +13074,7 @@ "rule": "React-createRef", "path": "ts/components/conversation/Message.js", "line": " this.reactionsContainerRef = react_1.default.createRef();", - "lineNumber": 60, + "lineNumber": 61, "reasonCategory": "usageTrusted", "updated": "2020-08-28T16:12:19.904Z", "reasonDetail": "Used for detecting clicks outside reaction viewer" @@ -13083,7 +13083,7 @@ "rule": "React-createRef", "path": "ts/components/conversation/Message.tsx", "line": " public audioRef: React.RefObject = React.createRef();", - "lineNumber": 211, + "lineNumber": 215, "reasonCategory": "usageTrusted", "updated": "2020-09-08T20:19:01.913Z" }, @@ -13091,7 +13091,7 @@ "rule": "React-createRef", "path": "ts/components/conversation/Message.tsx", "line": " public focusRef: React.RefObject = React.createRef();", - "lineNumber": 213, + "lineNumber": 217, "reasonCategory": "usageTrusted", "updated": "2020-09-08T20:19:01.913Z" }, @@ -13099,7 +13099,7 @@ "rule": "React-createRef", "path": "ts/components/conversation/Message.tsx", "line": " > = React.createRef();", - "lineNumber": 217, + "lineNumber": 221, "reasonCategory": "usageTrusted", "updated": "2020-08-28T19:36:40.817Z" }, diff --git a/ts/views/conversation_view.ts b/ts/views/conversation_view.ts index c40e7e8d6d3c..04377ba73fa0 100644 --- a/ts/views/conversation_view.ts +++ b/ts/views/conversation_view.ts @@ -308,6 +308,11 @@ Whisper.ConversationView = Whisper.View.extend({ ); this.listenTo(this.model.messageCollection, 'force-send', this.forceSend); this.listenTo(this.model.messageCollection, 'delete', this.deleteMessage); + this.listenTo( + this.model.messageCollection, + 'delete-for-everyone', + this.deleteMessageForEveryone + ); this.listenTo( this.model.messageCollection, 'show-visual-attachment', @@ -618,6 +623,9 @@ Whisper.ConversationView = Whisper.View.extend({ const deleteMessage = (messageId: any) => { this.deleteMessage(messageId); }; + const deleteMessageForEveryone = (messageId: string) => { + this.deleteMessageForEveryone(messageId); + }; const showMessageDetail = (messageId: any) => { this.showMessageDetail(messageId); }; @@ -796,6 +804,7 @@ Whisper.ConversationView = Whisper.View.extend({ id, deleteMessage, + deleteMessageForEveryone, displayTapToViewMessage, downloadAttachment, downloadNewVersion, @@ -2330,6 +2339,27 @@ Whisper.ConversationView = Whisper.View.extend({ dialog.focusCancel(); }, + deleteMessageForEveryone(messageId: string) { + const message = this.model.messageCollection.get(messageId); + if (!message) { + throw new Error( + `deleteMessageForEveryone: Did not find message for id ${messageId}` + ); + } + + const dialog = new Whisper.ConfirmationDialogView({ + message: window.i18n('deleteForEveryoneWarning'), + okText: window.i18n('delete'), + resolve: async () => { + await this.model.sendDeleteForEveryoneMessage(message.get('sent_at')); + this.resetPanel(); + }, + }); + + this.$el.prepend(dialog.el); + dialog.focusCancel(); + }, + showStickerPackPreview(packId: any, packKey: any) { window.Signal.Stickers.downloadEphemeralPack(packId, packKey);