Delete For Everyone Send

Co-authored-by: Chris Svenningsen <chris@carbonfive.com>
This commit is contained in:
Sidney Keese 2020-09-29 15:55:56 -07:00 committed by Josh Perez
parent 693deaebe8
commit 866217a724
14 changed files with 276 additions and 10 deletions

View file

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

View file

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

View file

@ -45,12 +45,14 @@ const createProps = (overrideProps: Partial<Props> = {}): 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 <Message {...props} direction="outgoing" />;
});
story.add('Error', () => {
const props = createProps({
status: 'error',

View file

@ -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<Props, State> {
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<Props, State> {
isWide: this.wideMl.matches,
containerWidth: 0,
canDeleteForEveryone,
};
}
@ -322,6 +331,7 @@ export class Message extends React.PureComponent<Props, State> {
public componentDidMount(): void {
this.startSelectedTimer();
this.startDeleteForEveryoneTimer();
const { isSelected } = this.props;
if (isSelected) {
@ -353,6 +363,9 @@ export class Message extends React.PureComponent<Props, State> {
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<Props, State> {
}
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<Props, State> {
}
this.checkExpired();
if (canDeleteForEveryone !== prevProps.canDeleteForEveryone) {
this.startDeleteForEveryoneTimer();
}
}
public startSelectedTimer(): void {
@ -388,6 +405,29 @@ export class Message extends React.PureComponent<Props, State> {
}
}
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<Props, State> {
attachments,
canReply,
deleteMessage,
deleteMessageForEveryone,
direction,
i18n,
id,
@ -1296,6 +1337,8 @@ export class Message extends React.PureComponent<Props, State> {
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<Props, State> {
>
{i18n('deleteMessage')}
</MenuItem>
{canDeleteForEveryone ? (
<MenuItem
attributes={{
className: 'module-message__context__delete-message-for-everyone',
}}
onClick={(event: React.MouseEvent) => {
event.stopPropagation();
event.preventDefault();
deleteMessageForEveryone(id);
}}
>
{i18n('deleteMessageForEveryone')}
</MenuItem>
) : null}
</ContextMenu>
);

View file

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

View file

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

View file

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

View file

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

1
ts/model-types.d.ts vendored
View file

@ -50,6 +50,7 @@ export type MessageAttributesType = {
dataMessage: ArrayBuffer | null;
decrypted_at: number;
deletedForEveryone: boolean;
deletedForEveryoneTimestamp?: number;
delivered: number;
delivered_to: Array<string | null>;
errors: Array<CustomError> | null;

View file

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

View file

@ -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<MessageAttributesType> {
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<MessageAttributesType> {
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<MessageAttributesType> {
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<MessageAttributesType> {
);
}
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<MessageAttributesType> {
previewWithData,
stickerWithData,
null,
this.get('deletedForEveryoneTimestamp'),
this.get('sent_at'),
this.get('expireTimer'),
profileKey,

View file

@ -118,6 +118,7 @@ type MessageOptionsType = {
recipients: Array<string>;
sticker?: any;
reaction?: any;
deletedForEveryoneTimestamp?: number;
timestamp: number;
};
@ -157,6 +158,8 @@ class Message {
attachmentPointers?: Array<any>;
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<PreviewType>,
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<PreviewType> | 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<AttachmentType>;
@ -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
? {

View file

@ -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<HTMLAudioElement> = 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<HTMLDivElement> = 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"
},

View file

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