From 1d459ba533f6def9db8a24ccf8488aaa90e47fc3 Mon Sep 17 00:00:00 2001 From: Warrick Date: Mon, 20 Sep 2021 13:47:59 +1000 Subject: [PATCH] Unsend messages (only message hashes saving for now is enabled) (#1891) * WIP unsending message. * retrieving message with hash from db on receiving unsend request. * outgoing messages hashes updated on message sent success * unsend messaging * unsend message - deleting messages and marking as isDeleted. * add msg hash to unprocessed records. * Message unsending working for closed groups provided the message has been updated with a hash from server. * adding 2-stage confirmation modal for message deletion * adding rendering of removed incoming messages, disabling rendering of unsent outgoing messages in coversation screen. * Adding logging * debugging. * outgoing only saved for sync message instead of regular message. * deleting locally * adding post unsend deletioncode. * starting adding feature flag. * Added feature flag. * addding mandatory messageHash pollling pipeline methods swarm polling. * Conversation list item message preview showing deletion placeholder text if deleted. * add condition to drop unsend requests not send by message author * refactoring deleteMessage. Saving response hash for closed group message sending * running yarn ready * removing logging. * Adding PR fixes * Minor changes and running yarn ready * fix typo * Moved feature flag to lokiFeatureFlags. Fixing linting errors Co-authored-by: Audric Ackermann --- _locales/en/messages.json | 9 +- app/sql.js | 57 +++++- preload.js | 1 + protos/SignalService.proto | 9 +- stylesheets/_session_theme.scss | 8 + ts/components/ConversationListItem.tsx | 2 + ts/components/conversation/Message.tsx | 44 ++--- .../message/GenericReadableMessage.tsx | 1 + .../conversation/message/MessageContent.tsx | 38 ++-- .../message/MessageContentWithStatus.tsx | 9 +- .../message/MessageContextMenu.tsx | 2 +- .../conversation/message/MessageText.tsx | 13 +- ts/components/dialog/SessionConfirm.tsx | 15 +- .../session/conversation/SessionRecording.tsx | 2 +- ts/data/data.ts | 25 +++ ts/interactions/conversationInteractions.ts | 135 +++++++++---- ts/models/conversation.ts | 145 ++++++++++++-- ts/models/message.ts | 39 ++++ ts/models/messageType.ts | 12 ++ ts/opengroup/opengroupV2/OpenGroupAPIV2.ts | 3 + ts/receiver/cache.ts | 7 +- ts/receiver/contentMessage.ts | 47 ++++- ts/receiver/dataMessage.ts | 13 +- ts/receiver/queuedJob.ts | 16 +- ts/receiver/receiver.ts | 28 ++- .../outgoing/controlMessage/UnsendMessage.ts | 30 +++ ts/session/sending/MessageQueue.ts | 48 +++-- ts/session/sending/MessageSender.ts | 46 ++++- ts/session/sending/MessageSentHandler.ts | 11 +- ts/session/sending/PendingMessageCache.ts | 7 +- ts/session/snode_api/SNodeAPI.ts | 180 +++++++++++++++++- ts/session/snode_api/swarmPolling.ts | 6 +- ts/session/utils/Messages.ts | 18 +- ts/session/utils/Toast.tsx | 4 + ts/state/ducks/conversations.ts | 3 + ts/state/selectors/conversations.ts | 8 +- .../session/unit/sending/MessageQueue_test.ts | 7 +- ts/window.d.ts | 1 + 38 files changed, 880 insertions(+), 169 deletions(-) create mode 100644 ts/session/messages/outgoing/controlMessage/UnsendMessage.ts diff --git a/_locales/en/messages.json b/_locales/en/messages.json index 80f7c32bc..760fc8836 100644 --- a/_locales/en/messages.json +++ b/_locales/en/messages.json @@ -125,8 +125,11 @@ "moreInformation": "More information", "resend": "Resend", "deleteMessage": "Delete Message", + "deleteMessageQuestion": "Delete message?", + "deleteMessagesQuestion": "Delete messages?", "deleteMessages": "Delete Messages", "deleteMessageForEveryone": "Delete Message For Everyone", + "deleteMessageForEveryoneLowercase": "Delete Message For Everyone", "deleteMessagesForEveryone": "Delete Messages For Everyone", "deleteForEveryone": "Delete for Everyone", "deleteConversationConfirmation": "Permanently delete the messages in this conversation?", @@ -434,5 +437,9 @@ "recoveryPhraseSecureTitle": "You're almost finished!", "recoveryPhraseRevealMessage": "Secure your account by saving your recovery phrase. Reveal your recovery phrase then store it safely to secure it.", "recoveryPhraseRevealButtonText": "Reveal Recovery Phrase", - "notificationSubtitle": "Notifications - $setting$" + "notificationSubtitle": "Notifications - $setting$", + "deletionTypeTitle": "Deletion Type", + "deleteJustForMe": "Delete just for me", + "messageDeletedPlaceholder": "This message has been deleted", + "messageDeleted": "Message deleted" } diff --git a/app/sql.js b/app/sql.js index 14648edfb..217f7ba56 100644 --- a/app/sql.js +++ b/app/sql.js @@ -62,6 +62,7 @@ module.exports = { getMessageBySender, getMessageBySenderAndServerId, getMessageBySenderAndServerTimestamp, + getMessageBySenderAndTimestamp, getMessageIdsFromServerIds, getMessageById, getMessagesBySentAt, @@ -833,6 +834,7 @@ const LOKI_SCHEMA_VERSIONS = [ updateToLokiSchemaVersion13, updateToLokiSchemaVersion14, updateToLokiSchemaVersion15, + updateToLokiSchemaVersion16, ]; function updateToLokiSchemaVersion1(currentVersion, db) { @@ -1195,6 +1197,37 @@ function updateToLokiSchemaVersion15(currentVersion, db) { console.log(`updateToLokiSchemaVersion${targetVersion}: success!`); } +function updateToLokiSchemaVersion16(currentVersion, db) { + const targetVersion = 16; + if (currentVersion >= targetVersion) { + return; + } + console.log(`updateToLokiSchemaVersion${targetVersion}: starting...`); + + db.transaction(() => { + db.exec(` + ALTER TABLE ${MESSAGES_TABLE} ADD COLUMN serverHash TEXT; + ALTER TABLE ${MESSAGES_TABLE} ADD COLUMN isDeleted BOOLEAN; + + CREATE INDEX messages_serverHash ON ${MESSAGES_TABLE} ( + serverHash + ) WHERE serverHash IS NOT NULL; + + CREATE INDEX messages_isDeleted ON ${MESSAGES_TABLE} ( + isDeleted + ) WHERE isDeleted IS NOT NULL; + + ALTER TABLE unprocessed ADD serverHash TEXT; + CREATE INDEX messages_messageHash ON unprocessed ( + serverHash + ) WHERE serverHash IS NOT NULL; + `); + + writeLokiSchemaVersion(targetVersion, db); + })(); + console.log(`updateToLokiSchemaVersion${targetVersion}: success!`); +} + function writeLokiSchemaVersion(newVersion, db) { db.prepare( `INSERT INTO loki_schema( @@ -2040,6 +2073,21 @@ function getMessageBySenderAndServerId({ source, serverId }) { return map(rows, row => jsonToObject(row.json)); } +function getMessageBySenderAndTimestamp({ source, timestamp }) { + const rows = globalInstance + .prepare( + `SELECT json FROM ${MESSAGES_TABLE} WHERE + source = $source AND + sent_at = $timestamp;` + ) + .all({ + source, + timestamp, + }); + + return map(rows, row => jsonToObject(row.json)); +} + function getMessageBySenderAndServerTimestamp({ source, serverTimestamp }) { const rows = globalInstance .prepare( @@ -2230,7 +2278,7 @@ function getNextExpiringMessage() { /* Unproccessed a received messages not yet processed */ function saveUnprocessed(data) { - const { id, timestamp, version, attempts, envelope, senderIdentity } = data; + const { id, timestamp, version, attempts, envelope, senderIdentity, messageHash } = data; if (!id) { throw new Error(`saveUnprocessed: id was falsey: ${id}`); } @@ -2243,14 +2291,16 @@ function saveUnprocessed(data) { version, attempts, envelope, - senderIdentity + senderIdentity, + serverHash ) values ( $id, $timestamp, $version, $attempts, $envelope, - $senderIdentity + $senderIdentity, + $messageHash );` ) .run({ @@ -2260,6 +2310,7 @@ function saveUnprocessed(data) { attempts, envelope, senderIdentity, + messageHash, }); return id; diff --git a/preload.js b/preload.js index 71a4f55fa..51864a310 100644 --- a/preload.js +++ b/preload.js @@ -52,6 +52,7 @@ window.lokiFeatureFlags = { useFileOnionRequestsV2: true, // more compact encoding of files in response padOutgoingAttachments: true, enablePinConversations: true, + useUnsendRequests: false, }; window.isBeforeVersion = (toCheck, baseVersion) => { diff --git a/protos/SignalService.proto b/protos/SignalService.proto index 69347e53e..439b5b91f 100644 --- a/protos/SignalService.proto +++ b/protos/SignalService.proto @@ -28,6 +28,13 @@ message TypingMessage { } +message Unsend { + // @required + required uint64 timestamp = 1; + // @required + required string author = 2; +} + message Content { optional DataMessage dataMessage = 1; @@ -35,7 +42,7 @@ message Content { optional TypingMessage typingMessage = 6; optional ConfigurationMessage configurationMessage = 7; optional DataExtractionNotification dataExtractionNotification = 8; - + optional Unsend unsendMessage = 9; } message KeyPair { diff --git a/stylesheets/_session_theme.scss b/stylesheets/_session_theme.scss index 8cfdf4acc..58c43a7ef 100644 --- a/stylesheets/_session_theme.scss +++ b/stylesheets/_session_theme.scss @@ -54,6 +54,14 @@ .module-message__text { color: var(--color-received-message-text); + display: flex; + flex-direction: row; + justify-content: center; + align-items: center; + + svg { + margin-right: $session-margin-xs; + } a { text-decoration: underline; diff --git a/ts/components/ConversationListItem.tsx b/ts/components/ConversationListItem.tsx index cdb1921f3..f1de55bfc 100644 --- a/ts/components/ConversationListItem.tsx +++ b/ts/components/ConversationListItem.tsx @@ -181,6 +181,7 @@ const MessageItem = (props: { lastMessage?: LastMessageType; isTyping: boolean; unreadCount: number; + convoId: string; }) => { const { lastMessage, isTyping, unreadCount } = props; @@ -323,6 +324,7 @@ const ConversationListItem = (props: Props) => { currentNotificationSetting={currentNotificationSetting || 'all'} /> Promise; }; -export class Message extends React.PureComponent { - public ctxMenuID: string; +export const Message = (props: Props) => { + const msgProps = useSelector(state => + getGenericReadableMessageSelectorProps(state as any, props.messageId) + ); - public constructor(props: Props) { - super(props); - autoBind(this); + const ctxMenuID = `ctx-menu-message-${uuid()}`; + const onQuoteClick = (quote: QuoteClickOptions) => { + void props.onQuoteClick?.(quote); + }; - this.ctxMenuID = `ctx-menu-message-${uuid()}`; + if (msgProps?.isDeleted && msgProps.direction === 'outgoing') { + return null; } - // tslint:disable-next-line: cyclomatic-complexity cyclomatic-complexity - public render() { - return ( - - ); - } - - private onQuoteClick(quote: QuoteClickOptions) { - void this.props.onQuoteClick?.(quote); - } -} + return ( + + ); +}; diff --git a/ts/components/conversation/message/GenericReadableMessage.tsx b/ts/components/conversation/message/GenericReadableMessage.tsx index ebf7c9202..156bf79b1 100644 --- a/ts/components/conversation/message/GenericReadableMessage.tsx +++ b/ts/components/conversation/message/GenericReadableMessage.tsx @@ -32,6 +32,7 @@ export type GenericReadableMessageSelectorProps = Pick< | 'isKickedFromGroup' | 'isExpired' | 'convoId' + | 'isDeleted' >; type ExpiringProps = { diff --git a/ts/components/conversation/message/MessageContent.tsx b/ts/components/conversation/message/MessageContent.tsx index 5ad5c3471..d17fb5069 100644 --- a/ts/components/conversation/message/MessageContent.tsx +++ b/ts/components/conversation/message/MessageContent.tsx @@ -5,7 +5,10 @@ import { InView } from 'react-intersection-observer'; import { useSelector } from 'react-redux'; import _ from 'underscore'; import { MessageRenderingProps, QuoteClickOptions } from '../../../models/messageType'; -import { getMessageContentSelectorProps } from '../../../state/selectors/conversations'; +import { + getMessageContentSelectorProps, + getMessageTextProps, +} from '../../../state/selectors/conversations'; import { canDisplayImage, getGridDimensions, @@ -131,6 +134,12 @@ export const MessageContent = (props: Props) => { attachments, } = contentProps; + const selectedMsg = useSelector(state => getMessageTextProps(state as any, props.messageId)); + let isDeleted = false; + if (selectedMsg && selectedMsg.isDeleted !== undefined) { + isDeleted = selectedMsg.isDeleted; + } + const width = getWidth({ previews, attachments }); const isShowingImage = getIsShowingImage({ attachments, imageBroken, previews, text }); const hasText = Boolean(text); @@ -170,19 +179,24 @@ export const MessageContent = (props: Props) => { triggerOnce={false} > - - - {hasContentAfterAttachmentAndQuote ? ( + {!isDeleted && ( <> - - - - + + + )} + {hasContentAfterAttachmentAndQuote ? ( + + {!isDeleted && ( + + )} + + + ) : null} diff --git a/ts/components/conversation/message/MessageContentWithStatus.tsx b/ts/components/conversation/message/MessageContentWithStatus.tsx index 184975b42..f2351d1c9 100644 --- a/ts/components/conversation/message/MessageContentWithStatus.tsx +++ b/ts/components/conversation/message/MessageContentWithStatus.tsx @@ -13,7 +13,10 @@ import { MessageContent } from './MessageContent'; import { MessageContextMenu } from './MessageContextMenu'; import { MessageStatus } from './MessageStatus'; -export type MessageContentWithStatusSelectorProps = Pick; +export type MessageContentWithStatusSelectorProps = Pick< + MessageRenderingProps, + 'direction' | 'isDeleted' +>; type Props = { messageId: string; @@ -60,7 +63,7 @@ export const MessageContentWithStatuses = (props: Props) => { if (!contentProps) { return null; } - const { direction } = contentProps; + const { direction, isDeleted } = contentProps; const isIncoming = direction === 'incoming'; return ( @@ -80,7 +83,7 @@ export const MessageContentWithStatuses = (props: Props) => { /> - + {!isDeleted && } ); }; diff --git a/ts/components/conversation/message/MessageContextMenu.tsx b/ts/components/conversation/message/MessageContextMenu.tsx index 59144e1ba..5573a331e 100644 --- a/ts/components/conversation/message/MessageContextMenu.tsx +++ b/ts/components/conversation/message/MessageContextMenu.tsx @@ -154,7 +154,7 @@ export const MessageContextMenu = (props: Props) => { }, [messageId]); const onDelete = useCallback(() => { - void deleteMessagesById([messageId], convoId, false); + void deleteMessagesById([messageId], convoId, true); }, [convoId, messageId]); return ( diff --git a/ts/components/conversation/message/MessageText.tsx b/ts/components/conversation/message/MessageText.tsx index dfa6c7035..643a15092 100644 --- a/ts/components/conversation/message/MessageText.tsx +++ b/ts/components/conversation/message/MessageText.tsx @@ -6,6 +6,7 @@ import { getMessageTextProps, isMessageSelectionMode, } from '../../../state/selectors/conversations'; +import { SessionIcon } from '../../session/icon'; import { MessageBody } from '../MessageBody'; type Props = { @@ -14,7 +15,7 @@ type Props = { export type MessageTextSelectorProps = Pick< MessageRenderingProps, - 'text' | 'direction' | 'status' | 'conversationType' | 'convoId' + 'text' | 'direction' | 'status' | 'conversationType' | 'convoId' | 'isDeleted' >; export const MessageText = (props: Props) => { @@ -24,10 +25,13 @@ export const MessageText = (props: Props) => { if (!selected) { return null; } - const { text, direction, status, conversationType, convoId } = selected; + const { text, direction, status, conversationType, convoId, isDeleted } = selected; - const contents = - direction === 'incoming' && status === 'error' ? window.i18n('incomingError') : text; + const contents = isDeleted + ? window.i18n('messageDeletedPlaceholder') + : direction === 'incoming' && status === 'error' + ? window.i18n('incomingError') + : text; if (!contents) { return null; @@ -42,6 +46,7 @@ export const MessageText = (props: Props) => { status === 'error' && direction === 'incoming' ? 'module-message__text--error' : null )} > + {isDeleted && } Promise | void; + onClickClose?: () => any; + + /** + * function to run on close click. Closes modal after execution by default + */ onClickCancel?: () => any; okText?: string; cancelText?: string; @@ -42,6 +52,7 @@ export const SessionConfirm = (props: SessionConfirmDialogProps) => { shouldShowConfirm, onClickCancel, showExitIcon, + closeAfterInput = true, } = props; const [isLoading, setIsLoading] = useState(false); @@ -64,7 +75,9 @@ export const SessionConfirm = (props: SessionConfirmDialogProps) => { } } - window.inboxStore?.dispatch(updateConfirmModal(null)); + if (closeAfterInput) { + window.inboxStore?.dispatch(updateConfirmModal(null)); + } }; if (shouldShowConfirm && !shouldShowConfirm) { diff --git a/ts/components/session/conversation/SessionRecording.tsx b/ts/components/session/conversation/SessionRecording.tsx index 9cf77acc3..2f48c933a 100644 --- a/ts/components/session/conversation/SessionRecording.tsx +++ b/ts/components/session/conversation/SessionRecording.tsx @@ -144,7 +144,7 @@ class SessionRecordingInner extends React.Component { {hasRecording && ( )} diff --git a/ts/data/data.ts b/ts/data/data.ts index 4db4afb9b..1a78167ea 100644 --- a/ts/data/data.ts +++ b/ts/data/data.ts @@ -113,6 +113,7 @@ const channelsToMake = { getMessageBySender, getMessageBySenderAndServerId, getMessageBySenderAndServerTimestamp, + getMessageBySenderAndTimestamp, getMessageIdsFromServerIds, getMessageById, getMessagesBySentAt, @@ -726,6 +727,29 @@ export async function getMessageBySenderAndServerTimestamp({ return new MessageModel(messages[0]); } +/** + * + * @param source senders id + * @param timestamp the timestamp of the message - not to be confused with the serverTimestamp. This is equivalent to sent_at + */ +export async function getMessageBySenderAndTimestamp({ + source, + timestamp, +}: { + source: string; + timestamp: number; +}): Promise { + const messages = await channels.getMessageBySenderAndTimestamp({ + source, + timestamp, + }); + if (!messages || !messages.length) { + return null; + } + + return new MessageModel(messages[0]); +} + export async function getUnreadByConversation(conversationId: string): Promise { const messages = await channels.getUnreadByConversation(conversationId); return new MessageCollection(messages); @@ -833,6 +857,7 @@ export type UnprocessedParameter = { envelope: string; timestamp: number; attempts: number; + messageHash: string; senderIdentity?: string; }; diff --git a/ts/interactions/conversationInteractions.ts b/ts/interactions/conversationInteractions.ts index a981735a3..b3ba51ccf 100644 --- a/ts/interactions/conversationInteractions.ts +++ b/ts/interactions/conversationInteractions.ts @@ -435,12 +435,13 @@ export async function uploadOurAvatar(newAvatarDecrypted?: ArrayBuffer) { } } +// tslint:disable-next-line: max-func-body-length export async function deleteMessagesById( messageIds: Array, conversationId: string, askUserForConfirmation: boolean ) { - const conversationModel = getConversationController().getOrThrow(conversationId); + const conversation = getConversationController().getOrThrow(conversationId); const selectedMessages = _.compact( await Promise.all(messageIds.map(m => getMessageById(m, false))) ); @@ -450,20 +451,20 @@ export async function deleteMessagesById( // In future, we may be able to unsend private messages also // isServerDeletable also defined in ConversationHeader.tsx for // future reference - const isServerDeletable = conversationModel.isPublic(); + const isServerDeletable = conversation.isPublic(); - const doDelete = async () => { + const doDelete = async (deleteForEveryone: boolean = true) => { let toDeleteLocallyIds: Array; + const ourDevicePubkey = UserUtils.getOurPubKeyStrFromCache(); + if (!ourDevicePubkey) { + return; + } + const isAllOurs = selectedMessages.every(message => ourDevicePubkey === message.getSource()); if (isServerDeletable) { + //#region open group v2 deletion // Get our Moderator status - const ourDevicePubkey = UserUtils.getOurPubKeyStrFromCache(); - if (!ourDevicePubkey) { - return; - } - - const isAdmin = conversationModel.isAdmin(ourDevicePubkey); - const isAllOurs = selectedMessages.every(message => ourDevicePubkey === message.getSource()); + const isAdmin = conversation.isAdmin(ourDevicePubkey); if (!isAllOurs && !isAdmin) { ToastUtils.pushMessageDeleteForbidden(); @@ -472,24 +473,38 @@ export async function deleteMessagesById( return; } - toDeleteLocallyIds = await deleteOpenGroupMessages(selectedMessages, conversationModel); + toDeleteLocallyIds = await deleteOpenGroupMessages(selectedMessages, conversation); if (toDeleteLocallyIds.length === 0) { // Message failed to delete from server, show error? return; } + // successful deletion + ToastUtils.pushDeleted(); + window.inboxStore?.dispatch(resetSelectedMessageIds()); + //#endregion } else { - toDeleteLocallyIds = selectedMessages.map(m => m.id as string); - } - - await Promise.all( - toDeleteLocallyIds.map(async msgId => { - await conversationModel.removeMessage(msgId); - }) - ); + //#region deletion for 1-1 and closed groups + if (!isAllOurs) { + ToastUtils.pushMessageDeleteForbidden(); + window.inboxStore?.dispatch(resetSelectedMessageIds()); + return; + } - // Update view and trigger update - window.inboxStore?.dispatch(resetSelectedMessageIds()); - ToastUtils.pushDeleted(); + if (window.lokiFeatureFlags?.useUnsendRequests) { + if (deleteForEveryone) { + void deleteForAll(selectedMessages); + } else { + void deleteForJustThisUser(selectedMessages); + } + } else { + //#region to remove once unsend enabled + await Promise.all(messageIds.map(msgId => conversation.removeMessage(msgId))); + ToastUtils.pushDeleted(); + window.inboxStore?.dispatch(resetSelectedMessageIds()); + //#endregion + } + //#endregion + } }; if (askUserForConfirmation) { @@ -512,31 +527,83 @@ export async function deleteMessagesById( const okText = window.i18n(isServerDeletable ? 'deleteForEveryone' : 'delete'); - const onClickClose = () => { + //#region confirmation for deletion of messages + const showDeletionTypeModal = () => { window.inboxStore?.dispatch(updateConfirmModal(null)); + window.inboxStore?.dispatch( + updateConfirmModal({ + title: window.i18n('deletionTypeTitle'), + okText: window.i18n('deleteMessageForEveryoneLowercase'), + okTheme: SessionButtonColor.Danger, + onClickOk: async () => { + await doDelete(true); + }, + cancelText: window.i18n('deleteJustForMe'), + onClickCancel: async () => { + await doDelete(false); + }, + }) + ); + return; }; - const warningMessage = (() => { - if (isServerDeletable) { - return moreThanOne - ? window.i18n('deleteMultiplePublicWarning') - : window.i18n('deletePublicWarning'); - } - return moreThanOne ? window.i18n('deleteMultipleWarning') : window.i18n('deleteWarning'); - })(); window.inboxStore?.dispatch( updateConfirmModal({ title, - message: warningMessage, + message: window.i18n(moreThanOne ? 'deleteMessagesQuestion' : 'deleteMessageQuestion'), okText, okTheme: SessionButtonColor.Danger, - onClickOk: doDelete, - onClickClose, + onClickOk: async () => { + if (isServerDeletable) { + // unsend logic + await doDelete(true); + // explicity close modal for this case. + window.inboxStore?.dispatch(updateConfirmModal(null)); + } else { + showDeletionTypeModal(); + } + }, + closeAfterInput: false, }) ); + //#endregion } else { void doDelete(); } + + /** + * Deletes messages for everyone in a 1-1 or closed group conversation + * @param msgsToDelete Messages to delete + */ + async function deleteForAll(msgsToDelete: Array) { + window?.log?.warn('Deleting messages for all users in this conversation'); + const result = await conversation.unsendMessages(msgsToDelete); + // TODO: may need to specify deletion for own device as well. + window.inboxStore?.dispatch(resetSelectedMessageIds()); + if (result) { + ToastUtils.pushDeleted(); + } else { + ToastUtils.someDeletionsFailed(); + } + } + + /** + * + * @param toDeleteLocallyIds Messages to delete for just this user. Still sends an unsend message to sync + * with other devices + */ + async function deleteForJustThisUser(msgsToDelete: Array) { + window?.log?.warn('Deleting messages just for this user'); + // is deleting on swarm sufficient or does it need to be unsent as well? + const deleteResult = await conversation.deleteMessages(msgsToDelete); + // Update view and trigger update + window.inboxStore?.dispatch(resetSelectedMessageIds()); + if (deleteResult) { + ToastUtils.pushDeleted(); + } else { + ToastUtils.someDeletionsFailed(); + } + } } export async function replyToMessage(messageId: string) { diff --git a/ts/models/conversation.ts b/ts/models/conversation.ts index 92da64a76..dfc68c9df 100644 --- a/ts/models/conversation.ts +++ b/ts/models/conversation.ts @@ -48,7 +48,8 @@ import { import { ed25519Str } from '../session/onions/onionPath'; import { getDecryptedMediaUrl } from '../session/crypto/DecryptedAttachmentsManager'; import { IMAGE_JPEG } from '../types/MIME'; -import { getLatestTimestampOffset } from '../session/snode_api/SNodeAPI'; +import { UnsendMessage } from '../session/messages/outgoing/controlMessage/UnsendMessage'; +import { getLatestTimestampOffset, networkDeleteMessages } from '../session/snode_api/SNodeAPI'; export enum ConversationTypeEnum { GROUP = 'group', @@ -789,6 +790,129 @@ export class ConversationModel extends Backbone.Model { return null; } } + + /** + * @param messages Messages to delete + */ + public async deleteMessages(messages: Array) { + const results = await Promise.all( + messages.map(async message => { + return this.deleteMessage(message, true); + }) + ); + return _.every(results); + } + + /** + * Deletes message from this device's swarm and handles local deletion of message + * @param message Message to delete + * @param removeFromDatabase delete message from the database entirely or just modify the message data + * @returns boolean if the deletion succeeeded + */ + public async deleteMessage(message: MessageModel, removeFromDatabase = false): Promise { + //#region deletion on network + try { + const deletionMessageHashes = _.compact([message.get('messageHash')]); + if (deletionMessageHashes.length > 0) { + await networkDeleteMessages(deletionMessageHashes); + } + } catch (e) { + window.log?.error('Error deleting message from swarm', e); + return false; + } + //#endregion + + //#region handling database + if (removeFromDatabase) { + // remove the message from the database + await this.removeMessage(message.get('id')); + } else { + // just mark the message as deleted but still show in conversation + await message.markAsDeleted(); + await message.markRead(Date.now()); + this.updateLastMessage(); + } + //#endregion + return true; + } + + public async unsendMessages(messages: Array, onlyDeleteForSender: boolean = false) { + const results = await Promise.all( + messages.map(async message => { + return this.unsendMessage(message, onlyDeleteForSender); + }) + ); + return _.every(results); + } + + /** + * Creates an unsend request using protobuf and adds to messageQueue. + * @param message Message to unsend + */ + public async unsendMessage( + message: MessageModel, + onlyDeleteForSender: boolean = false + ): Promise { + if (!message.get('messageHash')) { + window?.log?.error( + `message with id ${message.get('id')} cannot find hash: ${message.get('messageHash')}` + ); + return false; + } + const ownPrimaryDevicePubkey = UserUtils.getOurPubKeyFromCache(); + + // If deleting just for sender, set destination to sender + const destinationId = onlyDeleteForSender ? ownPrimaryDevicePubkey : this.id; + if (!destinationId) { + return false; + } + //#endregion + + //#region building request + const author = message.get('source'); + + const timestamp = message.getPropsForMessage().timestamp; + if (!timestamp) { + window?.log?.error('cannot find timestamp - aborting unsend request'); + return false; + } + + const unsendParams = { + timestamp, + author, + }; + + const unsendMessage = new UnsendMessage(unsendParams); + //#endregion + + //#region sending + // 1-1 Session + if (!this.isGroup()) { + // sending to recipient + getMessageQueue() + .sendToPubKey(new PubKey(destinationId), unsendMessage) + .catch(window?.log?.error); + return this.deleteMessage(message); + } + + // closed groups + if (this.isClosedGroup() && this.id) { + getMessageQueue() + .sendToGroup(unsendMessage, undefined, PubKey.cast(this.id)) + .catch(window?.log?.error); + // not calling deleteMessage as it'll be called by the unsend handler when it's received + return true; + } + + // open groups + if (this.isOpenGroupV2()) { + window?.log?.info('Conversation is open group. Skipping unsend request.'); + } + + return true; + //#endregion + } + public async sendMessage(msg: SendMessageType) { const { attachments, body, groupInvitation, preview, quote } = msg; this.clearTypingTimers(); @@ -825,6 +949,7 @@ export class ConversationModel extends Backbone.Model { received_at: now, expireTimer, recipients, + isDeleted: false, }); if (!this.isPublic()) { @@ -1334,24 +1459,6 @@ export class ConversationModel extends Backbone.Model { return this.get('type') === 'group'; } - // public deleteContact() { - // let title = window.i18n('delete'); - // let message = window.i18n('deleteContactConfirmation'); - - // if (this.isGroup()) { - // title = window.i18n('leaveGroup'); - // message = window.i18n('leaveGroupConfirmation'); - // } - - // window.confirmationDialog({ - // title, - // message, - // resolve: () => { - // void getConversationController().deleteContact(this.id); - // }, - // }); - // } - public async removeMessage(messageId: any) { await dataRemoveMessage(messageId); this.updateLastMessage(); diff --git a/ts/models/message.ts b/ts/models/message.ts index fb732b1bc..b8126c5af 100644 --- a/ts/models/message.ts +++ b/ts/models/message.ts @@ -527,6 +527,14 @@ export class MessageModel extends Backbone.Model { props.text = this.createNonBreakingLastSeparator(body); } + if (this.get('messageHash')) { + props.messageHash = this.get('messageHash'); + } + + if (this.get('isDeleted')) { + props.isDeleted = this.get('isDeleted'); + } + if (this.get('received_at')) { props.receivedAt = this.get('received_at'); } @@ -850,6 +858,26 @@ export class MessageModel extends Backbone.Model { }; } + /** + * Marks the message as deleted to show the author has deleted this message for everyone. + * Sets isDeleted property to true. Set message body text to deletion placeholder for conversation list items. + */ + public async markAsDeleted() { + this.set({ + isDeleted: true, + body: window.i18n('messageDeleted'), + quote: undefined, + groupInvitation: undefined, + dataExtractionNotification: undefined, + hasAttachments: false, + hasVisualMediaAttachments: false, + attachments: undefined, + preview: undefined, + }); + await this.markRead(Date.now()); + await this.commit(); + } + // One caller today: event handler for the 'Retry Send' entry on right click of a failed send message public async retrySend() { if (!window.textsecure.messaging) { @@ -1006,6 +1034,17 @@ export class MessageModel extends Backbone.Model { return null; } + public async updateMessageHash(messageHash: string) { + if (!messageHash) { + window?.log?.error('Message hash not provided to update message hash'); + } + this.set({ + messageHash, + }); + + await this.commit(); + } + public async sendSyncMessageOnly(dataMessage: DataMessage) { const now = Date.now(); this.set({ diff --git a/ts/models/messageType.ts b/ts/models/messageType.ts index 23da1d7b5..6e7509cdc 100644 --- a/ts/models/messageType.ts +++ b/ts/models/messageType.ts @@ -98,6 +98,16 @@ export interface MessageAttributes { * We display a small message just below the message referenced */ dataExtractionNotification?: DataExtractionNotificationMsg; + + /** + * This field is used for unsending messages and used in sending unsend message requests. + */ + messageHash?: string; + + /** + * This field is used for unsending messages and used in sending unsend message requests. + */ + isDeleted?: boolean; } export interface DataExtractionNotificationMsg { @@ -165,6 +175,8 @@ export interface MessageAttributesOptionals { sync?: boolean; snippet?: any; direction?: any; + messageHash?: string; + isDeleted?: boolean; } /** diff --git a/ts/opengroup/opengroupV2/OpenGroupAPIV2.ts b/ts/opengroup/opengroupV2/OpenGroupAPIV2.ts index 3e7aa6e54..bc31cf2df 100644 --- a/ts/opengroup/opengroupV2/OpenGroupAPIV2.ts +++ b/ts/opengroup/opengroupV2/OpenGroupAPIV2.ts @@ -297,6 +297,9 @@ export const unbanUser = async ( return isOk; }; +/** + * Deletes messages on open group server + */ export const deleteMessageByServerIds = async ( idsToRemove: Array, roomInfos: OpenGroupRequestCommonType diff --git a/ts/receiver/cache.ts b/ts/receiver/cache.ts index 4258713c4..793e5a11a 100644 --- a/ts/receiver/cache.ts +++ b/ts/receiver/cache.ts @@ -20,7 +20,11 @@ export async function removeFromCache(envelope: EnvelopePlus) { return removeUnprocessed(id); } -export async function addToCache(envelope: EnvelopePlus, plaintext: ArrayBuffer) { +export async function addToCache( + envelope: EnvelopePlus, + plaintext: ArrayBuffer, + messageHash: string +) { const { id } = envelope; window?.log?.info(`adding to cache envelope: ${id}`); @@ -29,6 +33,7 @@ export async function addToCache(envelope: EnvelopePlus, plaintext: ArrayBuffer) id, version: 2, envelope: encodedEnvelope, + messageHash, timestamp: Date.now(), attempts: 1, }; diff --git a/ts/receiver/contentMessage.ts b/ts/receiver/contentMessage.ts index b0c4ed234..f5422bc33 100644 --- a/ts/receiver/contentMessage.ts +++ b/ts/receiver/contentMessage.ts @@ -17,8 +17,9 @@ import { ConversationTypeEnum } from '../models/conversation'; import { removeMessagePadding } from '../session/crypto/BufferPadding'; import { perfEnd, perfStart } from '../session/utils/Performance'; import { getAllCachedECKeyPair } from './closedGroups'; +import { getMessageBySenderAndTimestamp } from '../data/data'; -export async function handleContentMessage(envelope: EnvelopePlus) { +export async function handleContentMessage(envelope: EnvelopePlus, messageHash?: string) { try { const plaintext = await decrypt(envelope, envelope.content); @@ -30,7 +31,7 @@ export async function handleContentMessage(envelope: EnvelopePlus) { } perfStart(`innerHandleContentMessage-${envelope.id}`); - await innerHandleContentMessage(envelope, plaintext); + await innerHandleContentMessage(envelope, plaintext, messageHash); perfEnd(`innerHandleContentMessage-${envelope.id}`, 'innerHandleContentMessage'); } catch (e) { window?.log?.warn(e); @@ -323,7 +324,8 @@ function shouldDropBlockedUserMessage(content: SignalService.Content): boolean { export async function innerHandleContentMessage( envelope: EnvelopePlus, - plaintext: ArrayBuffer + plaintext: ArrayBuffer, + messageHash?: string ): Promise { try { perfStart(`SignalService.Content.decode-${envelope.id}`); @@ -354,7 +356,7 @@ export async function innerHandleContentMessage( content.dataMessage.profileKey = null; } perfStart(`handleDataMessage-${envelope.id}`); - await handleDataMessage(envelope, content.dataMessage); + await handleDataMessage(envelope, content.dataMessage, messageHash); perfEnd(`handleDataMessage-${envelope.id}`, 'handleDataMessage'); return; } @@ -394,6 +396,9 @@ export async function innerHandleContentMessage( ); return; } + if (content.unsendMessage && window.lokiFeatureFlags?.useUnsendRequests) { + await handleUnsendMessage(envelope, content.unsendMessage as SignalService.Unsend); + } } catch (e) { window?.log?.warn(e); } @@ -481,6 +486,40 @@ async function handleTypingMessage( } } +/** + * delete message from user swarm and delete locally upon receiving unsend request + * @param unsendMessage data required to delete message + */ +async function handleUnsendMessage(envelope: EnvelopePlus, unsendMessage: SignalService.Unsend) { + const { source: unsendSource } = envelope; + const { author: messageAuthor, timestamp } = unsendMessage; + await removeFromCache(envelope); + + //#region early exit conditions + if (!unsendMessage || !unsendSource) { + window?.log?.error('UnsendMessageHandler:: Invalid parameters -- dropping message.'); + } + if (!timestamp) { + window?.log?.error('UnsendMessageHander:: Invalid timestamp -- dropping message'); + } + const conversation = getConversationController().get(unsendSource); + if (!conversation) { + return; + } + const messageToDelete = await getMessageBySenderAndTimestamp({ + source: messageAuthor, + timestamp: Lodash.toNumber(timestamp), + }); + const messageHash = messageToDelete?.getPropsForMessage().messageHash; + //#endregion + + //#region executing deletion + if (messageHash && messageToDelete) { + await conversation.deleteMessage(messageToDelete); + } + //#endregion +} + /** * A DataExtractionNotification message can only come from a 1 o 1 conversation. * diff --git a/ts/receiver/dataMessage.ts b/ts/receiver/dataMessage.ts index 9e6f590da..aff80bf61 100644 --- a/ts/receiver/dataMessage.ts +++ b/ts/receiver/dataMessage.ts @@ -264,7 +264,8 @@ function isBodyEmpty(body: string) { */ export async function handleDataMessage( envelope: EnvelopePlus, - dataMessage: SignalService.IDataMessage + dataMessage: SignalService.IDataMessage, + messageHash?: string ): Promise { // we handle group updates from our other devices in handleClosedGroupControlMessage() if (dataMessage.closedGroupControlMessage) { @@ -331,6 +332,7 @@ export async function handleDataMessage( timestamp: _.toNumber(envelope.timestamp), receivedAt: envelope.receivedAt, message, + messageHash, }; await handleMessageEvent(ev); // dataMessage @@ -457,6 +459,7 @@ export interface MessageCreationData { // Needed for synced outgoing messages expirationStartTimestamp: any; // ??? destination: string; + messageHash?: string; } export function initIncomingMessage(data: MessageCreationData): MessageModel { @@ -469,6 +472,7 @@ export function initIncomingMessage(data: MessageCreationData): MessageModel { serverId, message, serverTimestamp, + messageHash, } = data; const messageGroupId = message?.group?.id; @@ -490,6 +494,7 @@ export function initIncomingMessage(data: MessageCreationData): MessageModel { direction: 'incoming', // + unread: 1, // + isPublic, // + + messageHash: messageHash || null, }; return new MessageModel(messageData); @@ -508,6 +513,7 @@ function createSentMessage(data: MessageCreationData): MessageModel { expirationStartTimestamp, destination, message, + messageHash, } = data; const sentSpecificFields = { @@ -533,6 +539,7 @@ function createSentMessage(data: MessageCreationData): MessageModel { isPublic, conversationId: groupId ?? destination, type: 'outgoing' as MessageModelType, + messageHash, ...sentSpecificFields, }; @@ -565,7 +572,7 @@ export async function handleMessageEvent(event: MessageEvent): Promise { return; } - const { message, destination } = data; + const { message, destination, messageHash } = data; let { source } = data; @@ -625,6 +632,6 @@ export async function handleMessageEvent(event: MessageEvent): Promise { confirm(); return; } - await handleMessageJob(msg, conversation, message, ourNumber, confirm, source); + await handleMessageJob(msg, conversation, message, ourNumber, confirm, source, messageHash); }); } diff --git a/ts/receiver/queuedJob.ts b/ts/receiver/queuedJob.ts index 904857875..a1847bd8e 100644 --- a/ts/receiver/queuedJob.ts +++ b/ts/receiver/queuedJob.ts @@ -252,7 +252,8 @@ async function handleRegularMessage( message: MessageModel, initialMessage: any, source: string, - ourNumber: string + ourNumber: string, + messageHash: string ) { const { upgradeMessageSchema } = window.Signal.Migrations; @@ -291,6 +292,7 @@ async function handleRegularMessage( body: dataMessage.body, conversationId: conversation.id, decrypted_at: now, + messageHash, errors: [], }); @@ -378,7 +380,8 @@ export async function handleMessageJob( initialMessage: any, ourNumber: string, confirm: () => void, - source: string + source: string, + messageHash: string ) { window?.log?.info( `Starting handleDataMessage for message ${message.idForLogging()}, ${message.get( @@ -402,7 +405,14 @@ export async function handleMessageJob( } await handleExpirationTimerUpdate(conversation, message, source, expireTimer); } else { - await handleRegularMessage(conversation, message, initialMessage, source, ourNumber); + await handleRegularMessage( + conversation, + message, + initialMessage, + source, + ourNumber, + messageHash + ); } const id = await message.commit(); diff --git a/ts/receiver/receiver.ts b/ts/receiver/receiver.ts index 1b3b5bcc6..601a4f1fd 100644 --- a/ts/receiver/receiver.ts +++ b/ts/receiver/receiver.ts @@ -38,15 +38,9 @@ interface ReqOptions { const incomingMessagePromises: Array> = []; -async function handleEnvelope(envelope: EnvelopePlus) { - // TODO: enable below - - // if (this.stoppingProcessing) { - // return Promise.resolve(); - // } - +async function handleEnvelope(envelope: EnvelopePlus, messageHash?: string) { if (envelope.content && envelope.content.length > 0) { - return handleContentMessage(envelope); + return handleContentMessage(envelope, messageHash); } await removeFromCache(envelope); @@ -76,11 +70,11 @@ class EnvelopeQueue { const envelopeQueue = new EnvelopeQueue(); -function queueEnvelope(envelope: EnvelopePlus) { +function queueEnvelope(envelope: EnvelopePlus, messageHash?: string) { const id = getEnvelopeId(envelope); window?.log?.info('queueing envelope', id); - const task = handleEnvelope.bind(null, envelope); + const task = handleEnvelope.bind(null, envelope, messageHash); const taskWithTimeout = createTaskWithTimeout(task, `queueEnvelope ${id}`); try { @@ -98,7 +92,8 @@ function queueEnvelope(envelope: EnvelopePlus) { async function handleRequestDetail( plaintext: Uint8Array, options: ReqOptions, - lastPromise: Promise + lastPromise: Promise, + messageHash: string ): Promise { const envelope: any = SignalService.Envelope.decode(plaintext); @@ -125,6 +120,7 @@ async function handleRequestDetail( envelope.id = envelope.serverGuid || uuidv4(); envelope.serverTimestamp = envelope.serverTimestamp ? envelope.serverTimestamp.toNumber() : null; + envelope.messageHash = messageHash; try { // NOTE: Annoyngly we add plaintext to the cache @@ -132,7 +128,7 @@ async function handleRequestDetail( // need to handle senderIdentity separately)... perfStart(`addToCache-${envelope.id}`); - await addToCache(envelope, plaintext); + await addToCache(envelope, plaintext, messageHash); perfEnd(`addToCache-${envelope.id}`, 'addToCache'); // TODO: This is the glue between the first and the last part of the @@ -142,7 +138,7 @@ async function handleRequestDetail( await lastPromise; - queueEnvelope(envelope); + queueEnvelope(envelope, messageHash); } catch (error) { window?.log?.error( 'handleRequest error trying to add message to cache:', @@ -151,13 +147,13 @@ async function handleRequestDetail( } } -export function handleRequest(body: any, options: ReqOptions): void { +export function handleRequest(body: any, options: ReqOptions, messageHash: string): void { // tslint:disable-next-line no-promise-as-boolean const lastPromise = _.last(incomingMessagePromises) || Promise.resolve(); const plaintext = body; - const promise = handleRequestDetail(plaintext, options, lastPromise).catch(e => { + const promise = handleRequestDetail(plaintext, options, lastPromise, messageHash).catch(e => { window?.log?.error('Error handling incoming message:', e && e.stack ? e.stack : e); void onError(e); @@ -339,6 +335,6 @@ export async function handleOpenGroupV2Message( const msg = createMessage(messageCreationData, !isMe); const ourNumber = UserUtils.getOurPubKeyStrFromCache(); - await handleMessageJob(msg, conversation, decoded?.dataMessage, ourNumber, noop, sender); + await handleMessageJob(msg, conversation, decoded?.dataMessage, ourNumber, noop, sender, ''); }); } diff --git a/ts/session/messages/outgoing/controlMessage/UnsendMessage.ts b/ts/session/messages/outgoing/controlMessage/UnsendMessage.ts new file mode 100644 index 000000000..2fc93ab7a --- /dev/null +++ b/ts/session/messages/outgoing/controlMessage/UnsendMessage.ts @@ -0,0 +1,30 @@ +import { SignalService } from '../../../../protobuf'; +import { ContentMessage } from '../ContentMessage'; +import { MessageParams } from '../Message'; + +interface UnsendMessageParams extends MessageParams { + timestamp: number; + author: string; +} + +export class UnsendMessage extends ContentMessage { + private readonly author: string; + + constructor(params: UnsendMessageParams) { + super({ timestamp: params.timestamp, author: params.author } as MessageParams); + this.author = params.author; + } + + public contentProto(): SignalService.Content { + return new SignalService.Content({ + unsendMessage: this.unsendProto(), + }); + } + + public unsendProto(): SignalService.Unsend { + return new SignalService.Unsend({ + timestamp: this.timestamp, + author: this.author, + }); + } +} diff --git a/ts/session/sending/MessageQueue.ts b/ts/session/sending/MessageQueue.ts index cd87f40cf..e7ffdb4e2 100644 --- a/ts/session/sending/MessageQueue.ts +++ b/ts/session/sending/MessageQueue.ts @@ -20,6 +20,7 @@ import { SyncMessageType } from '../utils/syncUtils'; import { OpenGroupRequestCommonType } from '../../opengroup/opengroupV2/ApiUtil'; import { OpenGroupVisibleMessage } from '../messages/outgoing/visibleMessage/OpenGroupVisibleMessage'; +import { UnsendMessage } from '../messages/outgoing/controlMessage/UnsendMessage'; type ClosedGroupMessageType = | ClosedGroupVisibleMessage @@ -29,6 +30,7 @@ type ClosedGroupMessageType = | ClosedGroupMemberLeftMessage | ExpirationTimerUpdateMessage | ClosedGroupEncryptionPairMessage + | UnsendMessage | ClosedGroupEncryptionPairRequestMessage; // ClosedGroupEncryptionPairReplyMessage must be sent to a user pubkey. Not a group. @@ -43,14 +45,15 @@ export class MessageQueue { } public async sendToPubKey( - user: PubKey, + destinationPubKey: PubKey, message: ContentMessage, - sentCb?: (message: RawMessage) => Promise + sentCb?: (message: RawMessage) => Promise, + isGroup = false ): Promise { if (message instanceof ConfigurationMessage || !!(message as any).syncTarget) { throw new Error('SyncMessage needs to be sent with sendSyncMessage'); } - await this.process(user, message, sentCb); + await this.process(destinationPubKey, message, sentCb, isGroup); } /** @@ -87,18 +90,20 @@ export class MessageQueue { */ public async sendToGroup( message: ClosedGroupMessageType, - sentCb?: (message: RawMessage) => Promise + sentCb?: (message: RawMessage) => Promise, + groupPubKey?: PubKey ): Promise { - let groupId: PubKey | undefined; + let destinationPubKey: PubKey | undefined = groupPubKey; if (message instanceof ExpirationTimerUpdateMessage || message instanceof ClosedGroupMessage) { - groupId = message.groupId; + destinationPubKey = groupPubKey ? groupPubKey : message.groupId; } - if (!groupId) { + if (!destinationPubKey) { throw new Error('Invalid group message passed in sendToGroup.'); } + // if groupId is set here, it means it's for a medium group. So send it as it - return this.sendToPubKey(PubKey.cast(groupId), message, sentCb); + return this.sendToPubKey(PubKey.cast(destinationPubKey), message, sentCb, true); } public async sendSyncMessage( @@ -144,7 +149,11 @@ export class MessageQueue { } } - public async processPending(device: PubKey) { + /** + * processes pending jobs in the message sending queue. + * @param device - target device to send to + */ + public async processPending(device: PubKey, isSyncMessage: boolean = false) { const messages = await this.pendingMessageCache.getForDevice(device); const jobQueue = this.getJobQueue(device); @@ -155,7 +164,12 @@ export class MessageQueue { // We put the event handling inside this job to avoid sending duplicate events const job = async () => { try { - const { wrappedEnvelope, effectiveTimestamp } = await MessageSender.send(message); + const { wrappedEnvelope, effectiveTimestamp } = await MessageSender.send( + message, + undefined, + undefined, + isSyncMessage + ); await MessageSentHandler.handleMessageSentSuccess( message, @@ -196,29 +210,33 @@ export class MessageQueue { * This method should not be called directly. Only through sendToPubKey. */ private async process( - device: PubKey, + destinationPk: PubKey, message: ContentMessage, - sentCb?: (message: RawMessage) => Promise + sentCb?: (message: RawMessage) => Promise, + isGroup = false ): Promise { // Don't send to ourselves const currentDevice = UserUtils.getOurPubKeyFromCache(); - if (currentDevice && device.isEqual(currentDevice)) { + let isSyncMessage = false; + if (currentDevice && destinationPk.isEqual(currentDevice)) { // We allow a message for ourselve only if it's a ConfigurationMessage, a ClosedGroupNewMessage, // or a message with a syncTarget set. + if ( message instanceof ConfigurationMessage || message instanceof ClosedGroupNewMessage || (message as any).syncTarget?.length > 0 ) { window?.log?.warn('Processing sync message'); + isSyncMessage = true; } else { window?.log?.warn('Dropping message in process() to be sent to ourself'); return; } } - await this.pendingMessageCache.add(device, message, sentCb); - void this.processPending(device); + await this.pendingMessageCache.add(destinationPk, message, sentCb, isGroup); + void this.processPending(destinationPk, isSyncMessage); } private getJobQueue(device: PubKey): JobQueue { diff --git a/ts/session/sending/MessageSender.ts b/ts/session/sending/MessageSender.ts index 13fcedb0a..ebe666505 100644 --- a/ts/session/sending/MessageSender.ts +++ b/ts/session/sending/MessageSender.ts @@ -17,7 +17,7 @@ import { storeOnNode } from '../snode_api/SNodeAPI'; import { getSwarmFor } from '../snode_api/snodePool'; import { firstTrue } from '../utils/Promise'; import { MessageSender } from '.'; -import * as Data from '../../../ts/data/data'; +import { getConversationById, getMessageById } from '../../../ts/data/data'; import { SNodeAPI } from '../snode_api'; const DEFAULT_CONNECTIONS = 3; @@ -67,7 +67,8 @@ export function getMinRetryTimeout() { export async function send( message: RawMessage, attempts: number = 3, - retryMinTimeout?: number // in ms + retryMinTimeout?: number, // in ms + isSyncMessage?: boolean ): Promise<{ wrappedEnvelope: Uint8Array; effectiveTimestamp: number }> { return pRetry( async () => { @@ -91,14 +92,21 @@ export async function send( // make sure to update the local sent_at timestamp, because sometimes, we will get the just pushed message in the receiver side // before we return from the await below. // and the isDuplicate messages relies on sent_at timestamp to be valid. - const found = await Data.getMessageById(message.identifier); + const found = await getMessageById(message.identifier); // make sure to not update the send timestamp if this a currently syncing message if (found && !found.get('sentSync')) { found.set({ sent_at: diffTimestamp }); await found.commit(); } - await MessageSender.TEST_sendMessageToSnode(device.key, data, ttl, diffTimestamp); + await MessageSender.TEST_sendMessageToSnode( + device.key, + data, + ttl, + diffTimestamp, + isSyncMessage, + message.identifier + ); return { wrappedEnvelope: data, effectiveTimestamp: diffTimestamp }; }, { @@ -113,7 +121,9 @@ export async function TEST_sendMessageToSnode( pubKey: string, data: Uint8Array, ttl: number, - timestamp: number + timestamp: number, + isSyncMessage?: boolean, + messageId?: string ): Promise { const data64 = window.dcodeIO.ByteBuffer.wrap(data).toString('base64'); const swarm = await getSwarmFor(pubKey); @@ -125,10 +135,13 @@ export async function TEST_sendMessageToSnode( ttl: `${ttl}`, timestamp: `${timestamp}`, data: data64, + isSyncMessage, + messageId, }; const usedNodes = _.slice(swarm, 0, DEFAULT_CONNECTIONS); + let successfulSendHash: any; const promises = usedNodes.map(async usedNode => { // TODO: Revert back to using snode address instead of IP // No pRetry here as if this is a bad path it will be handled and retried in lokiOnionFetch. @@ -136,6 +149,9 @@ export async function TEST_sendMessageToSnode( // but considering we trigger this request with a few snode in //, this should be fine. const successfulSend = await storeOnNode(usedNode, params); if (successfulSend) { + if (_.isString(successfulSend)) { + successfulSendHash = successfulSend; + } return usedNode; } // should we mark snode as bad if it can't store our message? @@ -144,7 +160,10 @@ export async function TEST_sendMessageToSnode( let snode; try { - snode = await firstTrue(promises); + const firstSuccessSnode = await firstTrue(promises); + snode = firstSuccessSnode; + + // console.warn({successHash: }); } catch (e) { const snodeStr = snode ? `${snode.ip}:${snode.port}` : 'null'; window?.log?.warn( @@ -156,6 +175,21 @@ export async function TEST_sendMessageToSnode( throw new window.textsecure.EmptySwarmError(pubKey, 'Ran out of swarm nodes to query'); } + const conversation = await getConversationById(pubKey); + const isClosedGroup = conversation?.isClosedGroup(); + + // If message also has a sync message, save that hash. Otherwise save the hash from the regular message send i.e. only closed groups in this case. + if (messageId && (isSyncMessage || isClosedGroup)) { + const message = await getMessageById(messageId); + if (message) { + await message.updateMessageHash(successfulSendHash); + await message.commit(); + window?.log?.info( + `updated message ${message.get('id')} with hash: ${message.get('messageHash')}` + ); + } + } + window?.log?.info( `loki_message:::sendMessage - Successfully stored message to ${pubKey} via ${snode.ip}:${snode.port}` ); diff --git a/ts/session/sending/MessageSentHandler.ts b/ts/session/sending/MessageSentHandler.ts index c6ee50a7a..342a6be5d 100644 --- a/ts/session/sending/MessageSentHandler.ts +++ b/ts/session/sending/MessageSentHandler.ts @@ -1,5 +1,6 @@ import _ from 'lodash'; import { getMessageById } from '../../data/data'; +import { MessageModel } from '../../models/message'; import { SignalService } from '../../protobuf'; import { PnServer } from '../../pushnotification'; import { OpenGroupVisibleMessage } from '../messages/outgoing/visibleMessage/OpenGroupVisibleMessage'; @@ -39,13 +40,14 @@ export class MessageSentHandler { } } + // tslint:disable-next-line: cyclomatic-complexity public static async handleMessageSentSuccess( sentMessage: RawMessage, effectiveTimestamp: number, wrappedEnvelope?: Uint8Array ) { // The wrappedEnvelope will be set only if the message is not one of OpenGroupV2Message type. - const fetchedMessage = await MessageSentHandler.fetchHandleMessageSentData(sentMessage); + let fetchedMessage = await MessageSentHandler.fetchHandleMessageSentData(sentMessage); if (!fetchedMessage) { return; } @@ -109,6 +111,13 @@ export class MessageSentHandler { dataMessage as SignalService.DataMessage, effectiveTimestamp ); + const tempFetchMessage = await MessageSentHandler.fetchHandleMessageSentData(sentMessage); + if (!tempFetchMessage) { + window?.log?.warn( + 'Got an error while trying to sendSyncMessage(): fetchedMessage is null' + ); + } + fetchedMessage = tempFetchMessage as MessageModel; } catch (e) { window?.log?.warn('Got an error while trying to sendSyncMessage():', e); } diff --git a/ts/session/sending/PendingMessageCache.ts b/ts/session/sending/PendingMessageCache.ts index 37b69b727..470468c2d 100644 --- a/ts/session/sending/PendingMessageCache.ts +++ b/ts/session/sending/PendingMessageCache.ts @@ -39,12 +39,13 @@ export class PendingMessageCache { } public async add( - device: PubKey, + destinationPubKey: PubKey, message: ContentMessage, - sentCb?: (message: any) => Promise + sentCb?: (message: any) => Promise, + isGroup = false ): Promise { await this.loadFromDBIfNeeded(); - const rawMessage = await MessageUtils.toRawMessage(device, message); + const rawMessage = await MessageUtils.toRawMessage(destinationPubKey, message, isGroup); // Does it exist in cache already? if (this.find(rawMessage)) { diff --git a/ts/session/snode_api/SNodeAPI.ts b/ts/session/snode_api/SNodeAPI.ts index d84504faf..5f652585e 100644 --- a/ts/session/snode_api/SNodeAPI.ts +++ b/ts/session/snode_api/SNodeAPI.ts @@ -54,10 +54,13 @@ export type SendParams = { ttl: string; timestamp: string; data: string; + isSyncMessage?: boolean; + messageId?: string; }; -// get snodes for pubkey from random snode. Uses an existing snode - +/** + * get snodes for pubkey from random snode. Uses an existing snode + */ async function requestSnodesForPubkeyWithTargetNodeRetryable( pubKey: string, targetNode: Snode @@ -407,7 +410,10 @@ export async function TEST_getSnodePoolFromSnode(targetNode: Snode): Promise { +export async function storeOnNode( + targetNode: Snode, + params: SendParams +): Promise { try { // no retry here. If an issue is with the path this is handled in lokiOnionFetch // if there is an issue with the targetNode, we still send a few times this request to a few snodes in // already so it's handled @@ -426,6 +432,12 @@ export async function storeOnNode(targetNode: Snode, params: SendParams): Promis try { const parsed = JSON.parse(result.body); handleTimestampOffset('store', parsed.t); + + const messageHash = parsed.hash; + if (messageHash) { + return messageHash; + } + return true; } catch (e) { window?.log?.warn('Failed to parse "store" result: ', e.msg); @@ -666,3 +678,165 @@ export const forceNetworkDeletion = async (): Promise | null> => { // tslint:disable-next-line: variable-name export const TEST_getMinTimeout = () => 500; + +/** + * Locally deletes message and deletes message on the network (all nodes that contain the message) + */ +// tslint:disable-next-line: max-func-body-length +export const networkDeleteMessages = async (hashes: Array): Promise => { + const sodium = await getSodium(); + const userX25519PublicKey = UserUtils.getOurPubKeyStrFromCache(); + + const userED25519KeyPair = await UserUtils.getUserED25519KeyPair(); + + if (!userED25519KeyPair) { + window?.log?.warn('Cannot networkDeleteMessages, did not find user ed25519 key.'); + return null; + } + const edKeyPriv = userED25519KeyPair.privKey; + + try { + const maliciousSnodes = await pRetry( + async () => { + const userSwarm = await getSwarmFor(userX25519PublicKey); + const snodeToMakeRequestTo: Snode | undefined = _.sample(userSwarm); + const edKeyPrivBytes = fromHexToArray(edKeyPriv); + + if (!snodeToMakeRequestTo) { + window?.log?.warn('Cannot networkDeleteMessages, without a valid swarm node.'); + return null; + } + + return pRetry( + async () => { + const verificationData = StringUtils.encode(`delete${hashes.join('')}`, 'utf8'); + const message = new Uint8Array(verificationData); + const signature = sodium.crypto_sign_detached(message, edKeyPrivBytes); + const signatureBase64 = fromUInt8ArrayToBase64(signature); + + const deleteMessageParams = { + pubkey: userX25519PublicKey, + pubkey_ed25519: userED25519KeyPair.pubKey.toUpperCase(), + messages: hashes, + signature: signatureBase64, + }; + const ret = await snodeRpc({ + method: 'delete', + params: deleteMessageParams, + targetNode: snodeToMakeRequestTo, + associatedWith: userX25519PublicKey, + }); + if (!ret) { + throw new Error( + `Empty response got for delete on snode ${ed25519Str( + snodeToMakeRequestTo.pubkey_ed25519 + )}` + ); + } + + try { + const parsedResponse = JSON.parse(ret.body); + const { swarm } = parsedResponse; + + if (!swarm) { + throw new Error( + `Invalid JSON swarm response got for delete on snode ${ed25519Str( + snodeToMakeRequestTo.pubkey_ed25519 + )}, ${ret?.body}` + ); + } + const swarmAsArray = Object.entries(swarm) as Array>; + if (!swarmAsArray.length) { + throw new Error( + `Invalid JSON swarmAsArray response got for delete on snode ${ed25519Str( + snodeToMakeRequestTo.pubkey_ed25519 + )}, ${ret?.body}` + ); + } + // results will only contains the snode pubkeys which returned invalid/empty results + const results: Array = _.compact( + swarmAsArray.map(snode => { + const snodePubkey = snode[0]; + const snodeJson = snode[1]; + + //#region failure handling + const isFailed = snodeJson.failed || false; + + if (isFailed) { + const reason = snodeJson.reason; + const statusCode = snodeJson.code; + if (reason && statusCode) { + window?.log?.warn( + `Could not delete data from ${ed25519Str( + snodeToMakeRequestTo.pubkey_ed25519 + )} due to error: ${reason}: ${statusCode}` + ); + } else { + window?.log?.warn( + `Could not delete data from ${ed25519Str( + snodeToMakeRequestTo.pubkey_ed25519 + )}` + ); + } + return snodePubkey; + } + //#endregion + + //#region verification + const responseHashes = snodeJson.deleted as Array; + const signatureSnode = snodeJson.signature as string; + // The signature looks like ( PUBKEY_HEX || RMSG[0] || ... || RMSG[N] || DMSG[0] || ... || DMSG[M] ) + const dataToVerify = `${userX25519PublicKey}${hashes.join( + '' + )}${responseHashes.join('')}`; + const dataToVerifyUtf8 = StringUtils.encode(dataToVerify, 'utf8'); + const isValid = sodium.crypto_sign_verify_detached( + fromBase64ToArray(signatureSnode), + new Uint8Array(dataToVerifyUtf8), + fromHexToArray(snodePubkey) + ); + if (!isValid) { + return snodePubkey; + } + return null; + //#endregion + }) + ); + + return results; + } catch (e) { + throw new Error( + `Invalid JSON response got for delete on snode ${ed25519Str( + snodeToMakeRequestTo.pubkey_ed25519 + )}, ${ret?.body}` + ); + } + }, + { + retries: 3, + minTimeout: exports.TEST_getMinTimeout(), + onFailedAttempt: e => { + window?.log?.warn( + `delete INNER request attempt #${e.attemptNumber} failed. ${e.retriesLeft} retries left...` + ); + }, + } + ); + }, + { + retries: 3, + minTimeout: exports.TEST_getMinTimeout(), + onFailedAttempt: e => { + window?.log?.warn( + `delete OUTER request attempt #${e.attemptNumber} failed. ${e.retriesLeft} retries left...` + ); + }, + } + ); + + return maliciousSnodes; + } catch (e) { + window?.log?.warn('failed to delete message on network:', e); + return null; + } +}; diff --git a/ts/session/snode_api/swarmPolling.ts b/ts/session/snode_api/swarmPolling.ts index 0b250ed3f..a05a48bdf 100644 --- a/ts/session/snode_api/swarmPolling.ts +++ b/ts/session/snode_api/swarmPolling.ts @@ -30,12 +30,12 @@ interface Message { } // Some websocket nonsense -export function processMessage(message: string, options: any = {}) { +export function processMessage(message: string, options: any = {}, messageHash: string) { try { const dataPlaintext = new Uint8Array(StringUtils.encode(message, 'base64')); const messageBuf = SignalService.WebSocketMessage.decode(dataPlaintext); if (messageBuf.type === SignalService.WebSocketMessage.Type.REQUEST) { - Receiver.handleRequest(messageBuf.request?.body, options); + Receiver.handleRequest(messageBuf.request?.body, options, messageHash); } } catch (error) { const info = { @@ -258,7 +258,7 @@ export class SwarmPolling { newMessages.forEach((m: Message) => { const options = isGroup ? { conversationId: pkStr } : {}; - processMessage(m.data, options); + processMessage(m.data, options, m.hash); }); } diff --git a/ts/session/utils/Messages.ts b/ts/session/utils/Messages.ts index 02302c14d..a73d77d11 100644 --- a/ts/session/utils/Messages.ts +++ b/ts/session/utils/Messages.ts @@ -7,7 +7,10 @@ import { ClosedGroupEncryptionPairReplyMessage } from '../messages/outgoing/cont import { ContentMessage } from '../messages/outgoing'; import { ExpirationTimerUpdateMessage } from '../messages/outgoing/controlMessage/ExpirationTimerUpdateMessage'; -function getEncryptionTypeFromMessageType(message: ContentMessage): EncryptionType { +function getEncryptionTypeFromMessageType( + message: ContentMessage, + isGroup = false +): EncryptionType { // ClosedGroupNewMessage is sent using established channels, so using fallback if ( message instanceof ClosedGroupNewMessage || @@ -20,7 +23,8 @@ function getEncryptionTypeFromMessageType(message: ContentMessage): EncryptionTy // 2. if TypingMessage or ExpirationTimer and groupId is set => must be encoded with ClosedGroup too if ( message instanceof ClosedGroupMessage || - (message instanceof ExpirationTimerUpdateMessage && message.groupId) + (message instanceof ExpirationTimerUpdateMessage && message.groupId) || + isGroup ) { return EncryptionType.ClosedGroup; } else { @@ -28,17 +32,21 @@ function getEncryptionTypeFromMessageType(message: ContentMessage): EncryptionTy } } -export async function toRawMessage(device: PubKey, message: ContentMessage): Promise { +export async function toRawMessage( + destinationPubKey: PubKey, + message: ContentMessage, + isGroup = false +): Promise { const ttl = message.ttl(); const plainTextBuffer = message.plainTextBuffer(); - const encryption = getEncryptionTypeFromMessageType(message); + const encryption = getEncryptionTypeFromMessageType(message, isGroup); // tslint:disable-next-line: no-unnecessary-local-variable const rawMessage: RawMessage = { identifier: message.identifier, plainTextBuffer, - device: device.key, + device: destinationPubKey.key, ttl, encryption, }; diff --git a/ts/session/utils/Toast.tsx b/ts/session/utils/Toast.tsx index 80efe120e..cf3727337 100644 --- a/ts/session/utils/Toast.tsx +++ b/ts/session/utils/Toast.tsx @@ -171,6 +171,10 @@ export function pushYouLeftTheGroup() { pushToastError('youLeftTheGroup', window.i18n('youLeftTheGroup')); } +export function someDeletionsFailed() { + pushToastWarning('deletionError', 'Deletion error'); +} + export function pushDeleted() { pushToastSuccess('deleted', window.i18n('deleted'), undefined, 'check'); } diff --git a/ts/state/ducks/conversations.ts b/ts/state/ducks/conversations.ts index 130b2a81b..789d5974e 100644 --- a/ts/state/ducks/conversations.ts +++ b/ts/state/ducks/conversations.ts @@ -180,6 +180,8 @@ export type PropsForMessageWithoutConvoProps = { messageId?: string; referencedMessageNotFound?: boolean; } | null; + messageHash?: string; + isDeleted?: boolean; isUnread?: boolean; expirationLength?: number; expirationTimestamp?: number | null; @@ -199,6 +201,7 @@ export type PropsForMessageWithConvoProps = PropsForMessageWithoutConvoProps & { isSenderAdmin: boolean; isDeletable: boolean; isBlocked: boolean; + isDeleted?: boolean; }; export type LastMessageType = { diff --git a/ts/state/selectors/conversations.ts b/ts/state/selectors/conversations.ts index ba9de7397..aecbdf0ec 100644 --- a/ts/state/selectors/conversations.ts +++ b/ts/state/selectors/conversations.ts @@ -769,7 +769,7 @@ export const getMessageTextProps = createSelector(getMessagePropsByMessageId, (p return undefined; } - const { conversationType, convoId, direction, status, text } = props.propsForMessage; + const { conversationType, convoId, direction, status, text, isDeleted } = props.propsForMessage; const msgProps: MessageTextSelectorProps = { conversationType, @@ -777,6 +777,7 @@ export const getMessageTextProps = createSelector(getMessagePropsByMessageId, (p direction, status, text, + isDeleted, }; return msgProps; @@ -941,10 +942,11 @@ export const getMessageContentWithStatusesSelectorProps = createSelector( return undefined; } - const { direction } = props.propsForMessage; + const { direction, isDeleted } = props.propsForMessage; const msgProps: MessageContentWithStatusSelectorProps = { direction, + isDeleted, }; return msgProps; @@ -967,6 +969,7 @@ export const getGenericReadableMessageSelectorProps = createSelector( isUnread, receivedAt, isKickedFromGroup, + isDeleted, } = props.propsForMessage; const msgProps: GenericReadableMessageSelectorProps = { @@ -979,6 +982,7 @@ export const getGenericReadableMessageSelectorProps = createSelector( convoId: props.propsForMessage.convoId, receivedAt, isKickedFromGroup, + isDeleted, }; return msgProps; diff --git a/ts/test/session/unit/sending/MessageQueue_test.ts b/ts/test/session/unit/sending/MessageQueue_test.ts index 4fe5dadfb..2699a71b9 100644 --- a/ts/test/session/unit/sending/MessageQueue_test.ts +++ b/ts/test/session/unit/sending/MessageQueue_test.ts @@ -38,7 +38,12 @@ describe('MessageQueue', () => { let messageQueueStub: MessageQueue; // Message Sender Stubs - let sendStub: sinon.SinonStub<[RawMessage, (number | undefined)?, (number | undefined)?]>; + let sendStub: sinon.SinonStub<[ + RawMessage, + (number | undefined)?, + (number | undefined)?, + (boolean | undefined)? + ]>; beforeEach(() => { // Utils Stubs diff --git a/ts/window.d.ts b/ts/window.d.ts index cc453483d..61d1a538f 100644 --- a/ts/window.d.ts +++ b/ts/window.d.ts @@ -47,6 +47,7 @@ declare global { useFileOnionRequestsV2: boolean; padOutgoingAttachments: boolean; enablePinConversations: boolean; + useUnsendRequests: boolean; }; lokiSnodeAPI: LokiSnodeAPI; onLogin: any;