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 <audric@loki.network>
pull/1931/head
Warrick 4 years ago committed by GitHub
parent b17312c13c
commit 1d459ba533
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23

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

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

@ -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) => {

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

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

@ -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'}
/>
<MessageItem
convoId={conversationId}
isTyping={!!isTyping}
unreadCount={unreadCount || 0}
lastMessage={lastMessage}

@ -3,8 +3,9 @@ import React from 'react';
import _ from 'lodash';
import uuid from 'uuid';
import { QuoteClickOptions } from '../../models/messageType';
import autoBind from 'auto-bind';
import { GenericReadableMessage } from './message/GenericReadableMessage';
import { useSelector } from 'react-redux';
import { getGenericReadableMessageSelectorProps } from '../../state/selectors/conversations';
// Same as MIN_WIDTH in ImageGrid.tsx
export const MINIMUM_LINK_PREVIEW_IMAGE_WIDTH = 200;
@ -15,29 +16,26 @@ type Props = {
onQuoteClick?: (options: QuoteClickOptions) => Promise<void>;
};
export class Message extends React.PureComponent<Props> {
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 (
<GenericReadableMessage
onQuoteClick={this.onQuoteClick}
ctxMenuID={this.ctxMenuID}
messageId={this.props.messageId}
isDetailView={this.props.isDetailView}
onQuoteClick={onQuoteClick}
ctxMenuID={ctxMenuID}
messageId={props.messageId}
isDetailView={props.isDetailView}
/>
);
}
private onQuoteClick(quote: QuoteClickOptions) {
void this.props.onQuoteClick?.(quote);
}
}
};

@ -32,6 +32,7 @@ export type GenericReadableMessageSelectorProps = Pick<
| 'isKickedFromGroup'
| 'isExpired'
| 'convoId'
| 'isDeleted'
>;
type ExpiringProps = {

@ -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}
>
<IsMessageVisibleContext.Provider value={isMessageVisible}>
{!isDeleted && (
<>
<MessageQuote messageId={props.messageId} onQuoteClick={props.onQuoteClick} />
<MessageAttachment
messageId={props.messageId}
imageBroken={imageBroken}
handleImageError={handleImageError}
/>
</>
)}
{hasContentAfterAttachmentAndQuote ? (
<>
<MessagePreview messageId={props.messageId} handleImageError={handleImageError} />
<Flex padding="7px" container={true} flexDirection="column">
{!isDeleted && (
<MessagePreview messageId={props.messageId} handleImageError={handleImageError} />
)}
<MessageText messageId={props.messageId} />
</Flex>
</>
) : null}
</IsMessageVisibleContext.Provider>
</InView>

@ -13,7 +13,10 @@ import { MessageContent } from './MessageContent';
import { MessageContextMenu } from './MessageContextMenu';
import { MessageStatus } from './MessageStatus';
export type MessageContentWithStatusSelectorProps = Pick<MessageRenderingProps, 'direction'>;
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) => {
/>
</div>
<MessageStatus messageId={messageId} isCorrectSide={!isIncoming} />
<MessageContextMenu messageId={messageId} contextMenuId={ctxMenuID} />
{!isDeleted && <MessageContextMenu messageId={messageId} contextMenuId={ctxMenuID} />}
</div>
);
};

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

@ -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 && <SessionIcon iconType="delete" iconSize="small" />}
<MessageBody
text={contents || ''}
isGroup={conversationType === 'group'}

