diff --git a/app/src/main/java/org/thoughtcrime/securesms/attachments/DatabaseAttachmentProvider.kt b/app/src/main/java/org/thoughtcrime/securesms/attachments/DatabaseAttachmentProvider.kt index ac213edc97..88fd235687 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/attachments/DatabaseAttachmentProvider.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/attachments/DatabaseAttachmentProvider.kt @@ -241,7 +241,6 @@ class DatabaseAttachmentProvider(context: Context, helper: SQLCipherOpenHelper) val messagingDatabase: MessagingDatabase = if (isSms) DatabaseComponent.get(context).smsDatabase() else DatabaseComponent.get(context).mmsDatabase() - //todo DELETION can this be batched? messages.forEach { message -> messagingDatabase.markAsDeleted(message.messageId, message.isOutgoing, displayedMessage) } diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationActivityV2.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationActivityV2.kt index 55755cbd7a..bc537bd063 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationActivityV2.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationActivityV2.kt @@ -845,7 +845,11 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe finish() } + // show or hide the text input binding.inputBar.isGone = uiState.hideInputBar + + // show or hide loading indicator + binding.loader.isVisible = uiState.showLoader } } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationV2Dialogs.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationV2Dialogs.kt index 587b078c67..5b81962f47 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationV2Dialogs.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationV2Dialogs.kt @@ -10,24 +10,24 @@ import androidx.compose.ui.res.pluralStringResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp -import com.squareup.phrase.Phrase import network.loki.messenger.R -import network.loki.messenger.libsession_util.util.ExpiryMode -import org.session.libsession.utilities.StringSubstitutionConstants.APP_NAME_KEY -import org.thoughtcrime.securesms.conversation.disappearingmessages.ExpiryType -import org.thoughtcrime.securesms.ui.OpenURLAlertDialog -import org.thoughtcrime.securesms.ui.theme.SessionMaterialTheme -import org.thoughtcrime.securesms.conversation.v2.ConversationViewModel.Commands.* -import org.thoughtcrime.securesms.database.model.MessageRecord +import org.thoughtcrime.securesms.conversation.v2.ConversationViewModel.Commands.HideDeleteAllDevicesDialog +import org.thoughtcrime.securesms.conversation.v2.ConversationViewModel.Commands.HideDeleteDeviceOnlyDialog +import org.thoughtcrime.securesms.conversation.v2.ConversationViewModel.Commands.HideDeleteEveryoneDialog +import org.thoughtcrime.securesms.conversation.v2.ConversationViewModel.Commands.MarkAsDeletedForEveryone +import org.thoughtcrime.securesms.conversation.v2.ConversationViewModel.Commands.MarkAsDeletedLocally +import org.thoughtcrime.securesms.conversation.v2.ConversationViewModel.Commands.ShowOpenUrlDialog +import org.thoughtcrime.securesms.conversation.v2.ConversationViewModel.DeleteForEveryoneMessageType.NoteToSelf import org.thoughtcrime.securesms.ui.AlertDialog import org.thoughtcrime.securesms.ui.DialogButtonModel import org.thoughtcrime.securesms.ui.GetString +import org.thoughtcrime.securesms.ui.OpenURLAlertDialog import org.thoughtcrime.securesms.ui.RadioOption import org.thoughtcrime.securesms.ui.components.TitledRadioButton import org.thoughtcrime.securesms.ui.theme.LocalColors import org.thoughtcrime.securesms.ui.theme.LocalDimensions import org.thoughtcrime.securesms.ui.theme.PreviewTheme -import kotlin.time.Duration.Companion.days +import org.thoughtcrime.securesms.ui.theme.SessionMaterialTheme @Composable fun ConversationV2Dialogs( @@ -125,7 +125,9 @@ fun ConversationV2Dialogs( onClick = { // delete messages based on chosen option sendCommand( - if(deleteForEveryone) MarkAsDeletedForEveryone(dialogsState.deleteEveryone.messages) + if(deleteForEveryone) MarkAsDeletedForEveryone( + dialogsState.deleteEveryone.copy(defaultToEveryone = deleteForEveryone) + ) else MarkAsDeletedLocally(dialogsState.deleteEveryone.messages) ) } @@ -139,7 +141,7 @@ fun ConversationV2Dialogs( // delete message(s) for all my devices if(dialogsState.deleteAllDevices != null){ - var deleteAllDevices by remember { mutableStateOf(false) } + var deleteAllDevices by remember { mutableStateOf(dialogsState.deleteAllDevices.defaultToEveryone) } AlertDialog( onDismissRequest = { @@ -148,8 +150,8 @@ fun ConversationV2Dialogs( }, title = pluralStringResource( R.plurals.deleteMessage, - dialogsState.deleteAllDevices.size, - dialogsState.deleteAllDevices.size + dialogsState.deleteAllDevices.messages.size, + dialogsState.deleteAllDevices.messages.size ), text = stringResource(R.string.deleteMessageConfirm), //todo DELETION we need the plural version of this here, which currently is not set up in strings content = { @@ -188,8 +190,10 @@ fun ConversationV2Dialogs( onClick = { // delete messages based on chosen option sendCommand( - if(deleteAllDevices) MarkAsDeletedForEveryone(dialogsState.deleteAllDevices) - else MarkAsDeletedLocally(dialogsState.deleteAllDevices) + if(deleteAllDevices) MarkAsDeletedForEveryone( + dialogsState.deleteAllDevices.copy(defaultToEveryone = deleteAllDevices) + ) + else MarkAsDeletedLocally(dialogsState.deleteAllDevices.messages) ) } ), diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationViewModel.kt index 7e40132f9d..6e367dca01 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationViewModel.kt @@ -199,17 +199,13 @@ class ConversationViewModel( // Refer to our figma document for info on message deletion [https://www.figma.com/design/kau6LggVcMMWmZRMibEo8F/Standardise-Message-Deletion?node-id=0-1&t=dEPcU0SZ9G2s4gh2-0] - //todo DELETION delete for everyone - - //todo DELETION delete all my devices - //todo DELETION handle control messages deletion ( and make clickable ) //todo DELETION handle multi select scenarios //todo DELETION check that the unread status works as expected when deleting a message - //todo DELETION handle errors: Toasts for errors, or deleting messages not fully sent yet + //todo DELETION handle deleting messages not fully sent yet (failed or sending states) viewModelScope.launch(Dispatchers.IO) { val allSentByCurrentUser = messages.all { it.isOutgoing } @@ -255,7 +251,12 @@ class ConversationViewModel( // the conversation is a note to self conversation.isLocalNumber -> { _dialogsState.update { - it.copy(deleteAllDevices = messages) + it.copy(deleteAllDevices = DeleteForEveryoneDialogData( + messages = messages, + defaultToEveryone = false, + messageType = DeleteForEveryoneMessageType.NoteToSelf + ) + ) } } @@ -265,7 +266,14 @@ class ConversationViewModel( it.copy( deleteEveryone = DeleteForEveryoneDialogData( messages = messages, - defaultToEveryone = isAdmin + defaultToEveryone = isAdmin, + messageType = when{ + conversation.isLocalNumber -> DeleteForEveryoneMessageType.NoteToSelf + conversation.isCommunityRecipient -> DeleteForEveryoneMessageType.Community + conversation.isClosedGroupRecipient -> DeleteForEveryoneMessageType.LegacyGroup //todo GROUPS V2 this property will change for groups v2. Check for legacyGroup here + //conversation.isClosedGroup -> DeleteForEveryoneMessageType.GroupV2(isAdmin) //todo GROUPS V2 properly check for GroupV2 type here once available + else -> DeleteForEveryoneMessageType.OneOnOne + } ) ) } @@ -282,11 +290,6 @@ class ConversationViewModel( } } - private fun isUserCommunityManager() = openGroup?.let { openGroup -> - val userPublicKey = textSecurePreferences.getLocalNumber() ?: return@let false - OpenGroupManager.isUserModerator(application, openGroup.id, userPublicKey, blindedPublicKey) - } ?: false - /** * This will delete these messages from the db * Not to be confused with 'marking messages as deleted' @@ -322,23 +325,6 @@ class ConversationViewModel( ).show() } - /** - * Stops audio player if its current playing is the one given in the message. - */ - private fun stopMessageAudio(message: MessageRecord) { - val mmsMessage = message as? MmsMessageRecord ?: return - val audioSlide = mmsMessage.slideDeck.audioSlide ?: return - stopMessageAudio(audioSlide) - } - private fun stopMessageAudio(audioSlide: AudioSlide) { - AudioSlidePlayer.getInstance()?.takeIf { it.audioSlide == audioSlide }?.stop() - } - - fun setRecipientApproved() { - val recipient = recipient ?: return Log.w("Loki", "Recipient was null for set approved action") - repository.setApproved(recipient, true) - } - /** * This will mark the messages as deleted, for everyone. * Attachments and other related data will be removed from the db, @@ -346,24 +332,237 @@ class ConversationViewModel( * Instead they will appear as a special type of message * that says something like "This message was deleted" */ - private fun markAsDeletedForEveryone(messages: Set) = viewModelScope.launch { + private fun markAsDeletedForEveryone( + data: DeleteForEveryoneDialogData + ) = viewModelScope.launch { val recipient = recipient ?: return@launch Log.w("Loki", "Recipient was null for delete for everyone - aborting delete operation.") // make sure to stop audio messages, if any - messages.filterIsInstance() + data.messages.filterIsInstance() .mapNotNull { it.slideDeck.audioSlide } .forEach(::stopMessageAudio) - /*repository.markAsDeletedForEveryone(threadId, recipient, messages) - .onSuccess { - Log.d("Loki", "Deleted messages $messages ") + // the exact logic for this will depend on the messages type + when(data.messageType){ + is DeleteForEveryoneMessageType.NoteToSelf -> markAsDeletedForEveryoneNoteToSelf(data) + is DeleteForEveryoneMessageType.OneOnOne -> markAsDeletedForEveryone1On1(data) + is DeleteForEveryoneMessageType.LegacyGroup -> markAsDeletedForEveryoneLegacyGroup(data.messages) + is DeleteForEveryoneMessageType.GroupV2 -> markAsDeletedForEveryoneGroupsV2(data) + is DeleteForEveryoneMessageType.Community -> markAsDeletedForEveryoneCommunity(data) + } + } + + private fun markAsDeletedForEveryoneNoteToSelf(data: DeleteForEveryoneDialogData){ + viewModelScope.launch(Dispatchers.IO) { + // show a loading indicator + _uiState.update { it.copy(showLoader = true) } + + // delete remotely + try { + //todo DELETION need to delete remotely for note to self + repository.deleteCommunityMessagesRemotely(threadId, data.messages) + + //todo DELETION send unsendRequest to own swarm + + // When this is done we simply need to remove the message locally + repository.markAsDeletedLocally( + messages = data.messages, + displayedMessage = application.getString(R.string.deleteMessageDeletedGlobally) + ) + + // show confirmation toast + Toast.makeText( + application, + application.resources.getQuantityString( + R.plurals.deleteMessageDeleted, + data.messages.count(), + data.messages.count() + ), + Toast.LENGTH_SHORT + ).show() + } catch (e: Exception) { + Log.w("Loki", "FAILED TO delete messages ${data.messages} ") + // failed to delete - show a toast and get back on the modal + Toast.makeText( + application, + application.resources.getQuantityString( + R.plurals.deleteMessageFailed, + data.messages.size, + data.messages.size + ), Toast.LENGTH_SHORT + ).show() + + _dialogsState.update { it.copy(deleteEveryone = data) } } - .onFailure { - Log.w("Loki", "FAILED TO delete messages $messages ") - showMessage( - application.resources.getQuantityString(R.plurals.deleteMessageFailed, messages.size, messages.size) + + // hide loading indicator + _uiState.update { it.copy(showLoader = false) } + } + } + + private fun markAsDeletedForEveryone1On1(data: DeleteForEveryoneDialogData){ + if(recipient == null) return showMessage(application.getString(R.string.errorUnknown)) + + viewModelScope.launch(Dispatchers.IO) { + // show a loading indicator + _uiState.update { it.copy(showLoader = true) } + + // delete remotely + try { + repository.delete1on1MessagesRemotely(threadId, recipient!!, data.messages) + + // When this is done we simply need to remove the message locally + repository.markAsDeletedLocally( + messages = data.messages, + displayedMessage = application.getString(R.string.deleteMessageDeletedGlobally) + ) + + // show confirmation toast + Toast.makeText( + application, + application.resources.getQuantityString( + R.plurals.deleteMessageDeleted, + data.messages.count(), + data.messages.count() + ), + Toast.LENGTH_SHORT + ).show() + } catch (e: Exception) { + Log.w("Loki", "FAILED TO delete messages ${data.messages} ") + // failed to delete - show a toast and get back on the modal + Toast.makeText( + application, + application.resources.getQuantityString( + R.plurals.deleteMessageFailed, + data.messages.size, + data.messages.size + ), Toast.LENGTH_SHORT + ).show() + + _dialogsState.update { it.copy(deleteEveryone = data) } + } + + // hide loading indicator + _uiState.update { it.copy(showLoader = false) } + } + } + + private fun markAsDeletedForEveryoneLegacyGroup(messages: Set){ + + } + + + private fun markAsDeletedForEveryoneGroupsV2(data: DeleteForEveryoneDialogData){ + viewModelScope.launch(Dispatchers.IO) { + // show a loading indicator + _uiState.update { it.copy(showLoader = true) } + + //todo GROUPS V2 - uncomment below and use Fanchao's method to delete a group V2 + try { + //repository.callMethodFromFanchao(threadId, recipient, data.messages) + + // the repo will handle the internal logic (calling `/delete` on the swarm + // and sending 'GroupUpdateDeleteMemberContentMessage' + // When this is done we simply need to remove the message locally + repository.markAsDeletedLocally( + messages = data.messages, + displayedMessage = application.getString(R.string.deleteMessageDeletedGlobally) + ) + + // show confirmation toast + Toast.makeText( + application, + application.resources.getQuantityString( + R.plurals.deleteMessageDeleted, + data.messages.count(), data.messages.count() + ), + Toast.LENGTH_SHORT + ).show() + } catch (e: Exception) { + Log.w("Loki", "FAILED TO delete messages ${data.messages} ") + // failed to delete - show a toast and get back on the modal + Toast.makeText( + application, + application.resources.getQuantityString( + R.plurals.deleteMessageFailed, + data.messages.size, + data.messages.size + ), Toast.LENGTH_SHORT + ).show() + + _dialogsState.update { it.copy(deleteAllDevices = data) } + } + + // hide loading indicator + _uiState.update { it.copy(showLoader = false) } + } + } + + private fun markAsDeletedForEveryoneCommunity(data: DeleteForEveryoneDialogData){ + viewModelScope.launch(Dispatchers.IO) { + // show a loading indicator + _uiState.update { it.copy(showLoader = true) } + + // delete remotely + try { + repository.deleteCommunityMessagesRemotely(threadId, data.messages) + + // When this is done we simply need to remove the message locally + repository.markAsDeletedLocally( + messages = data.messages, + displayedMessage = application.getString(R.string.deleteMessageDeletedGlobally) ) - }*/ + + // show confirmation toast + Toast.makeText( + application, + application.resources.getQuantityString( + R.plurals.deleteMessageDeleted, + data.messages.count(), + data.messages.count() + ), + Toast.LENGTH_SHORT + ).show() + } catch (e: Exception) { + Log.w("Loki", "FAILED TO delete messages ${data.messages} ") + // failed to delete - show a toast and get back on the modal + Toast.makeText( + application, + application.resources.getQuantityString( + R.plurals.deleteMessageFailed, + data.messages.size, + data.messages.size + ), Toast.LENGTH_SHORT + ).show() + + _dialogsState.update { it.copy(deleteEveryone = data) } + } + + // hide loading indicator + _uiState.update { it.copy(showLoader = false) } + } + } + + private fun isUserCommunityManager() = openGroup?.let { openGroup -> + val userPublicKey = textSecurePreferences.getLocalNumber() ?: return@let false + OpenGroupManager.isUserModerator(application, openGroup.id, userPublicKey, blindedPublicKey) + } ?: false + + /** + * Stops audio player if its current playing is the one given in the message. + */ + private fun stopMessageAudio(message: MessageRecord) { + val mmsMessage = message as? MmsMessageRecord ?: return + val audioSlide = mmsMessage.slideDeck.audioSlide ?: return + stopMessageAudio(audioSlide) + } + private fun stopMessageAudio(audioSlide: AudioSlide) { + AudioSlidePlayer.getInstance()?.takeIf { it.audioSlide == audioSlide }?.stop() + } + + fun setRecipientApproved() { + val recipient = recipient ?: return Log.w("Loki", "Recipient was null for set approved action") + repository.setApproved(recipient, true) } fun banUser(recipient: Recipient) = viewModelScope.launch { @@ -483,8 +682,7 @@ class ConversationViewModel( markAsDeletedLocally(command.messages) } is Commands.MarkAsDeletedForEveryone -> { - //todo DELETION mark as deleted for everyone here - //markAsDeletedForEveryone(command.messages) + markAsDeletedForEveryone(command.data) } } } @@ -524,14 +722,23 @@ class ConversationViewModel( val openLinkDialogUrl: String? = null, val deleteDeviceOnly: Set? = null, val deleteEveryone: DeleteForEveryoneDialogData? = null, - val deleteAllDevices: Set? = null, + val deleteAllDevices: DeleteForEveryoneDialogData? = null, ) data class DeleteForEveryoneDialogData( val messages: Set, + val messageType: DeleteForEveryoneMessageType, val defaultToEveryone: Boolean ) + sealed class DeleteForEveryoneMessageType { + data object NoteToSelf: DeleteForEveryoneMessageType() + data object OneOnOne: DeleteForEveryoneMessageType() + data object LegacyGroup: DeleteForEveryoneMessageType() + data object GroupV2: DeleteForEveryoneMessageType() + data object Community: DeleteForEveryoneMessageType() + } + sealed class Commands { data class ShowOpenUrlDialog(val url: String?) : Commands() data object HideDeleteDeviceOnlyDialog : Commands() @@ -539,7 +746,7 @@ class ConversationViewModel( data object HideDeleteAllDevicesDialog : Commands() data class MarkAsDeletedLocally(val messages: Set): Commands() - data class MarkAsDeletedForEveryone(val messages: Set): Commands() + data class MarkAsDeletedForEveryone(val data: DeleteForEveryoneDialogData): Commands() } } @@ -549,7 +756,8 @@ data class ConversationUiState( val uiMessages: List = emptyList(), val isMessageRequestAccepted: Boolean? = null, val conversationExists: Boolean, - val hideInputBar: Boolean = false + val hideInputBar: Boolean = false, + val showLoader: Boolean = false ) data class RetrieveOnce(val retrieval: () -> T?) { diff --git a/app/src/main/java/org/thoughtcrime/securesms/repository/ConversationRepository.kt b/app/src/main/java/org/thoughtcrime/securesms/repository/ConversationRepository.kt index 5a0e97a913..924ff01906 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/repository/ConversationRepository.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/repository/ConversationRepository.kt @@ -22,11 +22,13 @@ import org.session.libsession.messaging.messages.visible.VisibleMessage import org.session.libsession.messaging.open_groups.OpenGroupApi import org.session.libsession.messaging.sending_receiving.MessageSender import org.session.libsession.snode.SnodeAPI +import org.session.libsession.snode.utilities.await import org.session.libsession.utilities.Address import org.session.libsession.utilities.GroupUtil import org.session.libsession.utilities.TextSecurePreferences import org.session.libsession.utilities.recipients.Recipient import org.session.libsignal.utilities.Log +import org.session.libsignal.utilities.get import org.session.libsignal.utilities.toHexString import org.thoughtcrime.securesms.database.DatabaseContentProviders import org.thoughtcrime.securesms.database.DraftDatabase @@ -61,7 +63,12 @@ interface ConversationRepository { fun deleteAllLocalMessagesInThreadFromSenderOfMessage(messageRecord: MessageRecord) fun setApproved(recipient: Recipient, isApproved: Boolean) - suspend fun markAsDeletedForEveryone(threadId: Long, recipient: Recipient, message: MessageRecord): Result + suspend fun deleteCommunityMessagesRemotely(threadId: Long, messages: Set) + suspend fun delete1on1MessagesRemotely( + threadId: Long, + recipient: Recipient, + messages: Set + ) fun buildUnsendRequest(recipient: Recipient, message: MessageRecord): UnsendRequest? suspend fun banUser(threadId: Long, recipient: Recipient): Result suspend fun banAndDeleteAll(threadId: Long, recipient: Recipient): Result @@ -221,7 +228,49 @@ class DefaultConversationRepository @Inject constructor( storage.setRecipientApproved(recipient, isApproved) } - override suspend fun markAsDeletedForEveryone( + override suspend fun deleteCommunityMessagesRemotely( + threadId: Long, + messages: Set + ) { + val community = lokiThreadDb.getOpenGroupChat(threadId) ?: + throw Error("Not a Community") + + messages.forEach { message -> + lokiMessageDb.getServerID(message.id, !message.isMms)?.let { messageServerID -> + OpenGroupApi.deleteMessage(messageServerID, community.room, community.server).await() + } + } + } + + override suspend fun delete1on1MessagesRemotely( + threadId: Long, + recipient: Recipient, + messages: Set + ) { + // delete the messages remotely + val publicKey = recipient.address.serialize() + val userAddress: Address? = textSecurePreferences.getLocalNumber()?.let { Address.fromSerialized(it) } + + messages.forEach { message -> + // delete from swarm + messageDataProvider.getServerHashForMessage(message.id, message.isMms) + ?.let { serverHash -> + SnodeAPI.deleteMessage(publicKey, listOf(serverHash)).await() + } + + // send an UnsendRequest to user's swarm + buildUnsendRequest(recipient, message)?.let { unsendRequest -> + userAddress?.let { MessageSender.send(unsendRequest, it) } + } + + // send an UnsendRequest to recipient's swarm + buildUnsendRequest(recipient, message)?.let { unsendRequest -> + MessageSender.send(unsendRequest, recipient.address) + } + } + } + + /* override suspend fun markAsDeletedForEveryone( threadId: Long, recipient: Recipient, message: MessageRecord @@ -276,7 +325,7 @@ class DefaultConversationRepository @Inject constructor( } } } - } + }*/ override fun buildUnsendRequest(recipient: Recipient, message: MessageRecord): UnsendRequest? { if (recipient.isCommunityRecipient) return null diff --git a/app/src/main/res/layout/activity_conversation_v2.xml b/app/src/main/res/layout/activity_conversation_v2.xml index db8a6a2b3c..77702dc4eb 100644 --- a/app/src/main/res/layout/activity_conversation_v2.xml +++ b/app/src/main/res/layout/activity_conversation_v2.xml @@ -351,4 +351,23 @@ android:layout_width="match_parent" android:layout_height="match_parent" /> + + + + + +