diff --git a/_locales/en/messages.json b/_locales/en/messages.json index 056f45537..04a581558 100644 --- a/_locales/en/messages.json +++ b/_locales/en/messages.json @@ -1691,6 +1691,10 @@ "message": "View-once Video", "description": "Shown in notifications and in the left pane when a message is a view once video." }, + "message--deletedForEveryone": { + "message": "This message was deleted.", + "description": "Shown in a message's bubble when the message has been deleted for everyone." + }, "stickers--toast--InstallFailed": { "message": "Sticker pack could not be installed", "description": "Shown in a toast if the user attempts to install a sticker pack and it fails" diff --git a/background.html b/background.html index 75ea6863a..b50458d8f 100644 --- a/background.html +++ b/background.html @@ -353,6 +353,7 @@ + diff --git a/js/background.js b/js/background.js index 14694c55e..1ebbe595d 100644 --- a/js/background.js +++ b/js/background.js @@ -2241,7 +2241,7 @@ // Note: We do very little in this function, since everything in handleDataMessage is // inside a conversation-specific queue(). Any code here might run before an earlier // message is processed in handleDataMessage(). - async function onMessageReceived(event) { + function onMessageReceived(event) { const { data, confirm } = event; const messageDescriptor = getDescriptorForReceived(data); @@ -2250,17 +2250,16 @@ // eslint-disable-next-line no-bitwise const isProfileUpdate = Boolean(data.message.flags & PROFILE_KEY_UPDATE); if (isProfileUpdate) { - await handleMessageReceivedProfileUpdate({ + return handleMessageReceivedProfileUpdate({ data, confirm, messageDescriptor, }); - return; } - const message = await initIncomingMessage(data); + const message = initIncomingMessage(data); - const result = await ConversationController.getOrCreateAndWait( + const result = ConversationController.getOrCreate( messageDescriptor.id, messageDescriptor.type ); @@ -2286,11 +2285,26 @@ // Note: We do not wait for completion here Whisper.Reactions.onReaction(reactionModel); confirm(); - return; + return Promise.resolve(); + } + + if (data.message.delete) { + const { delete: del } = data.message; + const deleteModel = Whisper.Deletes.add({ + targetSentTimestamp: del.targetSentTimestamp, + serverTimestamp: data.serverTimestamp, + fromId: data.source || data.sourceUuid, + }); + // Note: We do not wait for completion here + Whisper.Deletes.onDelete(deleteModel); + confirm(); + return Promise.resolve(); } // Don't wait for handleDataMessage, as it has its own per-conversation queueing message.handleDataMessage(data.message, event.confirm); + + return Promise.resolve(); } async function handleMessageSentProfileUpdate({ @@ -2341,6 +2355,7 @@ sourceUuid: textsecure.storage.user.getUuid(), sourceDevice: data.device, sent_at: data.timestamp, + serverTimestamp: data.serverTimestamp, sent_to: sentTo, received_at: now, conversationId: data.destination, @@ -2357,7 +2372,7 @@ // Note: We do very little in this function, since everything in handleDataMessage is // inside a conversation-specific queue(). Any code here might run before an earlier // message is processed in handleDataMessage(). - async function onSentMessage(event) { + function onSentMessage(event) { const { data, confirm } = event; const messageDescriptor = getDescriptorForSent(data); @@ -2366,20 +2381,20 @@ // eslint-disable-next-line no-bitwise const isProfileUpdate = Boolean(data.message.flags & PROFILE_KEY_UPDATE); if (isProfileUpdate) { - await handleMessageSentProfileUpdate({ + return handleMessageSentProfileUpdate({ data, confirm, messageDescriptor, }); - return; } - const message = await createSentMessage(data); + const message = createSentMessage(data); + + const ourNumber = textsecure.storage.user.getNumber(); + const ourUuid = textsecure.storage.user.getUuid(); if (data.message.reaction) { const { reaction } = data.message; - const ourNumber = textsecure.storage.user.getNumber(); - const ourUuid = textsecure.storage.user.getUuid(); const reactionModel = Whisper.Reactions.add({ emoji: reaction.emoji, remove: reaction.remove, @@ -2394,10 +2409,23 @@ Whisper.Reactions.onReaction(reactionModel); event.confirm(); - return; + return Promise.resolve(); } - await ConversationController.getOrCreateAndWait( + if (data.message.delete) { + const { delete: del } = data.message; + const deleteModel = Whisper.Deletes.add({ + targetSentTimestamp: del.targetSentTimestamp, + serverTimestamp: del.serverTimestamp, + fromId: ourNumber || ourUuid, + }); + // Note: We do not wait for completion here + Whisper.Deletes.onDelete(deleteModel); + confirm(); + return Promise.resolve(); + } + + ConversationController.getOrCreate( messageDescriptor.id, messageDescriptor.type ); @@ -2406,9 +2434,11 @@ message.handleDataMessage(data.message, event.confirm, { data, }); + + return Promise.resolve(); } - async function initIncomingMessage(data) { + function initIncomingMessage(data) { const targetId = data.source || data.sourceUuid; const conversation = ConversationController.get(targetId); const conversationId = conversation ? conversation.id : targetId; @@ -2418,6 +2448,7 @@ sourceUuid: data.sourceUuid, sourceDevice: data.sourceDevice, sent_at: data.timestamp, + serverTimestamp: data.serverTimestamp, received_at: Date.now(), conversationId, unidentifiedDeliveryReceived: data.unidentifiedDeliveryReceived, @@ -2485,7 +2516,7 @@ } } - async function onError(ev) { + function onError(ev) { const { error } = ev; window.log.error('background onError:', Errors.toLogFormat(error)); @@ -2494,8 +2525,7 @@ error.name === 'HTTPError' && (error.code === 401 || error.code === 403) ) { - await unlinkAndDisconnect(); - return; + return unlinkAndDisconnect(); } if ( @@ -2510,7 +2540,7 @@ Whisper.events.trigger('reconnectTimer'); } - return; + return Promise.resolve(); } if (ev.proto) { @@ -2520,13 +2550,13 @@ } // Ignore this message. It is likely a duplicate delivery // because the server lost our ack the first time. - return; + return Promise.resolve(); } const envelope = ev.proto; - const message = await initIncomingMessage(envelope); + const message = initIncomingMessage(envelope); const conversationId = message.get('conversationId'); - const conversation = await ConversationController.getOrCreateAndWait( + const conversation = ConversationController.getOrCreate( conversationId, 'private' ); diff --git a/js/deletes.js b/js/deletes.js new file mode 100644 index 000000000..9b7f76766 --- /dev/null +++ b/js/deletes.js @@ -0,0 +1,108 @@ +/* global + Backbone, + Whisper, + MessageController, + ConversationController +*/ + +/* eslint-disable more/no-then */ + +// eslint-disable-next-line func-names +(function() { + 'use strict'; + + const ONE_DAY = 24 * 60 * 60 * 1000; + + window.Whisper = window.Whisper || {}; + Whisper.Deletes = new (Backbone.Collection.extend({ + forMessage(message) { + const matchingDeletes = this.filter({ + targetSentTimestamp: message.get('sent_at'), + fromId: message.getContact().get('id'), + }); + + if (matchingDeletes.length > 0) { + window.log.info('Found early DOE for message'); + this.remove(matchingDeletes); + return matchingDeletes; + } + + return []; + }, + async onDelete(del) { + try { + const messages = await window.Signal.Data.getMessagesBySentAt( + del.get('targetSentTimestamp'), + { + MessageCollection: Whisper.MessageCollection, + } + ); + + // The contact the delete message came from + const fromContact = ConversationController.get(del.get('fromId')); + + if (!fromContact) { + window.log.info( + 'No contact for DOE', + del.get('fromId'), + del.get('targetSentTimestamp') + ); + + return; + } + + const targetMessage = messages.find(m => { + const messageContact = m.getContact(); + + if (!messageContact) { + return false; + } + + // Find messages which are from the same contact who sent the DOE + return messageContact.get('id') === fromContact.get('id'); + }); + + if (!targetMessage) { + window.log.info( + 'No message for DOE', + del.get('fromId'), + del.get('targetSentTimestamp') + ); + + return; + } + + // Make sure the server timestamps for the DOE and the matching message + // are less than one day apart + const delta = Math.abs( + del.get('serverTimestamp') - targetMessage.get('serverTimestamp') + ); + if (delta > ONE_DAY) { + window.log.info('Received late DOE. Dropping.', { + fromId: del.get('fromId'), + targetSentTimestamp: del.get('targetSentTimestamp'), + messageServerTimestamp: message.get('serverTimestamp'), + deleteServerTimestamp: del.get('serverTimestamp'), + }); + this.remove(del); + + return; + } + + const message = MessageController.register( + targetMessage.id, + targetMessage + ); + + await message.handleDeleteForEveryone(del); + + this.remove(del); + } catch (error) { + window.log.error( + 'Deletes.onDelete error:', + error && error.stack ? error.stack : error + ); + } + }, + }))(); +})(); diff --git a/js/models/conversations.js b/js/models/conversations.js index ced09bcd2..219b402ec 100644 --- a/js/models/conversations.js +++ b/js/models/conversations.js @@ -414,6 +414,7 @@ lastMessage: { status: this.get('lastMessageStatus'), text: this.get('lastMessage'), + deletedForEveryone: this.get('lastMessageDeletedForEveryone'), }, }; diff --git a/js/models/messages.js b/js/models/messages.js index 8cb5c4bc5..04845ec85 100644 --- a/js/models/messages.js +++ b/js/models/messages.js @@ -600,6 +600,8 @@ isTapToViewExpired: isTapToView && this.get('isErased'), isTapToViewError: isTapToView && this.isIncoming() && this.get('isTapToViewInvalid'), + + deletedForEveryone: this.get('deletedForEveryone') || false, }; }, @@ -1105,7 +1107,7 @@ isErased() { return Boolean(this.get('isErased')); }, - async eraseContents() { + async eraseContents(additionalProperties = {}) { if (this.get('isErased')) { return; } @@ -1129,6 +1131,7 @@ contact: [], sticker: null, preview: [], + ...additionalProperties, }); this.trigger('content-changed'); @@ -1442,12 +1445,17 @@ const isOutgoing = this.get('type') === 'outgoing'; const numDelivered = this.get('delivered'); - // Case 1: We can reply if this is outgoing and delievered to at least one recipient + // Case 1: We cannot reply if this message is deleted for everyone + if (this.get('deletedForEveryone')) { + return false; + } + + // Case 2: We can reply if this is outgoing and delievered to at least one recipient if (isOutgoing && numDelivered > 0) { return true; } - // Case 2: We can reply if there are no errors + // Case 3: We can reply if there are no errors if (!errors || (errors && errors.length === 0)) { return true; } @@ -2457,6 +2465,11 @@ message.handleReaction(reaction); }); + // Does this message have any pending, previously-received associated + // delete for everyone messages? + const deletes = Whisper.Deletes.forMessage(message); + deletes.forEach(del => Whisper.Deletes.onDelete(del)); + Whisper.events.trigger('incrementProgress'); confirm(); } catch (error) { @@ -2473,6 +2486,10 @@ }, async handleReaction(reaction) { + if (this.get('deletedForEveryone')) { + return; + } + const reactions = this.get('reactions') || []; const messageId = this.idForLogging(); const count = reactions.length; @@ -2512,6 +2529,27 @@ Message: Whisper.Message, }); }, + + async handleDeleteForEveryone(del) { + window.log.info('Handling DOE.', { + fromId: del.get('fromId'), + targetSentTimestamp: del.get('targetSentTimestamp'), + messageServerTimestamp: this.get('serverTimestamp'), + deleteServerTimestamp: del.get('serverTimestamp'), + }); + + // Remove any notifications for this message + const notificationForMessage = Whisper.Notifications.findWhere({ + messageId: this.get('id'), + }); + Whisper.Notifications.remove(notificationForMessage); + + // Erase the contents of this message + await this.eraseContents({ deletedForEveryone: true, reactions: [] }); + + // Update the conversation's last message in case this was the last message + this.getConversation().updateLastMessage(); + }, }); // Receive will be enabled before we enable send diff --git a/protos/SignalService.proto b/protos/SignalService.proto index 8b904e2bb..d5516c172 100644 --- a/protos/SignalService.proto +++ b/protos/SignalService.proto @@ -179,6 +179,10 @@ message DataMessage { optional uint64 targetTimestamp = 5; } + message Delete { + optional uint64 targetSentTimestamp = 1; + } + enum ProtocolVersion { option allow_alias = true; @@ -206,6 +210,7 @@ message DataMessage { optional uint32 requiredProtocolVersion = 12; optional bool isViewOnce = 14; optional Reaction reaction = 16; + optional Delete delete = 17; } message NullMessage { diff --git a/stylesheets/_modules.scss b/stylesheets/_modules.scss index 38699475c..0d9c86299 100644 --- a/stylesheets/_modules.scss +++ b/stylesheets/_modules.scss @@ -3396,9 +3396,12 @@ $timer-icons: '55', '50', '45', '40', '35', '30', '25', '20', '15', '10', '05', } } -.module-conversation-list-item__message__draft-prefix { - font-style: italic; - margin-right: 3px; +.module-conversation-list-item__message { + &__draft-prefix, + &__deleted-for-everyone { + font-style: italic; + margin-right: 3px; + } } .module-conversation-list-item__message__status-icon { @@ -7869,6 +7872,10 @@ button.module-image__border-overlay:focus { &--with-reactions { margin-bottom: 12px; } + + &--deleted-for-everyone { + font-style: italic; + } } /* Spec: container > 438px and container < 593px */ diff --git a/ts/components/ConversationListItem.tsx b/ts/components/ConversationListItem.tsx index d124456a6..8906a1798 100644 --- a/ts/components/ConversationListItem.tsx +++ b/ts/components/ConversationListItem.tsx @@ -31,6 +31,7 @@ export type PropsData = { lastMessage?: { status: 'sending' | 'sent' | 'delivered' | 'read' | 'error'; text: string; + deletedForEveryone?: boolean; }; }; @@ -152,6 +153,9 @@ export class ConversationListItem extends React.PureComponent { } const showingDraft = shouldShowDraft && draftPreview; + const deletedForEveryone = Boolean( + lastMessage && lastMessage.deletedForEveryone + ); // Note: instead of re-using showingDraft here we explode it because // typescript can't tell that draftPreview is truthy otherwise @@ -181,6 +185,10 @@ export class ConversationListItem extends React.PureComponent { {i18n('ConversationListItem--draft-prefix')} + ) : deletedForEveryone ? ( + + {i18n('message--deletedForEveryone')} + ) : null} { } public renderText() { - const { text, textPending, i18n, direction, status } = this.props; + const { + deletedForEveryone, + direction, + i18n, + status, + text, + textPending, + } = this.props; - const contents = - direction === 'incoming' && status === 'error' - ? i18n('incomingError') - : text; + const contents = deletedForEveryone + ? i18n('message--deletedForEveryone') + : direction === 'incoming' && status === 'error' + ? i18n('incomingError') + : text; if (!contents) { return null; @@ -1677,7 +1687,11 @@ export class Message extends React.PureComponent { } public renderContents() { - const { isTapToView } = this.props; + const { isTapToView, deletedForEveryone } = this.props; + + if (deletedForEveryone) { + return this.renderText(); + } if (isTapToView) { return ( @@ -1863,9 +1877,11 @@ export class Message extends React.PureComponent { this.handleOpen(event); }; + // tslint:disable-next-line: cyclomatic-complexity public renderContainer() { const { authorColor, + deletedForEveryone, direction, isSticker, isTapToView, @@ -1903,6 +1919,9 @@ export class Message extends React.PureComponent { : null, reactions && reactions.length > 0 ? 'module-message__container--with-reactions' + : null, + deletedForEveryone + ? 'module-message__container--deleted-for-everyone' : null ); const containerStyles = { diff --git a/ts/state/ducks/conversations.ts b/ts/state/ducks/conversations.ts index 831d3052e..a7e8eee86 100644 --- a/ts/state/ducks/conversations.ts +++ b/ts/state/ducks/conversations.ts @@ -88,6 +88,7 @@ export type MessageType = { phoneNumber?: string; }; }>; + deletedForEveryone?: boolean; errors?: Array; group_update?: any; @@ -627,6 +628,12 @@ function hasMessageHeightChanged( return true; } + const isDeletedForEveryone = message.deletedForEveryone; + const wasDeletedForEveryone = previous.deletedForEveryone; + if (isDeletedForEveryone !== wasDeletedForEveryone) { + return true; + } + return false; } diff --git a/ts/test/types/Conversation_test.ts b/ts/test/types/Conversation_test.ts index e57b4decd..a045e11a2 100644 --- a/ts/test/types/Conversation_test.ts +++ b/ts/test/types/Conversation_test.ts @@ -38,6 +38,7 @@ describe('Conversation', () => { const expected = { lastMessage: 'New outgoing message', lastMessageStatus: 'read', + lastMessageDeletedForEveryone: undefined, timestamp: 666, }; @@ -61,6 +62,7 @@ describe('Conversation', () => { const expected = { lastMessage: 'xoxoxoxo', lastMessageStatus: null, + lastMessageDeletedForEveryone: undefined, timestamp: 555, }; @@ -84,6 +86,7 @@ describe('Conversation', () => { const expected = { lastMessage: '', lastMessageStatus: null, + lastMessageDeletedForEveryone: undefined, timestamp: 555, }; @@ -112,6 +115,7 @@ describe('Conversation', () => { const expected = { lastMessage: 'Last message before expired', lastMessageStatus: null, + lastMessageDeletedForEveryone: undefined, timestamp: 555, }; diff --git a/ts/textsecure.d.ts b/ts/textsecure.d.ts index 9e4a92b2f..0c4b4bbfa 100644 --- a/ts/textsecure.d.ts +++ b/ts/textsecure.d.ts @@ -235,6 +235,7 @@ export declare class DataMessageClass { requiredProtocolVersion?: number; isViewOnce?: boolean; reaction?: DataMessageClass.Reaction; + delete?: DataMessageClass.Delete; } // Note: we need to use namespaces to express nested classes in Typescript @@ -287,6 +288,10 @@ export declare namespace DataMessageClass { targetTimestamp?: ProtoBigNumberType; } + class Delete { + targetSentTimestamp?: ProtoBigNumberType; + } + class Sticker { packId?: ProtoBinaryType; packKey?: ProtoBinaryType; diff --git a/ts/textsecure/MessageReceiver.ts b/ts/textsecure/MessageReceiver.ts index 731eb6870..9d95b2b24 100644 --- a/ts/textsecure/MessageReceiver.ts +++ b/ts/textsecure/MessageReceiver.ts @@ -1089,6 +1089,7 @@ class MessageReceiverInner extends EventTarget { ev.data = { destination, timestamp: timestamp.toNumber(), + serverTimestamp: envelope.serverTimestamp, device: envelope.sourceDevice, unidentifiedStatus, message, @@ -1159,6 +1160,7 @@ class MessageReceiverInner extends EventTarget { sourceUuid: envelope.sourceUuid, sourceDevice: envelope.sourceDevice, timestamp: envelope.timestamp.toNumber(), + serverTimestamp: envelope.serverTimestamp, unidentifiedDeliveryReceived: envelope.unidentifiedDeliveryReceived, message, }; @@ -1795,6 +1797,13 @@ class MessageReceiverInner extends EventTarget { } } + const { delete: del } = decrypted; + if (del) { + if (del.targetSentTimestamp) { + del.targetSentTimestamp = del.targetSentTimestamp.toNumber(); + } + } + const groupMembers = decrypted.group ? decrypted.group.members || [] : []; window.normalizeUuids( diff --git a/ts/types/Conversation.ts b/ts/types/Conversation.ts index 9b3b08ed2..1073715c0 100644 --- a/ts/types/Conversation.ts +++ b/ts/types/Conversation.ts @@ -4,6 +4,7 @@ interface ConversationLastMessageUpdate { lastMessage: string; lastMessageStatus: string | null; timestamp: number | null; + lastMessageDeletedForEveryone?: boolean; } export const createLastMessageUpdate = ({ @@ -25,7 +26,7 @@ export const createLastMessageUpdate = ({ }; } - const { type, expirationTimerUpdate } = lastMessage; + const { type, expirationTimerUpdate, deletedForEveryone } = lastMessage; const isMessageHistoryUnsynced = type === 'message-history-unsynced'; const isVerifiedChangeMessage = type === 'verified-change'; const isExpireTimerUpdateFromSync = Boolean( @@ -47,8 +48,9 @@ export const createLastMessageUpdate = ({ : ''; return { - lastMessage: newLastMessageText || '', + lastMessage: deletedForEveryone ? '' : newLastMessageText || '', lastMessageStatus: lastMessageStatus || null, timestamp: newTimestamp || null, + lastMessageDeletedForEveryone: deletedForEveryone, }; }; diff --git a/ts/types/Message.ts b/ts/types/Message.ts index 6d002a682..c1f2fbed7 100644 --- a/ts/types/Message.ts +++ b/ts/types/Message.ts @@ -2,10 +2,11 @@ import { Attachment } from './Attachment'; import { ContactType } from './Contact'; import { IndexableBoolean, IndexablePresence } from './IndexedDB'; -export type Message = +export type Message = ( | UserMessage | VerifiedChangeMessage - | MessageHistoryUnsyncedMessage; + | MessageHistoryUnsyncedMessage +) & { deletedForEveryone?: boolean }; export type UserMessage = IncomingMessage | OutgoingMessage; export type IncomingMessage = Readonly< diff --git a/ts/util/lint/exceptions.json b/ts/util/lint/exceptions.json index c38712d8f..e3c3c1abe 100644 --- a/ts/util/lint/exceptions.json +++ b/ts/util/lint/exceptions.json @@ -11648,17 +11648,17 @@ "rule": "React-createRef", "path": "ts/components/conversation/Message.tsx", "line": " public audioRef: React.RefObject = React.createRef();", - "lineNumber": 179, + "lineNumber": 181, "reasonCategory": "usageTrusted", - "updated": "2020-03-03T22:30:27.594Z" + "updated": "2020-04-16T19:36:47.586Z" }, { "rule": "React-createRef", "path": "ts/components/conversation/Message.tsx", "line": " > = React.createRef();", - "lineNumber": 183, + "lineNumber": 185, "reasonCategory": "usageTrusted", - "updated": "2020-03-03T22:30:27.594Z" + "updated": "2020-04-16T19:36:47.586Z" }, { "rule": "React-createRef",