@ -13,8 +13,18 @@ export interface SessionConfirmDialogProps {
title?: string;
onOk?: any;
onClose?: any;
closeAfterInput?: boolean;
/**
* function to run on ok click. Closes modal after execution by default
*/
onClickOk?: () => Promise<void> | 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) => {
}
}
if (closeAfterInput) {
window.inboxStore?.dispatch(updateConfirmModal(null));
}
};
if (shouldShowConfirm && !shouldShowConfirm) {

@ -144,7 +144,7 @@ class SessionRecordingInner extends React.Component<Props, State> {
{hasRecording && (
<SessionIconButton
iconType="delete"
iconSize={'medium'}
iconSize="medium"
onClick={this.onDeleteVoiceMessage}
/>
)}

@ -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<MessageModel | null> {
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<MessageCollection> {
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;
};

@ -435,12 +435,13 @@ export async function uploadOurAvatar(newAvatarDecrypted?: ArrayBuffer) {
}
}
// tslint:disable-next-line: max-func-body-length
export async function deleteMessagesById(
messageIds: Array<string>,
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<string>;
if (isServerDeletable) {
// Get our Moderator status
const ourDevicePubkey = UserUtils.getOurPubKeyStrFromCache();
if (!ourDevicePubkey) {
return;
}
const isAdmin = conversationModel.isAdmin(ourDevicePubkey);
const isAllOurs = selectedMessages.every(message => ourDevicePubkey === message.getSource());
if (isServerDeletable) {
//#region open group v2 deletion
// Get our Moderator status
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);
//#region deletion for 1-1 and closed groups
if (!isAllOurs) {
ToastUtils.pushMessageDeleteForbidden();
window.inboxStore?.dispatch(resetSelectedMessageIds());
return;
}
await Promise.all(
toDeleteLocallyIds.map(async msgId => {
await conversationModel.removeMessage(msgId);
})
);
// Update view and trigger update
window.inboxStore?.dispatch(resetSelectedMessageIds());
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<MessageModel>) {
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<MessageModel>) {
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) {

@ -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<ConversationAttributes> {
return null;
}
}
/**
* @param messages Messages to delete
*/
public async deleteMessages(messages: Array<MessageModel>) {
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<boolean> {
//#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<MessageModel>, 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<boolean> {
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<ConversationAttributes> {
received_at: now,
expireTimer,
recipients,
isDeleted: false,
});
if (!this.isPublic()) {
@ -1334,24 +1459,6 @@ export class ConversationModel extends Backbone.Model<ConversationAttributes> {
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();

@ -527,6 +527,14 @@ export class MessageModel extends Backbone.Model<MessageAttributes> {
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<MessageAttributes> {
};
}
/**
* 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<MessageAttributes> {
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({

@ -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;
}
/**

@ -297,6 +297,9 @@ export const unbanUser = async (
return isOk;
};
/**
* Deletes messages on open group server
*/
export const deleteMessageByServerIds = async (
idsToRemove: Array<number>,
roomInfos: OpenGroupRequestCommonType

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

@ -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<void> {
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.
*

@ -264,7 +264,8 @@ function isBodyEmpty(body: string) {
*/
export async function handleDataMessage(
envelope: EnvelopePlus,
dataMessage: SignalService.IDataMessage
dataMessage: SignalService.IDataMessage,
messageHash?: string
): Promise<void> {
// 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<void> {
return;
}
const { message, destination } = data;
const { message, destination, messageHash } = data;
let { source } = data;
@ -625,6 +632,6 @@ export async function handleMessageEvent(event: MessageEvent): Promise<void> {
confirm();
return;
}
await handleMessageJob(msg, conversation, message, ourNumber, confirm, source);
await handleMessageJob(msg, conversation, message, ourNumber, confirm, source, messageHash);
});
}

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

@ -38,15 +38,9 @@ interface ReqOptions {
const incomingMessagePromises: Array<Promise<any>> = [];
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<any>
lastPromise: Promise<any>,
messageHash: string
): Promise<void> {
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, '');
});
}

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

@ -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<void>
sentCb?: (message: RawMessage) => Promise<void>,
isGroup = false
): Promise<void> {
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<void>
sentCb?: (message: RawMessage) => Promise<void>,
groupPubKey?: PubKey
): Promise<void> {
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<void>
sentCb?: (message: RawMessage) => Promise<void>,
isGroup = false
): Promise<void> {
// 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 {

@ -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<void> {
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}`
);

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

@ -39,12 +39,13 @@ export class PendingMessageCache {
}
public async add(
device: PubKey,
destinationPubKey: PubKey,
message: ContentMessage,
sentCb?: (message: any) => Promise<void>
sentCb?: (message: any) => Promise<void>,
isGroup = false
): Promise<RawMessage> {
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)) {

@ -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<Arr
}
}
export async function storeOnNode(targetNode: Snode, params: SendParams): Promise<boolean> {
export async function storeOnNode(
targetNode: Snode,
params: SendParams
): Promise<string | null | boolean> {
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<Array<string> | 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<string>): Promise<any> => {
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<Array<any>>;
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<string> = _.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<string>;
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;
}
};

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

@ -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<RawMessage> {
export async function toRawMessage(
destinationPubKey: PubKey,
message: ContentMessage,
isGroup = false
): Promise<RawMessage> {
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,
};

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

@ -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 = {

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

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

1
ts/window.d.ts vendored

@ -47,6 +47,7 @@ declare global {
useFileOnionRequestsV2: boolean;
padOutgoingAttachments: boolean;
enablePinConversations: boolean;
useUnsendRequests: boolean;
};
lokiSnodeAPI: LokiSnodeAPI;
onLogin: any;

Loading…
Cancel
Save