diff --git a/ts/components/basic/SessionRadioGroup.tsx b/ts/components/basic/SessionRadioGroup.tsx index fa79464484..377b33069b 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 af004d7aa9..19501586ba 100644 --- a/ts/components/conversation/header/ConversationHeaderSelectionOverlay.tsx +++ b/ts/components/conversation/header/ConversationHeaderSelectionOverlay.tsx @@ -1,13 +1,9 @@ import { useDispatch, useSelector } from 'react-redux'; import useKey from 'react-use/lib/useKey'; -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, @@ -18,13 +14,15 @@ import { SessionFocusTrap } from '../../SessionFocusTrap'; import { tr } from '../../../localization/localeTools'; import { SessionLucideIconButton } from '../../icon/SessionIconButton'; import { LUCIDE_ICONS_UNICODE } from '../../icon/lucide'; +import { useDeleteMessagesCb } from '../../menuAndSettingsHooks/useDeleteMessagesCb'; export const SelectionOverlay = () => { const selectedMessageIds = useSelector(getSelectedMessageIds); const selectedConversationKey = useSelectedConversationKey(); - const isPublic = useSelectedIsPublic(); const dispatch = useDispatch(); + const deleteMessagesCb = useDeleteMessagesCb(selectedConversationKey); + function onCloseOverlay() { dispatch(resetSelectedMessageIds()); } @@ -51,8 +49,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 +59,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; - const classNameAndId = 'message-selection-overlay'; return ( @@ -88,14 +80,8 @@ export const SelectionOverlay = () => { buttonShape={SessionButtonShape.Square} buttonType={SessionButtonType.Solid} text={tr('delete')} - onClick={async () => { - if (selectedConversationKey) { - await deleteMessagesForX( - selectedMessageIds, - selectedConversationKey, - enforceDeleteServerSide - ); - } + onClick={() => { + 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 b33e163cff..8ad6f9f9a2 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, useMessageAuthor, @@ -54,6 +53,7 @@ import { useShowCopyAccountIdCb } from '../../../../menuAndSettingsHooks/useCopy import { sectionActions } from '../../../../../state/ducks/section'; import { useIsIncomingRequest } from '../../../../../hooks/useParamSelector'; import { tr } from '../../../../../localization/localeTools'; +import { useDeleteMessagesCb } from '../../../../menuAndSettingsHooks/useDeleteMessagesCb'; // NOTE we override the default max-widths when in the detail isDetailView const StyledMessageBody = styled.div` @@ -289,6 +289,8 @@ export const OverlayMessageInfo = () => { } }, [sender, closePanel]); + const deleteMessagesCb = useDeleteMessagesCb(convoId); + if (!rightOverlayMode || !messageInfo || !convoId || !messageId || !sender) { return null; } @@ -404,14 +406,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 a079e04e38..6ca05e845d 100644 --- a/ts/components/menu/items/DeleteMessage/DeleteMessageMenuItem.tsx +++ b/ts/components/menu/items/DeleteMessage/DeleteMessageMenuItem.tsx @@ -1,36 +1,24 @@ -import { useCallback } from 'react'; -import { deleteMessagesForX } from '../../../../interactions/conversations/unsendingInteractions'; -import { - useMessageIsDeletable, - useMessageIsDeletableForEveryone, - useMessageStatus, -} from '../../../../state/selectors'; -import { - useSelectedConversationKey, - useSelectedIsPublic, -} from '../../../../state/selectors/selectedConversation'; +import { useSelectedConversationKey } from '../../../../state/selectors/selectedConversation'; import { ItemWithDataTestId } from '../MenuItemWithDataTestId'; import { tr } from '../../../../localization/localeTools'; +import { useDeleteMessagesCb } from '../../../menuAndSettingsHooks/useDeleteMessagesCb'; export const DeleteItem = ({ messageId }: { messageId: string }) => { const convoId = useSelectedConversationKey(); - const isPublic = useSelectedIsPublic(); - const isDeletable = useMessageIsDeletable(messageId); - const isDeletableForEveryone = useMessageIsDeletableForEveryone(messageId); - const messageStatus = useMessageStatus(messageId); + const deleteMessagesCb = useDeleteMessagesCb(convoId); - const enforceDeleteServerSide = isPublic && messageStatus !== 'error'; - - const onDelete = useCallback(() => { - if (convoId) { - void deleteMessagesForX([messageId], convoId, enforceDeleteServerSide); - } - }, [convoId, enforceDeleteServerSide, messageId]); - - if (!convoId || (isPublic && !isDeletableForEveryone) || (!isPublic && !isDeletable)) { + if (!deleteMessagesCb) { return null; } - return {tr('delete')}; + return ( + { + void deleteMessagesCb?.(messageId); + }} + > + {tr('delete')} + + ); }; diff --git a/ts/components/menuAndSettingsHooks/useDeleteMessagesCb.tsx b/ts/components/menuAndSettingsHooks/useDeleteMessagesCb.tsx new file mode 100644 index 0000000000..32c4ea132b --- /dev/null +++ b/ts/components/menuAndSettingsHooks/useDeleteMessagesCb.tsx @@ -0,0 +1,421 @@ +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 type { LocalizerProps } from '../basic/Localizer'; +import { SessionButtonColor } from '../basic/SessionButton'; +import { closeRightPanel, resetSelectedMessageIds } from '../../state/ducks/conversations'; +import { tr } 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 { useNetworkStakedTokens } from '../../state/selectors/networkData'; + +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: LocalizerProps | undefined = isMe + ? { token: 'deleteMessageDescriptionDevice', args: { 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.isPublic()) { + 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; + } + + note: 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.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); + } + 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 + 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, + }), + }); +} + +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, + }); + }) + ); +} 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 f272ac1a46..6bd246ec00 100644 --- a/ts/interactions/conversations/unsendingInteractions.ts +++ b/ts/interactions/conversations/unsendingInteractions.ts @@ -1,215 +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 } from '../../localization/localeTools'; -import { sectionActions } from '../../state/ducks/section'; -import type { LocalizerProps } from '../../components/basic/Localizer'; - -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, - }), - }); -} - -/** - * 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, - }); - }) - ); - // #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. @@ -230,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( @@ -252,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; } @@ -283,339 +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.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 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', args: { 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: LocalizerProps | undefined = isMe - ? { token: 'deleteMessageDescriptionDevice', args: { 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()); - window.inboxStore?.dispatch(sectionActions.resetRightOverlayMode()); - }, - 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.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); - } - 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 d76385ae23..6361899ea2 100644 --- a/ts/models/message.ts +++ b/ts/models/message.ts @@ -183,7 +183,9 @@ export class MessageModel extends Model { this.isExpirationTimerUpdate() || this.isDataExtractionNotification() || this.isMessageRequestResponse() || - this.isGroupUpdate() + this.isGroupUpdate() || + this.isCallNotification() || + this.isInteractionNotification() ); } diff --git a/ts/models/messageType.ts b/ts/models/messageType.ts index 43d3f7cc32..aed236934e 100644 --- a/ts/models/messageType.ts +++ b/ts/models/messageType.ts @@ -54,7 +54,6 @@ export interface MessageAttributes { * You can use the values from READ_MESSAGE_STATE.unread and READ_MESSAGE_STATE.read */ unread: number; - group?: any; /** * timestamp is the sent_at timestamp, which is the envelope.timestamp */ diff --git a/ts/react.d.ts b/ts/react.d.ts index 169a836237..cbb0c413cc 100644 --- a/ts/react.d.ts +++ b/ts/react.d.ts @@ -75,6 +75,18 @@ declare module 'react' { // left pane section types type Sections = 'theme' | 'settings' | 'message' | 'privacy' | 'debug-menu'; + type RadioOptions = + | 'device_and_network' + | 'device_only' + | 'deleteMessageEveryone' + | 'deleteMessageDevicesAll' + | 'deleteMessageDeviceOnly' + | 'enterForSend' + | 'enterForNewLine' + | 'message' + | 'name' + | 'count'; + type SettingsMenuItems = | 'message-requests' | 'recovery-password' @@ -223,24 +235,8 @@ declare module 'react' { // SessionRadioGroup & SessionRadio | 'password-input-confirm' | 'msg-status' - | 'input-device_and_network' - | 'label-device_and_network' - | 'input-device_only' - | 'label-device_only' - | 'input-deleteForEveryone' - | 'label-deleteForEveryone' - | 'input-deleteJustForMe' - | 'label-deleteJustForMe' - | 'input-enterForSend' - | 'label-enterForSend' - | 'input-enterForNewLine' - | 'label-enterForNewLine' - | 'input-message' - | 'label-message' - | 'input-name' - | 'label-name' - | 'input-count' - | 'label-count' + | `input-${RadioOptions}` + | `label-${RadioOptions}` | 'clear-everyone-radio-option' | 'clear-device-radio-option' | 'clear-everyone-radio-option-label' diff --git a/ts/receiver/contentMessage.ts b/ts/receiver/contentMessage.ts index f9039dfbf1..d0568a7ffb 100644 --- a/ts/receiver/contentMessage.ts +++ b/ts/receiver/contentMessage.ts @@ -676,7 +676,7 @@ async function handleUnsendMessage(envelope: EnvelopePlus, unsendMessage: Signal 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 735fcf0731..7fc6813643 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 ce4f65d6ab..c9d4e062ec 100644 --- a/ts/session/utils/job_runners/jobs/GroupPendingRemovalsJob.ts +++ b/ts/session/utils/job_runners/jobs/GroupPendingRemovalsJob.ts @@ -5,7 +5,7 @@ import { v4 } from 'uuid'; 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, @@ -226,20 +226,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 39500d2b9a..862ed28682 100644 --- a/ts/state/ducks/conversations.ts +++ b/ts/state/ducks/conversations.ts @@ -186,7 +186,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 b955d21cdf..2f3357e6e5 100644 --- a/ts/state/selectors/conversations.ts +++ b/ts/state/selectors/conversations.ts @@ -738,12 +738,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 = { @@ -754,7 +748,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 79bf875dc1..75741a7035 100644 --- a/ts/state/selectors/messages.ts +++ b/ts/state/selectors/messages.ts @@ -123,9 +123,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'); +}