You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
session-desktop/ts/interactions/conversations/unsendingInteractions.ts

628 lines
22 KiB
TypeScript

import { GroupPubkeyType, PubkeyType } from 'libsession_util_nodejs';
import { compact, isEmpty } from 'lodash';
import { SessionButtonColor } from '../../components/basic/SessionButton';
import { Data } from '../../data/data';
import { ConversationModel } from '../../models/conversation';
import { MessageModel } from '../../models/message';
import { deleteSogsMessageByServerIds } from '../../session/apis/open_group_api/sogsv3/sogsV3DeleteMessages';
import { SnodeAPI } from '../../session/apis/snode_api/SNodeAPI';
import { SnodeNamespaces } from '../../session/apis/snode_api/namespaces';
import { ConvoHub } from '../../session/conversations';
import { getSodiumRenderer } from '../../session/crypto';
import { UnsendMessage } from '../../session/messages/outgoing/controlMessage/UnsendMessage';
import { GroupUpdateDeleteMemberContentMessage } from '../../session/messages/outgoing/controlMessage/group_v2/to_group/GroupUpdateDeleteMemberContentMessage';
import { PubKey } from '../../session/types';
import { ToastUtils, UserUtils } from '../../session/utils';
import { closeRightPanel, resetSelectedMessageIds } from '../../state/ducks/conversations';
import { updateConfirmModal } from '../../state/ducks/modalDialog';
import { resetRightOverlayMode } from '../../state/ducks/section';
import { ed25519Str } from '../../session/utils/String';
import { UserGroupsWrapperActions } from '../../webworker/workers/browser/libsession_worker_interface';
import { NetworkTime } from '../../util/NetworkTime';
import { MessageQueue } from '../../session/sending';
import { WithLocalMessageDeletionType } from '../../session/types/with';
async function unsendMessagesForEveryone1o1AndLegacy(
conversation: ConversationModel,
destination: PubkeyType,
msgsToDelete: Array<MessageModel>
) {
const unsendMsgObjects = getUnsendMessagesObjects1o1OrLegacyGroups(msgsToDelete);
if (conversation.isClosedGroupV2()) {
throw new Error('unsendMessagesForEveryone1o1AndLegacy not compatible with group v2');
}
if (conversation.isPrivate()) {
// sending to recipient all the messages separately for now
await Promise.all(
unsendMsgObjects.map(unsendObject =>
MessageQueue.use()
.sendToPubKey(new PubKey(destination), unsendObject, SnodeNamespaces.Default)
.catch(window?.log?.error)
)
);
await Promise.all(
unsendMsgObjects.map(unsendObject =>
MessageQueue.use()
.sendSyncMessage({ namespace: SnodeNamespaces.Default, message: unsendObject })
.catch(window?.log?.error)
)
);
return;
}
if (conversation.isClosedGroup()) {
// sending to recipient all the messages separately for now
await Promise.all(
unsendMsgObjects.map(unsendObject => {
return MessageQueue.use()
.sendToGroup({
message: unsendObject,
namespace: SnodeNamespaces.LegacyClosedGroup,
groupPubKey: new PubKey(destination),
})
.catch(window?.log?.error);
})
);
}
}
export async function unsendMessagesForEveryoneGroupV2({
allMessagesFrom,
groupPk,
msgsToDelete,
}: {
groupPk: GroupPubkeyType;
msgsToDelete: Array<MessageModel>;
allMessagesFrom: Array<PubkeyType>;
}) {
const messageHashesToUnsend = getMessageHashes(msgsToDelete);
const group = await UserGroupsWrapperActions.getGroup(groupPk);
if (!messageHashesToUnsend.length && !allMessagesFrom.length) {
window.log.info('unsendMessagesForEveryoneGroupV2: no hashes nor author to remove');
return;
}
await MessageQueue.use().sendToGroupV2NonDurably({
message: new GroupUpdateDeleteMemberContentMessage({
createAtNetworkTimestamp: NetworkTime.now(),
expirationType: 'unknown', // GroupUpdateDeleteMemberContentMessage is not displayed so not expiring.
expireTimer: 0,
groupPk,
memberSessionIds: allMessagesFrom,
messageHashes: messageHashesToUnsend,
sodium: await getSodiumRenderer(),
secretKey: group?.secretKey || undefined,
}),
});
}
/**
* Deletes messages for everyone in a 1-1 or everyone in a closed group conversation.
*/
async function unsendMessagesForEveryone(
conversation: ConversationModel,
msgsToDelete: Array<MessageModel>,
{ deletionType }: WithLocalMessageDeletionType
) {
window?.log?.info('Deleting messages for all users in this conversation');
const destinationId = conversation.id as string;
if (!destinationId) {
return;
}
if (conversation.isOpenGroupV2()) {
throw new Error(
'Cannot unsend a message for an opengroup v2. This has to be a deleteMessage api call'
);
}
if (
conversation.isPrivate() ||
(conversation.isClosedGroup() && !conversation.isClosedGroupV2())
) {
if (!PubKey.is05Pubkey(conversation.id)) {
throw new Error('unsendMessagesForEveryone1o1AndLegacy requires a 05 key');
}
await unsendMessagesForEveryone1o1AndLegacy(conversation, conversation.id, msgsToDelete);
} else if (conversation.isClosedGroupV2()) {
if (!PubKey.is03Pubkey(destinationId)) {
throw new Error('invalid conversation id (03) for unsendMessageForEveryone');
}
await unsendMessagesForEveryoneGroupV2({
groupPk: destinationId,
msgsToDelete,
allMessagesFrom: [], // currently we cannot remove all the messages from a specific pubkey but we do already handle them on the receiving side
});
}
if (deletionType === 'complete') {
await deleteMessagesFromSwarmAndCompletelyLocally(conversation, msgsToDelete);
} else {
await deleteMessagesFromSwarmAndMarkAsDeletedLocally(conversation, msgsToDelete);
}
window.inboxStore?.dispatch(resetSelectedMessageIds());
ToastUtils.pushDeleted(msgsToDelete.length);
}
function getUnsendMessagesObjects1o1OrLegacyGroups(messages: Array<MessageModel>) {
// #region building request
return compact(
messages.map(message => {
const author = message.get('source');
// call getPropsForMessage here so we get the received_at or sent_at timestamp in timestamp
const timestamp = message.getPropsForMessage().timestamp;
if (!timestamp) {
window?.log?.error('cannot find timestamp - aborting unsend request');
return undefined;
}
const unsendParams = {
createAtNetworkTimestamp: timestamp,
author,
};
return new UnsendMessage(unsendParams);
})
);
// #endregion
}
function getMessageHashes(messages: Array<MessageModel>) {
return compact(
messages.map(message => {
return message.get('messageHash');
})
);
}
function isStringArray(value: unknown): value is Array<string> {
return Array.isArray(value) && value.every(val => typeof val === 'string');
}
/**
* Do a single request to the swarm with all the message hashes to delete from the swarm.
*
* It does not delete anything locally.
*
* Returns true if no errors happened, false in an error happened
*/
export async function deleteMessagesFromSwarmOnly(
messages: Array<MessageModel> | Array<string>,
pubkey: PubkeyType | GroupPubkeyType
) {
const deletionMessageHashes = isStringArray(messages) ? messages : getMessageHashes(messages);
try {
if (isEmpty(messages)) {
return false;
}
if (!deletionMessageHashes.length) {
window.log?.warn(
'deleteMessagesFromSwarmOnly: We do not have hashes for some of those messages'
);
return false;
}
const hashesAsSet = new Set(deletionMessageHashes);
if (PubKey.is03Pubkey(pubkey)) {
return await SnodeAPI.networkDeleteMessagesForGroup(hashesAsSet, pubkey);
}
return await SnodeAPI.networkDeleteMessageOurSwarm(hashesAsSet, pubkey);
} catch (e) {
window.log?.error(
`deleteMessagesFromSwarmOnly: Error deleting message from swarm of ${ed25519Str(pubkey)}, hashes: ${deletionMessageHashes}`,
e
);
return false;
}
}
/**
* Delete the messages from the swarm with an unsend request and if it worked, delete those messages locally.
* If an error happened, we just return false, Toast an error, and do not remove the messages locally at all.
*/
export async function deleteMessagesFromSwarmAndCompletelyLocally(
conversation: ConversationModel,
messages: Array<MessageModel>
) {
// If this is a private chat, we can only delete messages on our own swarm, so use our "side" of the conversation
const pubkey = conversation.isPrivate() ? UserUtils.getOurPubKeyStrFromCache() : conversation.id;
if (!PubKey.is03Pubkey(pubkey) && !PubKey.is05Pubkey(pubkey)) {
throw new Error('deleteMessagesFromSwarmAndCompletelyLocally needs a 03 or 05 pk');
}
if (PubKey.is05Pubkey(pubkey) && pubkey !== UserUtils.getOurPubKeyStrFromCache()) {
window.log.warn(
'deleteMessagesFromSwarmAndCompletelyLocally with 05 pk can only delete for ourself'
);
return;
}
// LEGACY GROUPS -- we cannot delete on the swarm (just unsend which is done separately)
if (conversation.isClosedGroup() && PubKey.is05Pubkey(pubkey)) {
window.log.info('Cannot delete message from a closed group swarm, so we just complete delete.');
await deletesMessageLocallyOnly({ conversation, messages, deletionType: 'complete' });
return;
}
window.log.info(
'Deleting from swarm of ',
ed25519Str(pubkey),
' hashes: ',
messages.map(m => m.get('messageHash'))
);
const deletedFromSwarm = await deleteMessagesFromSwarmOnly(messages, pubkey);
if (!deletedFromSwarm) {
window.log.warn(
'deleteMessagesFromSwarmAndCompletelyLocally: some messages failed to be deleted. Maybe they were already deleted?'
);
}
await deletesMessageLocallyOnly({ conversation, messages, deletionType: 'complete' });
}
/**
* Delete the messages from the swarm with an unsend request and if it worked, mark those messages locally as deleted but do not remove them.
* If an error happened, we still mark the message locally as deleted.
*/
export async function deleteMessagesFromSwarmAndMarkAsDeletedLocally(
conversation: ConversationModel,
messages: Array<MessageModel>
) {
// legacy groups cannot delete messages on the swarm (just "unsend")
if (conversation.isClosedGroup() && PubKey.is05Pubkey(conversation.id)) {
window.log.info(
'Cannot delete messages from a legacy closed group swarm, so we just markDeleted.'
);
await deletesMessageLocallyOnly({ conversation, messages, deletionType: 'markDeleted' });
return;
}
// we can only delete messages on the swarm when they are on our own swarm, or it is a groupv2 that we are the admin off
const pubkeyToDeleteFrom = PubKey.is03Pubkey(conversation.id)
? conversation.id
: UserUtils.getOurPubKeyStrFromCache();
// if this is a groupv2 and we don't have the admin key, it will fail and return false.
const deletedFromSwarm = await deleteMessagesFromSwarmOnly(messages, pubkeyToDeleteFrom);
if (!deletedFromSwarm) {
window.log.warn(
'deleteMessagesFromSwarmAndMarkAsDeletedLocally: some messages failed to be deleted but still removing the messages content... '
);
}
await deletesMessageLocallyOnly({ conversation, messages, deletionType: 'markDeleted' });
}
/**
* Deletes a message completely or mark it as deleted only. Does not interact with the swarm at all
* @param message Message to delete
* @param deletionType 'complete' means completely delete the item from the database, markDeleted means empty the message content but keep an entry
*/
async function deletesMessageLocallyOnly({
conversation,
messages,
deletionType,
}: WithLocalMessageDeletionType & {
conversation: ConversationModel;
messages: Array<MessageModel>;
}) {
for (let index = 0; index < messages.length; index++) {
const message = messages[index];
if (deletionType === 'complete') {
// remove the message from the database
// eslint-disable-next-line no-await-in-loop
await conversation.removeMessage(message.get('id'));
} else {
// just mark the message as deleted but still show in conversation
// eslint-disable-next-line no-await-in-loop
await message.markAsDeleted();
}
}
conversation.updateLastMessage();
}
/**
* Send an UnsendMessage synced message so our devices removes those messages locally,
* and send an unsend request on our swarm so this message is effectively removed.
*
* Show a toast on error/success and reset the selection
*/
async function unsendMessageJustForThisUser(
conversation: ConversationModel,
msgsToDelete: Array<MessageModel>
) {
window?.log?.warn('Deleting messages just for this user');
const unsendMsgObjects = getUnsendMessagesObjects1o1OrLegacyGroups(msgsToDelete);
// sending to our other devices all the messages separately for now
await Promise.all(
unsendMsgObjects.map(unsendObject =>
MessageQueue.use()
.sendSyncMessage({ namespace: SnodeNamespaces.Default, message: unsendObject })
.catch(window?.log?.error)
)
);
await deleteMessagesFromSwarmAndCompletelyLocally(conversation, msgsToDelete);
// Update view and trigger update
window.inboxStore?.dispatch(resetSelectedMessageIds());
ToastUtils.pushDeleted(unsendMsgObjects.length);
}
const doDeleteSelectedMessagesInSOGS = async (
selectedMessages: Array<MessageModel>,
conversation: ConversationModel,
isAllOurs: boolean
) => {
const ourDevicePubkey = UserUtils.getOurPubKeyStrFromCache();
if (!ourDevicePubkey) {
return;
}
// #region open group v2 deletion
// Get our Moderator status
const isAdmin = conversation.weAreAdminUnblinded();
const isModerator = conversation.isModerator(ourDevicePubkey);
if (!isAllOurs && !(isAdmin || isModerator)) {
ToastUtils.pushMessageDeleteForbidden();
window.inboxStore?.dispatch(resetSelectedMessageIds());
return;
}
const toDeleteLocallyIds = await deleteOpenGroupMessages(selectedMessages, conversation);
if (toDeleteLocallyIds.length === 0) {
// Message failed to delete from server, show error?
return;
}
await Promise.all(
toDeleteLocallyIds.map(async id => {
const msgToDeleteLocally = await Data.getMessageById(id);
if (msgToDeleteLocally) {
return deletesMessageLocallyOnly({
conversation,
messages: [msgToDeleteLocally],
deletionType: 'complete',
});
}
return null;
})
);
// successful deletion
ToastUtils.pushDeleted(toDeleteLocallyIds.length);
window.inboxStore?.dispatch(resetSelectedMessageIds());
// #endregion
};
/**
* Effectively delete the messages from a conversation.
* This call is to be called by the user on a confirmation dialog for instance.
*
* It does what needs to be done on a user action to delete messages for each conversation type
*/
const doDeleteSelectedMessages = async ({
conversation,
selectedMessages,
deleteForEveryone,
}: {
selectedMessages: Array<MessageModel>;
conversation: ConversationModel;
deleteForEveryone: boolean;
}) => {
const ourDevicePubkey = UserUtils.getOurPubKeyStrFromCache();
if (!ourDevicePubkey) {
return;
}
const areAllOurs = selectedMessages.every(message => message.getSource() === ourDevicePubkey);
if (conversation.isPublic()) {
await doDeleteSelectedMessagesInSOGS(selectedMessages, conversation, areAllOurs);
return;
}
// Note: a groupv2 member can delete messages for everyone if they are the admin, or if that message is theirs.
if (deleteForEveryone) {
if (conversation.isClosedGroupV2()) {
const convoId = conversation.id;
if (!PubKey.is03Pubkey(convoId)) {
throw new Error('unsend request for groupv2 but not a 03 key is impossible possible');
}
// only lookup adminKey if we need to
if (!areAllOurs) {
const group = await UserGroupsWrapperActions.getGroup(convoId);
const weHaveAdminKey = !isEmpty(group?.secretKey);
if (!weHaveAdminKey) {
ToastUtils.pushMessageDeleteForbidden();
window.inboxStore?.dispatch(resetSelectedMessageIds());
return;
}
}
// if they are all ours, of not but we are an admin, we can move forward
await unsendMessagesForEveryone(conversation, selectedMessages, {
deletionType: 'markDeleted', // 03 groups: mark as deleted
});
return;
}
if (!areAllOurs) {
ToastUtils.pushMessageDeleteForbidden();
window.inboxStore?.dispatch(resetSelectedMessageIds());
return;
}
await unsendMessagesForEveryone(conversation, selectedMessages, { deletionType: 'complete' }); // not 03 group: delete completely
return;
}
// delete just for me in a groupv2 only means delete locally (not even synced to our other devices)
if (conversation.isClosedGroupV2()) {
await deletesMessageLocallyOnly({
conversation,
messages: selectedMessages,
deletionType: 'markDeleted',
});
return;
}
// delete just for me in a legacy closed group only means delete locally
if (conversation.isClosedGroup()) {
await deleteMessagesFromSwarmAndMarkAsDeletedLocally(conversation, selectedMessages);
// Update view and trigger update
window.inboxStore?.dispatch(resetSelectedMessageIds());
ToastUtils.pushDeleted(selectedMessages.length);
return;
}
// otherwise, delete that message locally, from our swarm and from our other devices
await unsendMessageJustForThisUser(conversation, selectedMessages);
};
/**
* Either delete for everyone or not, based on the props
*/
export async function deleteMessagesForX(
messageIds: Array<string>,
conversationId: string,
/** should only be enforced for messages successfully sent on communities */
enforceDeleteServerSide: boolean
) {
if (conversationId) {
if (enforceDeleteServerSide) {
await deleteMessagesByIdForEveryone(messageIds, conversationId);
} else {
await deleteMessagesById(messageIds, conversationId);
}
}
}
export async function deleteMessagesByIdForEveryone(
messageIds: Array<string>,
conversationId: string
) {
const conversation = ConvoHub.use().getOrThrow(conversationId);
const isMe = conversation.isMe();
const selectedMessages = compact(
await Promise.all(messageIds.map(m => Data.getMessageById(m, false)))
);
const closeDialog = () => window.inboxStore?.dispatch(updateConfirmModal(null));
window.inboxStore?.dispatch(
updateConfirmModal({
title: isMe
? window.i18n('deleteMessageDevicesAll')
: window.i18n('clearMessagesForEveryone'),
i18nMessage: { token: 'deleteMessage', args: { count: selectedMessages.length } },
okText: isMe
? window.i18n('deleteMessageDevicesAll')
: window.i18n('clearMessagesForEveryone'),
okTheme: SessionButtonColor.Danger,
onClickOk: async () => {
await doDeleteSelectedMessages({ selectedMessages, conversation, deleteForEveryone: true });
// explicitly close modal for this case.
closeDialog();
},
onClickCancel: closeDialog,
onClickClose: closeDialog,
closeAfterInput: false,
})
);
}
export async function deleteMessagesById(messageIds: Array<string>, conversationId: string) {
const conversation = ConvoHub.use().getOrThrow(conversationId);
const selectedMessages = compact(
await Promise.all(messageIds.map(m => Data.getMessageById(m, false)))
);
const isMe = conversation.isMe();
const closeDialog = () => window.inboxStore?.dispatch(updateConfirmModal(null));
const clearMessagesForEveryone = 'clearMessagesForEveryone';
window.inboxStore?.dispatch(
updateConfirmModal({
title: window.i18n('deleteMessage', { count: selectedMessages.length }),
radioOptions: !isMe
? [
{
label: window.i18n('clearMessagesForMe'),
value: 'clearMessagesForMe' as const,
inputDataTestId: 'input-deleteJustForMe' as const,
labelDataTestId: 'label-deleteJustForMe' as const,
},
{
label: window.i18n('clearMessagesForEveryone'),
value: clearMessagesForEveryone,
inputDataTestId: 'input-deleteForEveryone' as const,
labelDataTestId: 'label-deleteForEveryone' as const,
},
]
: undefined,
okText: window.i18n('delete'),
okTheme: SessionButtonColor.Danger,
onClickOk: async args => {
await doDeleteSelectedMessages({
selectedMessages,
conversation,
deleteForEveryone: args === clearMessagesForEveryone,
});
window.inboxStore?.dispatch(updateConfirmModal(null));
window.inboxStore?.dispatch(closeRightPanel());
window.inboxStore?.dispatch(resetRightOverlayMode());
},
closeAfterInput: false,
onClickClose: closeDialog,
})
);
}
/**
*
* @param messages the list of MessageModel to delete
* @param convo the conversation to delete from (only v2 opengroups are supported)
*/
async function deleteOpenGroupMessages(
messages: Array<MessageModel>,
convo: ConversationModel
): Promise<Array<string>> {
if (!convo.isPublic()) {
throw new Error('cannot delete public message on a non public groups');
}
const roomInfos = convo.toOpenGroupV2();
// on v2 servers we can only remove a single message per request..
// so logic here is to delete each messages and get which one where not removed
const validServerIdsToRemove = compact(
messages.map(msg => {
return msg.get('serverId');
})
);
const validMessageModelsToRemove = compact(
messages.map(msg => {
const serverId = msg.get('serverId');
if (serverId) {
return msg;
}
return undefined;
})
);
let allMessagesAreDeleted: boolean = false;
if (validServerIdsToRemove.length) {
allMessagesAreDeleted = await deleteSogsMessageByServerIds(validServerIdsToRemove, roomInfos);
}
// remove only the messages we managed to remove on the server
if (allMessagesAreDeleted) {
window?.log?.info('Removed all those serverIds messages successfully');
return validMessageModelsToRemove.map(m => m.id as string);
}
window?.log?.info(
'failed to remove all those serverIds message. not removing them locally neither'
);
return [];
}