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