From a7799217354883ea520566b91b5b3cc52d7d93f5 Mon Sep 17 00:00:00 2001 From: Audric Ackermann Date: Fri, 20 Feb 2026 17:04:39 +1100 Subject: [PATCH] chore: wip --- ts/components/basic/SessionRadioGroup.tsx | 2 + .../ConversationHeaderSelectionOverlay.tsx | 27 +- .../message-info/OverlayMessageInfo.tsx | 8 +- .../DeleteMessage/DeleteMessageMenuItem.tsx | 42 +- .../useDeleteMessagesCb.tsx | 424 +++++++++++++ ts/hooks/useMessageInteractions.ts | 14 - .../deleteMessagesFromSwarmOnly.ts | 47 ++ .../deleteMessagesLocallyOnly.ts | 32 + .../conversations/unsendingInteractions.ts | 561 +----------------- ts/models/message.ts | 4 +- ts/react.d.ts | 5 +- ts/receiver/contentMessage.ts | 2 +- ts/receiver/groupv2/handleGroupV2Message.ts | 2 +- .../jobs/GroupPendingRemovalsJob.ts | 19 +- ts/state/ducks/conversations.ts | 1 - ts/state/selectors/conversations.ts | 7 - ts/state/selectors/messages.ts | 4 - ts/types/isStringArray.ts | 3 + 18 files changed, 563 insertions(+), 641 deletions(-) create mode 100644 ts/components/menuAndSettingsHooks/useDeleteMessagesCb.tsx create mode 100644 ts/interactions/conversations/deleteMessagesFromSwarmOnly.ts create mode 100644 ts/interactions/conversations/deleteMessagesLocallyOnly.ts create mode 100644 ts/types/isStringArray.ts diff --git a/ts/components/basic/SessionRadioGroup.tsx b/ts/components/basic/SessionRadioGroup.tsx index e9c50212c9..1d118e7fca 100644 --- a/ts/components/basic/SessionRadioGroup.tsx +++ b/ts/components/basic/SessionRadioGroup.tsx @@ -10,6 +10,7 @@ export type SessionRadioItems = Array<{ label: string; inputDataTestId: SessionDataTestId; labelDataTestId: SessionDataTestId; + disabled?: boolean; }>; interface Props { @@ -50,6 +51,7 @@ export const SessionRadioGroup = (props: Props) => { label={item.label} active={itemIsActive} value={item.value} + disabled={item.disabled} inputDataTestId={item.inputDataTestId} labelDataTestId={item.labelDataTestId} inputName={group} diff --git a/ts/components/conversation/header/ConversationHeaderSelectionOverlay.tsx b/ts/components/conversation/header/ConversationHeaderSelectionOverlay.tsx index b5a695f6a5..d266be9a91 100644 --- a/ts/components/conversation/header/ConversationHeaderSelectionOverlay.tsx +++ b/ts/components/conversation/header/ConversationHeaderSelectionOverlay.tsx @@ -3,13 +3,9 @@ import { useSelector } from 'react-redux'; import useKey from 'react-use/lib/useKey'; import { getAppDispatch } from '../../../state/dispatch'; -import { deleteMessagesForX } from '../../../interactions/conversations/unsendingInteractions'; import { resetSelectedMessageIds } from '../../../state/ducks/conversations'; import { getSelectedMessageIds } from '../../../state/selectors/conversations'; -import { - useSelectedConversationKey, - useSelectedIsPublic, -} from '../../../state/selectors/selectedConversation'; +import { useSelectedConversationKey } from '../../../state/selectors/selectedConversation'; import { SessionButton, SessionButtonColor, @@ -21,11 +17,12 @@ import { tr } from '../../../localization/localeTools'; import { SessionLucideIconButton } from '../../icon/SessionIconButton'; import { LUCIDE_ICONS_UNICODE } from '../../icon/lucide'; import { isBackspace, isDeleteKey, isEscapeKey } from '../../../util/keyboardShortcuts'; +import { useDeleteMessagesCb } from '../../menuAndSettingsHooks/useDeleteMessagesCb'; export const SelectionOverlay = () => { const selectedMessageIds = useSelector(getSelectedMessageIds); const selectedConversationKey = useSelectedConversationKey(); - const isPublic = useSelectedIsPublic(); + const deleteMessagesCb = useDeleteMessagesCb(selectedConversationKey); const dispatch = getAppDispatch(); const ref = useRef(null); @@ -51,8 +48,8 @@ export const SelectionOverlay = () => { return true; case 'Backspace': case 'Delete': - if (selectionMode && selectedConversationKey) { - void deleteMessagesForX(selectedMessageIds, selectedConversationKey, isPublic); + if (selectionMode) { + void deleteMessagesCb?.(selectedMessageIds); } return true; default: @@ -61,12 +58,6 @@ export const SelectionOverlay = () => { } ); - // `enforceDeleteServerSide` should check for message statuses too, but when we have multiple selected, - // some might be sent and some in an error state. We default to trying to delete all of them server side first, - // which might fail. If that fails, the user will need to do a delete for all the ones sent already, and a manual delete - // for each ones which is in an error state. - const enforceDeleteServerSide = isPublic; - return ( ref.current} @@ -97,13 +88,7 @@ export const SelectionOverlay = () => { buttonType={SessionButtonType.Solid} text={tr('delete')} onClick={async () => { - if (selectedConversationKey) { - await deleteMessagesForX( - selectedMessageIds, - selectedConversationKey, - enforceDeleteServerSide - ); - } + void deleteMessagesCb?.(selectedMessageIds); }} /> diff --git a/ts/components/conversation/right-panel/overlay/message-info/OverlayMessageInfo.tsx b/ts/components/conversation/right-panel/overlay/message-info/OverlayMessageInfo.tsx index 4b83c69441..306466792d 100644 --- a/ts/components/conversation/right-panel/overlay/message-info/OverlayMessageInfo.tsx +++ b/ts/components/conversation/right-panel/overlay/message-info/OverlayMessageInfo.tsx @@ -17,7 +17,6 @@ import { replyToMessage, resendMessage, } from '../../../../../interactions/conversationInteractions'; -import { deleteMessagesById } from '../../../../../interactions/conversations/unsendingInteractions'; import { useMessageAttachments, useMessageBody, @@ -55,6 +54,7 @@ import { tr } from '../../../../../localization/localeTools'; import { AppDispatch } from '../../../../../state/createStore'; import { useKeyboardShortcut } from '../../../../../hooks/useKeyboardShortcut'; import { KbdShortcut } from '../../../../../util/keyboardShortcuts'; +import { useDeleteMessagesCb } from '../../../../menuAndSettingsHooks/useDeleteMessagesCb'; // NOTE we override the default max-widths when in the detail isDetailView const StyledMessageBody = styled.div` @@ -295,6 +295,8 @@ export const OverlayMessageInfo = () => { const messageId = useMessageId(); const messageInfo = useMessageInfo(messageId); + const selectedConversationKey = useSelectedConversationKey(); + const deleteMessagesCb = useDeleteMessagesCb(selectedConversationKey); const { rightOverlayMode, @@ -430,14 +432,14 @@ export const OverlayMessageInfo = () => { /> )} {/* Deleting messages sends a "delete message" message so it must be disabled for message requests. */} - {isDeletable && !isLegacyGroup && !isIncomingMessageRequest && ( + {isDeletable && !isLegacyGroup && !isIncomingMessageRequest && deleteMessagesCb && ( } color={'var(--danger-color)'} dataTestId="delete-from-details" onClick={() => { - void deleteMessagesById([messageId], convoId); + void deleteMessagesCb?.(messageId); }} /> )} diff --git a/ts/components/menu/items/DeleteMessage/DeleteMessageMenuItem.tsx b/ts/components/menu/items/DeleteMessage/DeleteMessageMenuItem.tsx index c89b921d4f..795197c517 100644 --- a/ts/components/menu/items/DeleteMessage/DeleteMessageMenuItem.tsx +++ b/ts/components/menu/items/DeleteMessage/DeleteMessageMenuItem.tsx @@ -2,21 +2,15 @@ import styled from 'styled-components'; import { isEmpty } from 'lodash'; import useUpdate from 'react-use/lib/useUpdate'; import useInterval from 'react-use/lib/useInterval'; -import { - useMessageIsDeletable, - useMessageIsDeletableForEveryone, -} from '../../../../state/selectors'; -import { - useSelectedConversationKey, - useSelectedIsPublic, -} from '../../../../state/selectors/selectedConversation'; +import { useMessageStatus } from '../../../../state/selectors'; +import { useSelectedConversationKey } from '../../../../state/selectors/selectedConversation'; import { MenuItem } from '../MenuItem'; import { tr } from '../../../../localization/localeTools'; import { LUCIDE_ICONS_UNICODE } from '../../../icon/lucide'; import { DURATION } from '../../../../session/constants'; import { formatAbbreviatedExpireDoubleTimer } from '../../../../util/i18n/formatting/expirationTimer'; import { useMessageExpirationPropsById } from '../../../../hooks/useParamSelector'; -import { useMessageInteractions } from '../../../../hooks/useMessageInteractions'; +import { useDeleteMessagesCb } from '../../../menuAndSettingsHooks/useDeleteMessagesCb'; const StyledDeleteItemContent = styled.span` display: flex; @@ -93,24 +87,34 @@ const ExpiresInItem = ({ messageId }: { messageId: string }) => { return {formatTimeLeft({ timeLeftMs })}; }; -export const DeleteItem = ({ messageId }: { messageId: string }) => { +function useDelete(messageId?: string) { const convoId = useSelectedConversationKey(); - const isPublic = useSelectedIsPublic(); + const messageStatus = useMessageStatus(messageId); + const deleteMessageCb = useDeleteMessagesCb(convoId); + + console.warn('fixme allow ot delete a message to a sogs that failed to send via', messageStatus); + return deleteMessageCb + ? () => { + void deleteMessageCb?.(messageId); + } + : null; +} - const isDeletable = useMessageIsDeletable(messageId); - const isDeletableForEveryone = useMessageIsDeletableForEveryone(messageId); +export const DeleteItem = ({ messageId }: { messageId: string }) => { + const convoId = useSelectedConversationKey(); - const { deleteFromConvo } = useMessageInteractions(messageId); - const onClick = () => { - deleteFromConvo(isPublic, convoId); - }; + const deleteMessagesCb = useDelete(convoId); - if (!convoId || (isPublic && !isDeletableForEveryone) || (!isPublic && !isDeletable)) { + if (!deleteMessagesCb) { return null; } return ( - + {tr('delete')} diff --git a/ts/components/menuAndSettingsHooks/useDeleteMessagesCb.tsx b/ts/components/menuAndSettingsHooks/useDeleteMessagesCb.tsx new file mode 100644 index 0000000000..5e3c94e04b --- /dev/null +++ b/ts/components/menuAndSettingsHooks/useDeleteMessagesCb.tsx @@ -0,0 +1,424 @@ +import type { PubkeyType, GroupPubkeyType } from 'libsession_util_nodejs'; +import { compact, isArray } from 'lodash'; +import { useDispatch } from 'react-redux'; +import { updateConfirmModal } from '../../state/ducks/modalDialog'; +import { useIsMe, useIsPublic, useWeAreAdmin } from '../../hooks/useParamSelector'; +import { SessionButtonColor } from '../basic/SessionButton'; +import { closeRightPanel, resetSelectedMessageIds } from '../../state/ducks/conversations'; +import { tr, type TrArgs } from '../../localization/localeTools'; +import { useWeAreCommunityAdminOrModerator } from '../../state/selectors/conversations'; +import type { ConversationModel } from '../../models/conversation'; +import type { MessageModel } from '../../models/message'; +import { PubKey } from '../../session/types'; +import { ToastUtils, UserUtils } from '../../session/utils'; +import { UserGroupsWrapperActions } from '../../webworker/workers/browser/libsession_worker_interface'; +import { Data } from '../../data/data'; +import { MessageQueue } from '../../session/sending'; +import { + deleteMessagesFromSwarmAndCompletelyLocally, + deleteMessagesFromSwarmAndMarkAsDeletedLocally, +} from '../../interactions/conversations/unsendingInteractions'; +import { deleteSogsMessageByServerIds } from '../../session/apis/open_group_api/sogsv3/sogsV3DeleteMessages'; +import { SnodeNamespaces } from '../../session/apis/snode_api/namespaces'; +import type { SessionRadioItems } from '../basic/SessionRadioGroup'; +import { getSodiumRenderer } from '../../session/crypto'; +import { GroupUpdateDeleteMemberContentMessage } from '../../session/messages/outgoing/controlMessage/group_v2/to_group/GroupUpdateDeleteMemberContentMessage'; +import { UnsendMessage } from '../../session/messages/outgoing/controlMessage/UnsendMessage'; +import { NetworkTime } from '../../util/NetworkTime'; +import { deleteMessagesLocallyOnly } from '../../interactions/conversations/deleteMessagesLocallyOnly'; +import { sectionActions } from '../../state/ducks/section'; +import { ConvoHub } from '../../session/conversations'; +import { uuidV4 } from '../../util/uuid'; + +const deleteMessageDeviceOnly = 'deleteMessageDeviceOnly'; +const deleteMessageDevicesAll = 'deleteMessageDevicesAll'; +const deleteMessageEveryone = 'deleteMessageEveryone'; + +type MessageDeletionType = + | typeof deleteMessageDeviceOnly + | typeof deleteMessageDevicesAll + | typeof deleteMessageEveryone; + +/** + * Offer to delete for everyone or not, based on what is currently selected + * and our role in the corresponding conversation. + * + */ +export function useDeleteMessagesCb(conversationId: string | undefined) { + const dispatch = useDispatch(); + + const isMe = useIsMe(conversationId); + const isPublic = useIsPublic(conversationId); + const weAreAdminOrModCommunity = useWeAreCommunityAdminOrModerator(conversationId); + const weAreAdminGroup = useWeAreAdmin(conversationId); + + const closeDialog = () => dispatch(updateConfirmModal(null)); + + if (!conversationId) { + return null; + } + + return async (messageIds: string | Array | undefined) => { + const count = isArray(messageIds) ? messageIds.length : messageIds ? 1 : 0; + const convo = ConvoHub.use().get(conversationId); + + if (!convo || !messageIds || (!isArray(messageIds) && !messageIds.length)) { + return; + } + const messageIdsArr = isArray(messageIds) ? messageIds : [messageIds]; + + const canDeleteAllForEveryoneAsAdmin = + (isPublic && weAreAdminOrModCommunity) || (!isPublic && weAreAdminGroup); + + const msgModels = await Data.getMessagesById(messageIdsArr); + const senders = compact(msgModels.map(m => m.getSource())); + const us = UserUtils.getOurPubKeyStrFromCache(); + + const anyAreMarkAsDeleted = msgModels.some(m => m.get('isDeleted')); + const anyAreControlMessages = msgModels.some(m => m.isControlMessage()); + + const canDeleteAllForEveryoneAsMe = senders.every(s => s === us); + const canDeleteAllForEveryone = + (canDeleteAllForEveryoneAsMe || canDeleteAllForEveryoneAsAdmin) && + !anyAreControlMessages && + !anyAreMarkAsDeleted; + + // Note: the isMe case has no radio buttons, so we just show the description below + const i18nMessage: TrArgs | undefined = isMe + ? { token: 'deleteMessageDescriptionDevice', count } + : undefined; + + const canDeleteFromAllDevices = isMe && !anyAreControlMessages && !anyAreMarkAsDeleted; + + const radioOptions: SessionRadioItems | undefined = [ + { + label: tr(deleteMessageDeviceOnly), + value: deleteMessageDeviceOnly, + inputDataTestId: `input-${deleteMessageDeviceOnly}` as const, + labelDataTestId: `label-${deleteMessageDeviceOnly}` as const, + disabled: false, // we can always delete message locally + }, + isMe + ? { + label: tr(deleteMessageDevicesAll), + value: deleteMessageDevicesAll, + inputDataTestId: `input-${deleteMessageDevicesAll}` as const, + labelDataTestId: `label-${deleteMessageDevicesAll}` as const, + disabled: !canDeleteFromAllDevices, + } + : { + label: tr(deleteMessageEveryone), + value: deleteMessageEveryone, + inputDataTestId: `input-${deleteMessageEveryone}` as const, + labelDataTestId: `label-${deleteMessageEveryone}` as const, + disabled: !canDeleteAllForEveryone, + }, + ]; + + dispatch( + updateConfirmModal({ + title: tr('deleteMessage', { count }), + radioOptions, + i18nMessage, + okText: tr('delete'), + + okTheme: SessionButtonColor.Danger, + onClickOk: async args => { + if ( + args !== deleteMessageEveryone && + args !== deleteMessageDevicesAll && + args !== deleteMessageDeviceOnly + ) { + throw new Error('doDeleteSelectedMessages: invalid args onClickOk'); + } + + await doDeleteSelectedMessages({ + selectedMessages: msgModels, + conversation: convo, + deletionType: args, + }); + dispatch(updateConfirmModal(null)); + dispatch(closeRightPanel()); + dispatch(sectionActions.resetRightOverlayMode()); + }, + onClickClose: closeDialog, + }) + ); + }; +} + +/** + * Delete the messages from the conversation. + * Also deletes messages from the swarm/sogs if needed, sends unsend requests for syncing etc... + * + * Note: this function does not check if the user is allowed to delete the messages. + * The call will just fail if the user is not allowed to delete the messages, silently. + * So make sure to check the user permissions before calling this function and to display only valid actions for the user's permissions. + * + */ +const doDeleteSelectedMessages = async ({ + conversation, + selectedMessages, + deletionType, +}: { + selectedMessages: Array; + conversation: ConversationModel; + deletionType: MessageDeletionType; +}) => { + // legacy groups are read only + if (conversation.isClosedGroup() && PubKey.is05Pubkey(conversation.id)) { + window.log.info('doDeleteSelectedMessages: legacy groups are deprecated'); + return; + } + + if (deletionType === deleteMessageDeviceOnly) { + // Mark those messages as deleted only locally + await deleteMessagesLocallyOnly({ + conversation, + messages: selectedMessages, + deletionType: 'markDeleted', + }); + ToastUtils.pushDeleted(selectedMessages.length); + + return; + } + + // device only was handled above, so this isPublic can only mean delete for everyone in a community + if (conversation.isOpenGroupV2()) { + await doDeleteSelectedMessagesInSOGS(selectedMessages, conversation); + return; + } + + if (deletionType === deleteMessageDevicesAll) { + // Delete those messages locally, from our swarm and from our other devices, but not for anyone else in the conversation + await unsendMessageJustForThisUserAllDevices(conversation, selectedMessages); + return; + } + + console.warn('FIXME: this is all done but untested'); + // Note: a groupv2 member can delete messages for everyone if they are the admin, or if that message is theirs. + + if (deletionType !== deleteMessageEveryone) { + throw new Error('doDeleteSelectedMessages: invalid deletionType'); + } + + if (conversation.isPrivate()) { + // Note: we cannot delete for everyone a message in non 05-private chat + if (!PubKey.is05Pubkey(conversation.id)) { + throw new Error('unsendMessagesForEveryone1o1AndLegacy requires a 05 key'); + } + // private chats: we want to delete those messages completely (not just marked as deleted) + await unsendMessagesForEveryone1o1(conversation, conversation.id, selectedMessages); + await deleteMessagesFromSwarmAndCompletelyLocally(conversation, selectedMessages); + ToastUtils.pushDeleted(selectedMessages.length); + window.inboxStore?.dispatch(resetSelectedMessageIds()); + + return; + } + + if (!conversation.isClosedGroupV2() || !PubKey.is03Pubkey(conversation.id)) { + // considering the above, the only valid case here is 03 groupv2 + throw new Error('doDeleteSelectedMessages: invalid conversation type'); + } + + await unsendMessagesForEveryoneGroupV2({ + groupPk: conversation.id, + msgsToDelete: selectedMessages, + allMessagesFrom: [], // currently we cannot remove all the messages from a specific pubkey but we do already handle them on the receiving side + }); + + // 03 groups: mark as deleted + await deleteMessagesFromSwarmAndMarkAsDeletedLocally(conversation, selectedMessages); + + window.inboxStore?.dispatch(resetSelectedMessageIds()); + ToastUtils.pushDeleted(selectedMessages.length); +}; + +/** + * 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 from it. + * Then, deletes completely the messages locally. + * + * Show a toast on error/success and reset the selection + */ +async function unsendMessageJustForThisUserAllDevices( + conversation: ConversationModel, + msgsToDelete: Array +) { + 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); +} + +/** + * Attempt to delete the messages from the SOGS. + * Note: this function does not check if the user is allowed to delete the messages. + * The call will just fail if the user is not allowed to delete the messages, silently, so make + * sure to check the user permissions before calling this function and to display only valid actions for the user's permissions. + */ +async function doDeleteSelectedMessagesInSOGS( + selectedMessages: Array, + conversation: ConversationModel +) { + const toDeleteLocallyIds = await deleteOpenGroupMessages(selectedMessages, conversation); + if (toDeleteLocallyIds.length === 0) { + // Failed to delete those messages from the sogs. + ToastUtils.pushToastError('errorGeneric', tr('errorGeneric')); + return; + } + + await deleteMessagesLocallyOnly({ + conversation, + messages: selectedMessages, + deletionType: 'complete', + }); + + // successful deletion + ToastUtils.pushDeleted(toDeleteLocallyIds.length); + window.inboxStore?.dispatch(resetSelectedMessageIds()); +} + +/** + * + * @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, + convo: ConversationModel +): Promise> { + if (!convo.isOpenGroupV2()) { + 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); + } + window?.log?.info( + 'failed to remove all those serverIds message. not removing them locally neither' + ); + return []; +} + +async function unsendMessagesForEveryone1o1( + conversation: ConversationModel, + destination: PubkeyType, + msgsToDelete: Array +) { + const unsendMsgObjects = getUnsendMessagesObjects1o1OrLegacyGroups(msgsToDelete); + + if (!conversation.isPrivate()) { + throw new Error('unsendMessagesForEveryone1o1 only works with private conversations'); + } + + // sending to recipient all the messages separately for now + console.warn('fixme sending to recipient all the messages separately for'); + 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) + ) + ); +} + +async function unsendMessagesForEveryoneGroupV2({ + allMessagesFrom, + groupPk, + msgsToDelete, +}: { + groupPk: GroupPubkeyType; + msgsToDelete: Array; + allMessagesFrom: Array; +}) { + const messageHashesToUnsend = compact(msgsToDelete.map(m => m.getMessageHash())); + 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, + dbMessageIdentifier: uuidV4(), + }), + }); +} + +function getUnsendMessagesObjects1o1OrLegacyGroups(messages: Array) { + return compact( + messages.map((message, index) => { + const author = message.get('source'); + + // call getPropsForMessage here so we get the received_at or sent_at timestamp in timestamp + const referencedMessageTimestamp = message.getPropsForMessage().timestamp; + if (!referencedMessageTimestamp) { + window?.log?.error('cannot find timestamp - aborting unsend request'); + return undefined; + } + + return new UnsendMessage({ + // this isn't pretty, but we need a unique timestamp for Android to not drop the message as a duplicate + createAtNetworkTimestamp: NetworkTime.now() + index, + referencedMessageTimestamp, + author, + dbMessageIdentifier: uuidV4(), + }); + }) + ); +} diff --git a/ts/hooks/useMessageInteractions.ts b/ts/hooks/useMessageInteractions.ts index af0e75b34e..dbb5d7ea1e 100644 --- a/ts/hooks/useMessageInteractions.ts +++ b/ts/hooks/useMessageInteractions.ts @@ -15,11 +15,9 @@ import { useMessageBody, useMessageSender, useMessageServerTimestamp, - useMessageStatus, useMessageTimestamp, } from '../state/selectors'; import { saveAttachmentToDisk } from '../util/attachment/attachmentsUtil'; -import { deleteMessagesForX } from '../interactions/conversations/unsendingInteractions'; function useSaveAttachemnt(messageId?: string) { const convoId = useSelectedConversationKey(); @@ -79,16 +77,6 @@ function useReply(messageId?: string) { }; } -function useDelete(messageId?: string) { - const messageStatus = useMessageStatus(messageId); - return (isPublic: boolean, convoId?: string) => { - if (convoId && messageId) { - const enforceDeleteServerSide = isPublic && messageStatus !== 'error'; - void deleteMessagesForX([messageId], convoId, enforceDeleteServerSide); - } - }; -} - export function useMessageInteractions(messageId?: string | null) { const dispatch = getAppDispatch(); @@ -96,7 +84,6 @@ export function useMessageInteractions(messageId?: string | null) { const saveAttachment = useSaveAttachemnt(messageId ?? undefined); const reply = useReply(messageId ?? undefined); - const deleteFromConvo = useDelete(messageId ?? undefined); const select = () => { if (!messageId) { @@ -120,6 +107,5 @@ export function useMessageInteractions(messageId?: string | null) { reply, select, reactToMessage, - deleteFromConvo, }; } diff --git a/ts/interactions/conversations/deleteMessagesFromSwarmOnly.ts b/ts/interactions/conversations/deleteMessagesFromSwarmOnly.ts new file mode 100644 index 0000000000..fcfec51854 --- /dev/null +++ b/ts/interactions/conversations/deleteMessagesFromSwarmOnly.ts @@ -0,0 +1,47 @@ +import type { PubkeyType, GroupPubkeyType } from 'libsession_util_nodejs'; +import { compact, isEmpty } from 'lodash'; +import type { MessageModel } from '../../models/message'; +import { SnodeAPI } from '../../session/apis/snode_api/SNodeAPI'; +import { PubKey } from '../../session/types'; +import { ed25519Str } from '../../session/utils/String'; +import { isStringArray } from '../../types/isStringArray'; + +/** + * Do a single request to the swarm with all the message hashes to delete from the swarm. + * Does not delete anything locally. + * Should only be used when we are deleting a + * + * Returns true if no errors happened, false in an error happened + */ +export async function deleteMessagesFromSwarmOnly( + messages: Array | Array, + pubkey: PubkeyType | GroupPubkeyType +) { + const deletionMessageHashes = isStringArray(messages) + ? messages + : compact(messages.map(m => m.getMessageHash())); + + 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; + } +} diff --git a/ts/interactions/conversations/deleteMessagesLocallyOnly.ts b/ts/interactions/conversations/deleteMessagesLocallyOnly.ts new file mode 100644 index 0000000000..74f82075bc --- /dev/null +++ b/ts/interactions/conversations/deleteMessagesLocallyOnly.ts @@ -0,0 +1,32 @@ +import type { ConversationModel } from '../../models/conversation'; +import type { MessageModel } from '../../models/message'; +import type { WithLocalMessageDeletionType } from '../../session/types/with'; + +/** + * Deletes a message completely or mark it as deleted. 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 + */ +export async function deleteMessagesLocallyOnly({ + conversation, + messages, + deletionType, +}: WithLocalMessageDeletionType & { + conversation: ConversationModel; + messages: Array; +}) { + 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.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(); +} diff --git a/ts/interactions/conversations/unsendingInteractions.ts b/ts/interactions/conversations/unsendingInteractions.ts index b737a42dde..6bd246ec00 100644 --- a/ts/interactions/conversations/unsendingInteractions.ts +++ b/ts/interactions/conversations/unsendingInteractions.ts @@ -1,216 +1,11 @@ -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 { UserUtils } from '../../session/utils'; 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'; -import { tr, type TrArgs } from '../../localization/localeTools'; -import { uuidV4 } from '../../util/uuid'; - -async function unsendMessagesForEveryone1o1AndLegacy( - conversation: ConversationModel, - destination: PubkeyType, - msgsToDelete: Array -) { - 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()) { - // legacy groups are readonly - } -} - -export async function unsendMessagesForEveryoneGroupV2({ - allMessagesFrom, - groupPk, - msgsToDelete, -}: { - groupPk: GroupPubkeyType; - msgsToDelete: Array; - allMessagesFrom: Array; -}) { - 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, - dbMessageIdentifier: uuidV4(), - }), - }); -} - -/** - * Deletes messages for everyone in a 1-1 or everyone in a closed group conversation. - */ -async function unsendMessagesForEveryone( - conversation: ConversationModel, - msgsToDelete: Array, - { deletionType }: WithLocalMessageDeletionType -) { - window?.log?.info('Deleting messages for all users in this conversation'); - const destinationId = conversation.id; - 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) { - // #region building request - return compact( - messages.map((message, index) => { - const author = message.get('source'); - - // call getPropsForMessage here so we get the received_at or sent_at timestamp in timestamp - const referencedMessageTimestamp = message.getPropsForMessage().timestamp; - if (!referencedMessageTimestamp) { - window?.log?.error('cannot find timestamp - aborting unsend request'); - return undefined; - } - - return new UnsendMessage({ - // this isn't pretty, but we need a unique timestamp for Android to not drop the message as a duplicate - createAtNetworkTimestamp: NetworkTime.now() + index, - referencedMessageTimestamp, - author, - dbMessageIdentifier: uuidV4(), - }); - }) - ); - // #endregion -} - -function getMessageHashes(messages: Array) { - return compact( - messages.map(message => { - return message.get('messageHash'); - }) - ); -} - -function isStringArray(value: unknown): value is Array { - 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 | Array, - 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; - } -} +import { deleteMessagesLocallyOnly } from './deleteMessagesLocallyOnly'; +import { deleteMessagesFromSwarmOnly } from './deleteMessagesFromSwarmOnly'; /** * Delete the messages from the swarm with an unsend request and if it worked, delete those messages locally. @@ -231,10 +26,10 @@ export async function deleteMessagesFromSwarmAndCompletelyLocally( ); return; } - // LEGACY GROUPS -- we cannot delete on the swarm (just unsend which is done separately) + // LEGACY GROUPS are deprecated if (conversation.isClosedGroup() && PubKey.is05Pubkey(pubkey)) { - window.log.info('Cannot delete message from a closed group swarm, so we just complete delete.'); - await deleteMessagesLocallyOnly({ conversation, messages, deletionType: 'complete' }); + window.log.info('legacy groups are deprecated.'); + return; } window.log.info( @@ -253,19 +48,16 @@ export async function deleteMessagesFromSwarmAndCompletelyLocally( } /** - * 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. + * Delete the messages from the swarm with an unsend request and 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 ) { - // legacy groups cannot delete messages on the swarm (just "unsend") + // legacy groups are deprecated if (conversation.isClosedGroup() && PubKey.is05Pubkey(conversation.id)) { - window.log.info( - 'Cannot delete messages from a legacy closed group swarm, so we just markDeleted.' - ); - await deleteMessagesLocallyOnly({ conversation, messages, deletionType: 'markDeleted' }); + window.log.info('legacy groups are deprecated. Not deleting anything'); return; } @@ -284,338 +76,3 @@ export async function deleteMessagesFromSwarmAndMarkAsDeletedLocally( } await deleteMessagesLocallyOnly({ 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 deleteMessagesLocallyOnly({ - conversation, - messages, - deletionType, -}: WithLocalMessageDeletionType & { - conversation: ConversationModel; - messages: Array; -}) { - 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.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 -) { - 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, - 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 deleteMessagesLocallyOnly({ - 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; - conversation: ConversationModel; - deleteForEveryone: boolean; -}) => { - const ourDevicePubkey = UserUtils.getOurPubKeyStrFromCache(); - if (!ourDevicePubkey) { - return; - } - - const areAllOurs = selectedMessages.every(message => message.getSource() === ourDevicePubkey); - if (conversation.isOpenGroupV2()) { - 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 deleteMessagesLocallyOnly({ - conversation, - messages: selectedMessages, - deletionType: 'markDeleted', - }); - ToastUtils.pushDeleted(selectedMessages.length); - - 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, - 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, - 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 ? tr('deleteMessageDevicesAll') : tr('clearMessagesForEveryone'), - i18nMessage: { token: 'deleteMessageConfirm', count: selectedMessages.length }, - okText: isMe ? tr('deleteMessageDevicesAll') : tr('clearMessagesForEveryone'), - okTheme: SessionButtonColor.Danger, - onClickOk: async () => { - await doDeleteSelectedMessages({ selectedMessages, conversation, deleteForEveryone: true }); - - // explicitly close modal for this case. - closeDialog(); - }, - onClickCancel: closeDialog, - onClickClose: closeDialog, - }) - ); -} - -export async function deleteMessagesById(messageIds: Array, 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 count = messageIds.length; - - const closeDialog = () => window.inboxStore?.dispatch(updateConfirmModal(null)); - const clearMessagesForEveryone = 'clearMessagesForEveryone'; - - // Note: the isMe case has no radio buttons, so we just show the description below - const i18nMessage: TrArgs | undefined = isMe - ? { token: 'deleteMessageDescriptionDevice', count } - : undefined; - - window.inboxStore?.dispatch( - updateConfirmModal({ - title: tr('deleteMessage', { count: selectedMessages.length }), - radioOptions: !isMe - ? [ - { - label: tr('clearMessagesForMe'), - value: 'clearMessagesForMe' as const, - inputDataTestId: 'input-deleteJustForMe' as const, - labelDataTestId: 'label-deleteJustForMe' as const, - }, - { - label: tr('clearMessagesForEveryone'), - value: clearMessagesForEveryone, - inputDataTestId: 'input-deleteForEveryone' as const, - labelDataTestId: 'label-deleteForEveryone' as const, - }, - ] - : undefined, - i18nMessage, - okText: tr('delete'), - okTheme: SessionButtonColor.Danger, - onClickOk: async args => { - await doDeleteSelectedMessages({ - selectedMessages, - conversation, - deleteForEveryone: args === clearMessagesForEveryone, - }); - window.inboxStore?.dispatch(updateConfirmModal(null)); - window.inboxStore?.dispatch(closeRightPanel()); - }, - 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, - convo: ConversationModel -): Promise> { - if (!convo.isOpenGroupV2()) { - 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); - } - window?.log?.info( - 'failed to remove all those serverIds message. not removing them locally neither' - ); - return []; -} diff --git a/ts/models/message.ts b/ts/models/message.ts index bc9d51eb4c..c2bc19d756 100644 --- a/ts/models/message.ts +++ b/ts/models/message.ts @@ -189,7 +189,9 @@ export class MessageModel extends Model { this.isExpirationTimerUpdate() || this.isDataExtractionNotification() || this.isMessageRequestResponse() || - this.isGroupUpdate() + this.isGroupUpdate() || + this.isCallNotification() || + this.isInteractionNotification() ); } diff --git a/ts/react.d.ts b/ts/react.d.ts index 694147eddd..27082506dc 100644 --- a/ts/react.d.ts +++ b/ts/react.d.ts @@ -220,8 +220,9 @@ declare module 'react' { type InputLabels = | 'device_and_network' | 'device_only' - | 'deleteForEveryone' - | 'deleteJustForMe' + | 'deleteMessageEveryone' + | 'deleteMessageDevicesAll' + | 'deleteMessageDeviceOnly' | 'enterForSend' | 'enterForNewLine' | 'message' diff --git a/ts/receiver/contentMessage.ts b/ts/receiver/contentMessage.ts index 7e865340e1..40a6f04ed2 100644 --- a/ts/receiver/contentMessage.ts +++ b/ts/receiver/contentMessage.ts @@ -491,7 +491,7 @@ async function handleUnsendMessage( return; } if (messageToDelete.getSource() === UserUtils.getOurPubKeyStrFromCache()) { - // a message we sent is completely removed when we get a unsend request + // a message we sent is completely removed when we get a unsend request for it void deleteMessagesFromSwarmAndCompletelyLocally(conversation, [messageToDelete]); } else { void deleteMessagesFromSwarmAndMarkAsDeletedLocally(conversation, [messageToDelete]); diff --git a/ts/receiver/groupv2/handleGroupV2Message.ts b/ts/receiver/groupv2/handleGroupV2Message.ts index c4dce91d5c..36bde266ee 100644 --- a/ts/receiver/groupv2/handleGroupV2Message.ts +++ b/ts/receiver/groupv2/handleGroupV2Message.ts @@ -2,7 +2,6 @@ import { GroupPubkeyType, PubkeyType, WithGroupPubkey } from 'libsession_util_no import { isEmpty, isFinite, isNumber } from 'lodash'; import { Data } from '../../data/data'; import { deleteAllMessagesByConvoIdNoConfirmation } from '../../interactions/conversationInteractions'; -import { deleteMessagesFromSwarmOnly } from '../../interactions/conversations/unsendingInteractions'; import { CONVERSATION_PRIORITIES, ConversationTypeEnum } from '../../models/types'; import { HexString } from '../../node/hexStrings'; import { SignalService } from '../../protobuf'; @@ -27,6 +26,7 @@ import { UserGroupsWrapperActions, } from '../../webworker/workers/browser/libsession_worker_interface'; import { sendInviteResponseToGroup } from '../../session/sending/group/GroupInviteResponse'; +import { deleteMessagesFromSwarmOnly } from '../../interactions/conversations/deleteMessagesFromSwarmOnly'; type WithSignatureTimestamp = { signatureTimestamp: number }; type WithAuthor = { author: PubkeyType }; diff --git a/ts/session/utils/job_runners/jobs/GroupPendingRemovalsJob.ts b/ts/session/utils/job_runners/jobs/GroupPendingRemovalsJob.ts index 1aa850ca37..67f34a648b 100644 --- a/ts/session/utils/job_runners/jobs/GroupPendingRemovalsJob.ts +++ b/ts/session/utils/job_runners/jobs/GroupPendingRemovalsJob.ts @@ -4,7 +4,7 @@ import { compact, isEmpty, isNumber } from 'lodash'; import AbortController from 'abort-controller'; import { StringUtils } from '../..'; import { Data } from '../../../../data/data'; -import { deleteMessagesFromSwarmOnly } from '../../../../interactions/conversations/unsendingInteractions'; +import { deleteMessagesFromSwarmAndMarkAsDeletedLocally } from '../../../../interactions/conversations/unsendingInteractions'; import { MetaGroupWrapperActions, MultiEncryptWrapperActions, @@ -227,20 +227,9 @@ class GroupPendingRemovalsJob extends PersistedJob m.getMessageHash())); - if (messageHashes.length) { - await deleteMessagesFromSwarmOnly(messageHashes, groupPk); - } - for (let index = 0; index < models.length; index++) { - const messageModel = models[index]; - try { - // eslint-disable-next-line no-await-in-loop - await messageModel.markAsDeleted(); - } catch (e) { - window.log.warn( - `GroupPendingRemoval markAsDeleted of ${messageModel.getMessageHash()} failed with`, - e.message - ); - } + const convo = models?.[0].getConversation(); + if (convo && messageHashes.length) { + await deleteMessagesFromSwarmAndMarkAsDeletedLocally(convo, models); } } } catch (e) { diff --git a/ts/state/ducks/conversations.ts b/ts/state/ducks/conversations.ts index 89e3cd74b6..3c8a1fc17a 100644 --- a/ts/state/ducks/conversations.ts +++ b/ts/state/ducks/conversations.ts @@ -181,7 +181,6 @@ export type PropsForMessageWithConvoProps = PropsForMessageWithoutConvoProps & { weAreAdmin: boolean; isSenderAdmin: boolean; isDeletable: boolean; - isDeletableForEveryone: boolean; isBlocked: boolean; isDeleted?: boolean; }; diff --git a/ts/state/selectors/conversations.ts b/ts/state/selectors/conversations.ts index 4d5388e023..8d4dde003e 100644 --- a/ts/state/selectors/conversations.ts +++ b/ts/state/selectors/conversations.ts @@ -754,12 +754,6 @@ export const getMessagePropsByMessageId = createSelector( const isDeletable = sender === ourPubkey || !isPublic || (isPublic && (weAreAdmin || weAreModerator)); - // A message is deletable for everyone if - // either we sent it no matter what the conversation type, - // or the convo is public and we are an admin or moderator - const isDeletableForEveryone = - sender === ourPubkey || (isPublic && (weAreAdmin || weAreModerator)) || false; - const isSenderAdmin = groupAdmins.includes(sender); const messageProps: MessageModelPropsWithConvoProps = { @@ -770,7 +764,6 @@ export const getMessagePropsByMessageId = createSelector( isPublic: !!isPublic, isSenderAdmin, isDeletable, - isDeletableForEveryone, weAreAdmin, conversationType: selectedConvo.type, sender, diff --git a/ts/state/selectors/messages.ts b/ts/state/selectors/messages.ts index 7006263523..d7f71d6aff 100644 --- a/ts/state/selectors/messages.ts +++ b/ts/state/selectors/messages.ts @@ -130,10 +130,6 @@ export function useMessageSender(messageId: string | undefined) { return useMessagePropsByMessageId(messageId)?.propsForMessage.sender; } -export function useMessageIsDeletableForEveryone(messageId: string | undefined) { - return useMessagePropsByMessageId(messageId)?.propsForMessage.isDeletableForEveryone; -} - export function useMessageServerTimestamp(messageId: string | undefined) { return useMessagePropsByMessageId(messageId)?.propsForMessage.serverTimestamp; } diff --git a/ts/types/isStringArray.ts b/ts/types/isStringArray.ts new file mode 100644 index 0000000000..03d9b60e68 --- /dev/null +++ b/ts/types/isStringArray.ts @@ -0,0 +1,3 @@ +export function isStringArray(value: unknown): value is Array { + return Array.isArray(value) && value.every(val => typeof val === 'string'); +}