From 54ef260aa9b14c1675b2719a2e1988ba95dfc9d1 Mon Sep 17 00:00:00 2001 From: ThomasSession Date: Mon, 14 Oct 2024 15:33:11 +1100 Subject: [PATCH 1/2] Feature/standardised message deletion (#1685) * Initial commit with high level structure for new message deletion logic * Adding admin logic * New dialog styles * Matching existing dialog closer to new designs * Using the theme attribute danger instead of a hardcoded colour * Using classes for the dialogs Also cleaned up older references to align with newer look * Adding cancel handling Cleaning unused code * Handling local deletion with batch message deletion * Reusing the 'delete locally' * Delete on device should "marl the message as deleted", not remove it from the db directly * Displaying "marked as deleted" messages Split the `BASE_DELETED_TYPE` into two types: BASE_DELETED_OUTGOING_TYPE and BASE_DELETED_INCOMING_TYPE so we can differentiate them visually. * Proper handling of merged code * Removed temp bg color * Making sure the deleted message view is visible * Renaming functions for clarity * Adding the ability to customise the text for the deleted control messages * Removing code that was added back from merging dev back in * Using the updated strings * Toast confirmation on 'delete locally' * Recreating xml dialogs in Compose and moved logic in VM * Removing hardcoded strings * Updated message deletion logic Still need to finalise "note to self" and "legacy groups" * Deletion logic rework Moving away from promises * More deletion logic Hndling unsend request retrieval as per figma docs * Making sure multi-select works as expectec * Multi message handling Sharing admin logic * Deleting reactions when deleting a message * Deleting reactions when deleting a message * Grabbing server hash from notification data * Fixed unit tests * Handling deletion od "marked as deleted" messages * Handling Control Messages longpress and deletion * Back up handling of no map data for huawei notifications Also rethemed the send buttona dn home plus button to have better ax contrast by standardising the colour displayed on the accent color to be the same as the one on the sent messages * Removed test line * Reworking the deletion dialogs We removed the 'delete locally' dialog, instead we show the 'delete for everyone' with the second option disabled * Outgoing messages can all be marked as 'delete for everyone' Cleaned up invisible copy button on black bgs * PR feedback * Updated huawei file and tested notifications * Fixed SES-2802 Only force the priority to visible when going from not approved to approved * Syncing state diaplays as sent Syncing happens in the bg so the user doesn't need to know of it hence the status can display as "Sent" during the syncing phase. Resyncing, in case it happens, can display the "Syncing" status as it would happen after a syncing error. * Latest strings --------- Co-authored-by: ThomasArtProcessors <71994342+ThomasArtProcessors@users.noreply.github.com> --- app/src/huawei/agconnect-services.json | 4 +- .../notifications/HuaweiPushService.kt | 4 +- .../notifications/HuaweiTokenFetcher.kt | 4 +- .../securesms/MediaPreviewActivity.java | 1 + .../attachments/DatabaseAttachmentProvider.kt | 34 +- .../dialogs}/DeleteMediaDialog.kt | 3 +- .../dialogs}/DeleteMediaPreviewDialog.kt | 3 +- .../securesms/components/menu/ActionItem.kt | 1 + .../conversation/v2/ConversationActivityV2.kt | 142 ++--- .../conversation/v2/ConversationAdapter.kt | 14 +- .../v2/ConversationReactionOverlay.kt | 50 +- .../conversation/v2/ConversationV2Dialogs.kt | 219 +++++++ .../conversation/v2/ConversationViewModel.kt | 540 ++++++++++++++++-- .../v2/input_bar/InputBarButton.kt | 4 +- .../menus/ConversationActionModeCallback.kt | 6 +- .../v2/messages/ControlMessageView.kt | 21 +- .../v2/messages/DeletedMessageView.kt | 3 +- .../v2/messages/VisibleMessageContentView.kt | 2 +- .../v2/messages/VisibleMessageView.kt | 38 +- .../securesms/database/MessagingDatabase.java | 2 +- .../securesms/database/MmsDatabase.kt | 9 +- .../securesms/database/MmsSmsColumns.java | 8 +- .../securesms/database/ReactionDatabase.kt | 51 +- .../securesms/database/SmsDatabase.java | 9 +- .../securesms/database/Storage.kt | 15 +- .../securesms/home/HomeActivity.kt | 1 - .../securesms/notifications/PushReceiver.kt | 70 ++- .../securesms/preferences/ShareLogsDialog.kt | 2 +- .../repository/ConversationRepository.kt | 231 +++++--- .../securesms/ui/components/RadioButton.kt | 9 +- .../securesms/ui/theme/ThemeColors.kt | 5 + .../res/drawable/ic_arrow_up_circle_24.xml | 11 - app/src/main/res/drawable/ic_copy.xml | 7 +- .../main/res/drawable/ic_radio_selected.xml | 3 +- .../main/res/drawable/ic_radio_unselected.xml | 3 +- .../res/layout/activity_conversation_v2.xml | 19 + app/src/main/res/layout/activity_home.xml | 3 +- .../main/res/layout/mediasend_fragment.xml | 7 +- .../main/res/layout/view_control_message.xml | 78 +-- .../main/res/layout/view_deleted_message.xml | 5 +- .../layout/view_visible_message_content.xml | 1 + app/src/main/res/values/attrs.xml | 2 - app/src/main/res/values/styles.xml | 2 +- app/src/main/res/values/themes.xml | 3 - .../notifications/FirebasePushService.kt | 2 +- .../v2/ConversationViewModelTest.kt | 54 +- .../database/MessageDataProvider.kt | 4 +- .../libsession/database/StorageProtocol.kt | 3 + .../messages/MarkAsDeletedMessage.kt | 6 + .../ReceivedMessageHandler.kt | 60 +- .../org/session/libsession/snode/SnodeAPI.kt | 99 ++-- .../utilities/recipients/MessageType.kt | 14 + .../utilities/task/SnackbarAsyncTask.java | 93 --- .../src/main/res/values-b+af+ZA/strings.xml | 2 - .../src/main/res/values-b+ar+SA/strings.xml | 2 - .../src/main/res/values-b+az+AZ/strings.xml | 2 - .../src/main/res/values-b+bal+BA/strings.xml | 2 - .../src/main/res/values-b+be+BY/strings.xml | 2 - .../src/main/res/values-b+bg+BG/strings.xml | 2 - .../src/main/res/values-b+bn+BD/strings.xml | 2 - .../src/main/res/values-b+ca+ES/strings.xml | 2 - .../src/main/res/values-b+cs+CZ/strings.xml | 2 - .../src/main/res/values-b+cy+GB/strings.xml | 2 - .../src/main/res/values-b+da+DK/strings.xml | 2 - .../src/main/res/values-b+de+DE/strings.xml | 2 - .../src/main/res/values-b+el+GR/strings.xml | 2 - .../src/main/res/values-b+eo+UY/strings.xml | 2 - .../src/main/res/values-b+es+419/strings.xml | 2 - .../src/main/res/values-b+es+ES/strings.xml | 2 - .../src/main/res/values-b+et+EE/strings.xml | 2 - .../src/main/res/values-b+eu+ES/strings.xml | 2 - .../src/main/res/values-b+fa+IR/strings.xml | 2 - .../src/main/res/values-b+fi+FI/strings.xml | 2 - .../src/main/res/values-b+fil+PH/strings.xml | 2 - .../src/main/res/values-b+fr+FR/strings.xml | 2 - .../src/main/res/values-b+gl+ES/strings.xml | 2 - .../src/main/res/values-b+ha+HG/strings.xml | 2 - .../src/main/res/values-b+he+IL/strings.xml | 2 - .../src/main/res/values-b+hi+IN/strings.xml | 2 - .../src/main/res/values-b+hr+HR/strings.xml | 2 - .../src/main/res/values-b+hu+HU/strings.xml | 2 - .../src/main/res/values-b+hy+AM/strings.xml | 2 - .../src/main/res/values-b+id+ID/strings.xml | 2 - .../src/main/res/values-b+it+IT/strings.xml | 2 - .../src/main/res/values-b+ja+JP/strings.xml | 2 - .../src/main/res/values-b+ka+GE/strings.xml | 2 - .../src/main/res/values-b+km+KH/strings.xml | 2 - .../src/main/res/values-b+kmr+TR/strings.xml | 2 - .../src/main/res/values-b+kn+IN/strings.xml | 2 - .../src/main/res/values-b+ko+KR/strings.xml | 2 - .../src/main/res/values-b+ku+TR/strings.xml | 2 - .../src/main/res/values-b+lg+UG/strings.xml | 2 - .../src/main/res/values-b+lo+LA/strings.xml | 2 - .../src/main/res/values-b+lt+LT/strings.xml | 2 - .../src/main/res/values-b+lv+LV/strings.xml | 2 - .../src/main/res/values-b+mk+MK/strings.xml | 2 - .../src/main/res/values-b+mn+MN/strings.xml | 2 - .../src/main/res/values-b+ms+MY/strings.xml | 2 - .../src/main/res/values-b+my+MM/strings.xml | 2 - .../src/main/res/values-b+nb+NO/strings.xml | 2 - .../src/main/res/values-b+ne+NP/strings.xml | 2 - .../src/main/res/values-b+nl+NL/strings.xml | 2 - .../src/main/res/values-b+nn+NO/strings.xml | 2 - .../src/main/res/values-b+no+NO/strings.xml | 2 - .../src/main/res/values-b+ny+MW/strings.xml | 2 - .../src/main/res/values-b+pa+IN/strings.xml | 2 - .../src/main/res/values-b+pl+PL/strings.xml | 2 - .../src/main/res/values-b+ps+AF/strings.xml | 2 - .../src/main/res/values-b+pt+BR/strings.xml | 2 - .../src/main/res/values-b+pt+PT/strings.xml | 2 - .../src/main/res/values-b+ro+RO/strings.xml | 2 - .../src/main/res/values-b+ru+RU/strings.xml | 2 - .../src/main/res/values-b+si+LK/strings.xml | 2 - .../src/main/res/values-b+sk+SK/strings.xml | 4 +- .../src/main/res/values-b+sl+SI/strings.xml | 2 - .../src/main/res/values-b+sq+AL/strings.xml | 2 - .../src/main/res/values-b+sr+CS/strings.xml | 2 - .../src/main/res/values-b+sr+SP/strings.xml | 2 - .../src/main/res/values-b+sv+SE/strings.xml | 2 - .../src/main/res/values-b+sw+KE/strings.xml | 2 - .../src/main/res/values-b+ta+IN/strings.xml | 2 - .../src/main/res/values-b+te+IN/strings.xml | 2 - .../src/main/res/values-b+th+TH/strings.xml | 2 - .../src/main/res/values-b+tl+PH/strings.xml | 2 - .../src/main/res/values-b+tr+TR/strings.xml | 2 - .../src/main/res/values-b+uk+UA/strings.xml | 8 +- .../src/main/res/values-b+ur+IN/strings.xml | 2 - .../src/main/res/values-b+uz+UZ/strings.xml | 2 - .../src/main/res/values-b+vi+VN/strings.xml | 2 - .../src/main/res/values-b+xh+ZA/strings.xml | 2 - .../src/main/res/values-b+zh+CN/strings.xml | 2 - .../src/main/res/values-b+zh+TW/strings.xml | 2 - libsession/src/main/res/values/attrs.xml | 2 - libsession/src/main/res/values/strings.xml | 21 +- .../exceptions/NonRetryableException.kt | 3 + .../session/libsignal/utilities/Retrying.kt | 23 + 136 files changed, 1467 insertions(+), 732 deletions(-) rename app/src/main/java/org/thoughtcrime/securesms/{ => components/dialogs}/DeleteMediaDialog.kt (84%) rename app/src/main/java/org/thoughtcrime/securesms/{ => components/dialogs}/DeleteMediaPreviewDialog.kt (84%) create mode 100644 app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationV2Dialogs.kt delete mode 100644 app/src/main/res/drawable/ic_arrow_up_circle_24.xml create mode 100644 libsession/src/main/java/org/session/libsession/messaging/messages/MarkAsDeletedMessage.kt create mode 100644 libsession/src/main/java/org/session/libsession/utilities/recipients/MessageType.kt delete mode 100644 libsession/src/main/java/org/session/libsession/utilities/task/SnackbarAsyncTask.java create mode 100644 libsignal/src/main/java/org/session/libsignal/exceptions/NonRetryableException.kt diff --git a/app/src/huawei/agconnect-services.json b/app/src/huawei/agconnect-services.json index 0c81d0477a..7fcb75e0f9 100644 --- a/app/src/huawei/agconnect-services.json +++ b/app/src/huawei/agconnect-services.json @@ -54,8 +54,8 @@ "channel_id":"" }, "edukit":{ - "edu_url":"edukit.edu.cloud.huawei.com.cn", - "dh_url":"edukit.edu.cloud.huawei.com.cn" + "edu_url":"edukit.cloud.huawei.com.cn", + "dh_url":"edukit.cloud.huawei.com.cn" }, "search":{ "url":"https://search-dre.cloud.huawei.com" diff --git a/app/src/huawei/kotlin/org/thoughtcrime/securesms/notifications/HuaweiPushService.kt b/app/src/huawei/kotlin/org/thoughtcrime/securesms/notifications/HuaweiPushService.kt index dc7bf893d7..0a5c14fd42 100644 --- a/app/src/huawei/kotlin/org/thoughtcrime/securesms/notifications/HuaweiPushService.kt +++ b/app/src/huawei/kotlin/org/thoughtcrime/securesms/notifications/HuaweiPushService.kt @@ -20,8 +20,8 @@ class HuaweiPushService: HmsMessageService() { override fun onMessageReceived(message: RemoteMessage?) { Log.d(TAG, "onMessageReceived") - message?.dataOfMap?.takeIf { it.isNotEmpty() }?.let(pushReceiver::onPush) ?: - pushReceiver.onPush(message?.data?.let(Base64::decode)) + message?.dataOfMap?.takeIf { it.isNotEmpty() }?.let(pushReceiver::onPushDataReceived) ?: + pushReceiver.onPushDataReceived(message?.data?.let(Base64::decode)) } override fun onNewToken(token: String?) { diff --git a/app/src/huawei/kotlin/org/thoughtcrime/securesms/notifications/HuaweiTokenFetcher.kt b/app/src/huawei/kotlin/org/thoughtcrime/securesms/notifications/HuaweiTokenFetcher.kt index 9d9b61ce9a..f0ce294596 100644 --- a/app/src/huawei/kotlin/org/thoughtcrime/securesms/notifications/HuaweiTokenFetcher.kt +++ b/app/src/huawei/kotlin/org/thoughtcrime/securesms/notifications/HuaweiTokenFetcher.kt @@ -24,6 +24,8 @@ class HuaweiTokenFetcher @Inject constructor( override suspend fun fetch(): String? = HmsInstanceId.getInstance(context).run { // https://developer.huawei.com/consumer/en/doc/development/HMS-Guides/push-basic-capability#h2-1576218800370 // getToken may return an empty string, if so HuaweiPushService#onNewToken will be called. - withContext(Dispatchers.IO) { getToken(APP_ID, TOKEN_SCOPE) } + withContext(Dispatchers.IO) { + getToken(APP_ID, TOKEN_SCOPE) + } } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/MediaPreviewActivity.java b/app/src/main/java/org/thoughtcrime/securesms/MediaPreviewActivity.java index f761cdd4e7..00be42e299 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/MediaPreviewActivity.java +++ b/app/src/main/java/org/thoughtcrime/securesms/MediaPreviewActivity.java @@ -73,6 +73,7 @@ import org.session.libsession.utilities.recipients.Recipient; import org.session.libsession.utilities.recipients.RecipientModifiedListener; import org.session.libsignal.utilities.Log; import org.thoughtcrime.securesms.components.MediaView; +import org.thoughtcrime.securesms.components.dialogs.DeleteMediaPreviewDialog; import org.thoughtcrime.securesms.database.MediaDatabase.MediaRecord; import org.thoughtcrime.securesms.database.loaders.PagingMediaLoader; import org.thoughtcrime.securesms.database.model.MmsMessageRecord; 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 6445abed3b..d74174fecb 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/attachments/DatabaseAttachmentProvider.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/attachments/DatabaseAttachmentProvider.kt @@ -6,6 +6,7 @@ import com.google.protobuf.ByteString import org.greenrobot.eventbus.EventBus import org.session.libsession.database.MessageDataProvider import org.session.libsession.messaging.MessagingModuleConfiguration +import org.session.libsession.messaging.messages.MarkAsDeletedMessage import org.session.libsession.messaging.messages.control.UnsendRequest import org.session.libsession.messaging.sending_receiving.attachments.Attachment import org.session.libsession.messaging.sending_receiving.attachments.AttachmentId @@ -198,7 +199,6 @@ class DatabaseAttachmentProvider(context: Context, helper: SQLCipherOpenHelper) } override fun deleteMessages(messageIDs: List, threadId: Long, isSms: Boolean) { - val messagingDatabase: MessagingDatabase = if (isSms) DatabaseComponent.get(context).smsDatabase() else DatabaseComponent.get(context).mmsDatabase() @@ -215,18 +215,32 @@ class DatabaseAttachmentProvider(context: Context, helper: SQLCipherOpenHelper) threadId?.let{ MessagingModuleConfiguration.shared.lastSentTimestampCache.delete(it, messages.map { it.timestamp }) } } - override fun updateMessageAsDeleted(timestamp: Long, author: String): Long? { + override fun markMessageAsDeleted(timestamp: Long, author: String, displayedMessage: String) { val database = DatabaseComponent.get(context).mmsSmsDatabase() val address = Address.fromSerialized(author) - val message = database.getMessageFor(timestamp, address) ?: return null - val messagingDatabase: MessagingDatabase = if (message.isMms) DatabaseComponent.get(context).mmsDatabase() - else DatabaseComponent.get(context).smsDatabase() - messagingDatabase.markAsDeleted(message.id, message.isRead, message.hasMention) - if (message.isOutgoing) { - messagingDatabase.deleteMessage(message.id) - } + val message = database.getMessageFor(timestamp, address) ?: return Log.w("", "Failed to find message to mark as deleted") - return message.id + markMessagesAsDeleted( + messages = listOf(MarkAsDeletedMessage( + messageId = message.id, + isOutgoing = message.isOutgoing + )), + isSms = !message.isMms, + displayedMessage = displayedMessage + ) + } + + override fun markMessagesAsDeleted( + messages: List, + isSms: Boolean, + displayedMessage: String + ) { + val messagingDatabase: MessagingDatabase = if (isSms) DatabaseComponent.get(context).smsDatabase() + else DatabaseComponent.get(context).mmsDatabase() + + messages.forEach { message -> + messagingDatabase.markAsDeleted(message.messageId, message.isOutgoing, displayedMessage) + } } override fun getServerHashForMessage(messageID: Long, mms: Boolean): String? = diff --git a/app/src/main/java/org/thoughtcrime/securesms/DeleteMediaDialog.kt b/app/src/main/java/org/thoughtcrime/securesms/components/dialogs/DeleteMediaDialog.kt similarity index 84% rename from app/src/main/java/org/thoughtcrime/securesms/DeleteMediaDialog.kt rename to app/src/main/java/org/thoughtcrime/securesms/components/dialogs/DeleteMediaDialog.kt index 3d38857b50..54e9197da4 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/DeleteMediaDialog.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/dialogs/DeleteMediaDialog.kt @@ -1,7 +1,8 @@ -package org.thoughtcrime.securesms +package org.thoughtcrime.securesms.components.dialogs import android.content.Context import network.loki.messenger.R +import org.thoughtcrime.securesms.showSessionDialog class DeleteMediaDialog { companion object { diff --git a/app/src/main/java/org/thoughtcrime/securesms/DeleteMediaPreviewDialog.kt b/app/src/main/java/org/thoughtcrime/securesms/components/dialogs/DeleteMediaPreviewDialog.kt similarity index 84% rename from app/src/main/java/org/thoughtcrime/securesms/DeleteMediaPreviewDialog.kt rename to app/src/main/java/org/thoughtcrime/securesms/components/dialogs/DeleteMediaPreviewDialog.kt index b8aad6c22a..a287ad0892 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/DeleteMediaPreviewDialog.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/dialogs/DeleteMediaPreviewDialog.kt @@ -1,7 +1,8 @@ -package org.thoughtcrime.securesms +package org.thoughtcrime.securesms.components.dialogs import android.content.Context import network.loki.messenger.R +import org.thoughtcrime.securesms.showSessionDialog class DeleteMediaPreviewDialog { companion object { diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/menu/ActionItem.kt b/app/src/main/java/org/thoughtcrime/securesms/components/menu/ActionItem.kt index af32b7f50f..65f90a134a 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/menu/ActionItem.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/menu/ActionItem.kt @@ -4,6 +4,7 @@ import android.content.Context import androidx.annotation.AttrRes import androidx.annotation.ColorInt + /** * Represents an action to be rendered */ 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 9bbbc787ef..ad2db82cfb 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 @@ -33,9 +33,8 @@ import android.widget.Toast import androidx.activity.result.ActivityResult import androidx.activity.result.contract.ActivityResultContracts import androidx.activity.viewModels +import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.setValue import androidx.compose.ui.platform.ViewCompositionStrategy import androidx.core.content.ContextCompat import androidx.core.view.isGone @@ -110,6 +109,7 @@ import org.thoughtcrime.securesms.components.emoji.RecentEmojiPageModel import org.thoughtcrime.securesms.contacts.SelectContactsActivity.Companion.selectedContactsKey import org.thoughtcrime.securesms.conversation.ConversationActionBarDelegate import org.thoughtcrime.securesms.conversation.disappearingmessages.DisappearingMessagesActivity +import org.thoughtcrime.securesms.conversation.v2.ConversationViewModel.Commands.* import org.thoughtcrime.securesms.conversation.v2.ConversationReactionOverlay.OnActionSelectedListener import org.thoughtcrime.securesms.conversation.v2.ConversationReactionOverlay.OnReactionSelectedListener import org.thoughtcrime.securesms.conversation.v2.MessageDetailActivity.Companion.MESSAGE_TIMESTAMP @@ -131,6 +131,7 @@ import org.thoughtcrime.securesms.conversation.v2.mention.MentionViewModel import org.thoughtcrime.securesms.conversation.v2.menus.ConversationActionModeCallback import org.thoughtcrime.securesms.conversation.v2.menus.ConversationActionModeCallbackDelegate import org.thoughtcrime.securesms.conversation.v2.menus.ConversationMenuHelper +import org.thoughtcrime.securesms.conversation.v2.messages.ControlMessageView import org.thoughtcrime.securesms.conversation.v2.messages.VisibleMessageView import org.thoughtcrime.securesms.conversation.v2.messages.VisibleMessageViewDelegate import org.thoughtcrime.securesms.conversation.v2.search.SearchBottomBar @@ -173,8 +174,6 @@ import org.thoughtcrime.securesms.permissions.Permissions import org.thoughtcrime.securesms.reactions.ReactionsDialogFragment import org.thoughtcrime.securesms.reactions.any.ReactWithAnyEmojiDialogFragment import org.thoughtcrime.securesms.showSessionDialog -import org.thoughtcrime.securesms.ui.OpenURLAlertDialog -import org.thoughtcrime.securesms.ui.theme.SessionMaterialTheme import org.thoughtcrime.securesms.util.ActivityDispatcher import org.thoughtcrime.securesms.util.ConfigurationMessageUtilities import org.thoughtcrime.securesms.util.DateUtils @@ -244,8 +243,6 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe .get(LinkPreviewViewModel::class.java) } - private var openLinkDialogUrl: String? by mutableStateOf(null) - private val threadId: Long by lazy { var threadId = intent.getLongExtra(THREAD_ID, -1L) if (threadId == -1L) { @@ -348,9 +345,9 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe if (!viewModel.isMessageRequestThread && viewModel.canReactToMessages ) { - showEmojiPicker(message, view) + showConversationReaction(message, view) } else { - handleLongPress(message, position) + selectMessage(message, position) } }, onDeselect = { message, position -> @@ -410,7 +407,7 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe // endregion fun showOpenUrlDialog(url: String){ - openLinkDialogUrl = url + viewModel.onCommand(ShowOpenUrlDialog(url)) } // region Lifecycle @@ -423,16 +420,11 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe binding.dialogOpenUrl.apply { setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed) setContent { - SessionMaterialTheme { - if(!openLinkDialogUrl.isNullOrEmpty()){ - OpenURLAlertDialog( - url = openLinkDialogUrl!!, - onDismissRequest = { - openLinkDialogUrl = null - } - ) - } - } + val dialogsState by viewModel.dialogsState.collectAsState() + ConversationV2Dialogs( + dialogsState = dialogsState, + sendCommand = viewModel::onCommand + ) } } @@ -442,7 +434,7 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe val recipient = viewModel.recipient val openGroup = recipient.let { viewModel.openGroup } if (recipient == null || (recipient.isCommunityRecipient && openGroup == null)) { - Toast.makeText(this, "This thread has been deleted.", Toast.LENGTH_LONG).show() + Toast.makeText(this, getString(R.string.conversationsDeleted), Toast.LENGTH_LONG).show() return finish() } @@ -659,6 +651,12 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe recyclerScrollState = newState } }) + + lifecycleScope.launch { + viewModel.isAdmin.collect{ + adapter.isAdmin = it + } + } } private fun scrollToMostRecentMessageIfWeShould() { @@ -856,7 +854,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 } } } @@ -1291,7 +1293,7 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe } // `position` is the adapter position; not the visual position - private fun handleLongPress(message: MessageRecord, position: Int) { + private fun selectMessage(message: MessageRecord, position: Int) { val actionMode = this.actionMode val actionModeCallback = ConversationActionModeCallback(adapter, viewModel.threadId, this) actionModeCallback.delegate = this @@ -1309,15 +1311,21 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe } } - private fun showEmojiPicker(message: MessageRecord, visibleMessageView: VisibleMessageView) { + private fun showConversationReaction(message: MessageRecord, messageView: View) { + val messageContentView = when(messageView){ + is VisibleMessageView -> messageView.messageContentView + is ControlMessageView -> messageView.controlContentView + else -> null + } ?: return Log.w(TAG, "Failed to show reaction because the messageRecord is not of a known type: $messageView") + val messageContentBitmap = try { - visibleMessageView.messageContentView.drawToBitmap() + messageContentView.drawToBitmap() } catch (e: Exception) { Log.e("Loki", "Failed to show emoji picker", e) return } emojiPickerVisible = true - ViewUtil.hideKeyboard(this, visibleMessageView) + ViewUtil.hideKeyboard(this, messageView) binding.reactionsShade.isVisible = true binding.scrollToBottomButton.isVisible = false binding.conversationRecyclerView.suppressLayout(true) @@ -1339,14 +1347,14 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe } }) - val topLeft = intArrayOf(0, 0).also { visibleMessageView.messageContentView.getLocationInWindow(it) } + val topLeft = intArrayOf(0, 0).also { messageContentView.getLocationInWindow(it) } val selectedConversationModel = SelectedConversationModel( messageContentBitmap, topLeft[0].toFloat(), topLeft[1].toFloat(), - visibleMessageView.messageContentView.width, + messageContentView.width, message.isOutgoing, - visibleMessageView.messageContentView + messageContentView ) reactionDelegate.show(this, message, selectedConversationModel, viewModel.blindedPublicKey) } @@ -2066,88 +2074,14 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe } override fun selectMessages(messages: Set) { - handleLongPress(messages.first(), 0) //TODO: begin selection mode - } - - // The option to "Delete just for me" or "Delete for everyone" - private fun showDeleteOrDeleteForEveryoneInCommunityUI(messages: Set) { - val bottomSheet = DeleteOptionsBottomSheet() - bottomSheet.recipient = viewModel.recipient!! - bottomSheet.onDeleteForMeTapped = { - messages.forEach(viewModel::deleteLocally) - bottomSheet.dismiss() - endActionMode() - } - bottomSheet.onDeleteForEveryoneTapped = { - messages.forEach(viewModel::deleteForEveryone) - bottomSheet.dismiss() - endActionMode() - } - bottomSheet.onCancelTapped = { - bottomSheet.dismiss() - endActionMode() - } - bottomSheet.show(supportFragmentManager, bottomSheet.tag) - } - - private fun showDeleteLocallyUI(messages: Set) { - showSessionDialog { - title(resources.getQuantityString(R.plurals.deleteMessage, messages.count(), messages.count())) - text(resources.getString(R.string.deleteMessagesDescriptionDevice)) - button(R.string.delete) { messages.forEach(viewModel::deleteLocally); endActionMode() } - cancelButton(::endActionMode) - } + selectMessage(messages.first(), 0) //TODO: begin selection mode } // Note: The messages in the provided set may be a single message, or multiple if there are a // group of selected messages. override fun deleteMessages(messages: Set) { - val recipient = viewModel.recipient - if (recipient == null) { - Log.w("ConversationActivityV2", "Asked to delete messages but could not obtain viewModel recipient - aborting.") - return - } - - val allSentByCurrentUser = messages.all { it.isOutgoing } - val allHasHash = messages.all { lokiMessageDb.getMessageServerHash(it.id, it.isMms) != null } - - // If the recipient is a community OR a Note-to-Self then we delete the message for everyone - if (recipient.isCommunityRecipient || recipient.isLocalNumber) { - showSessionDialog { - title(resources.getQuantityString(R.plurals.deleteMessage, messages.count(), messages.count())) - text(resources.getString(R.string.deleteMessageDescriptionEveryone)) - dangerButton(R.string.delete) { messages.forEach(viewModel::deleteForEveryone); endActionMode() } - cancelButton { endActionMode() } - } - // Otherwise if this is a 1-on-1 conversation we may decided to delete just for ourselves or delete for everyone - } else if (allSentByCurrentUser && allHasHash) { - val bottomSheet = DeleteOptionsBottomSheet() - bottomSheet.recipient = recipient - bottomSheet.onDeleteForMeTapped = { - messages.forEach(viewModel::deleteLocally) - bottomSheet.dismiss() - endActionMode() - } - bottomSheet.onDeleteForEveryoneTapped = { - messages.forEach(viewModel::deleteForEveryone) - bottomSheet.dismiss() - endActionMode() - } - bottomSheet.onCancelTapped = { - bottomSheet.dismiss() - endActionMode() - } - bottomSheet.show(supportFragmentManager, bottomSheet.tag) - } - else // Finally, if this is a closed group and you are deleting someone else's message(s) then we can only delete locally. - { - showSessionDialog { - title(resources.getQuantityString(R.plurals.deleteMessage, messages.count(), messages.count())) - text(resources.getString(R.string.deleteMessageDescriptionDevice)) - dangerButton(R.string.delete) { messages.forEach(viewModel::deleteLocally); endActionMode() } - cancelButton(::endActionMode) - } - } + viewModel.handleMessagesDeletion(messages) + endActionMode() } override fun banUser(messages: Set) { diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationAdapter.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationAdapter.kt index 880dacb070..c83f71074d 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationAdapter.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationAdapter.kt @@ -5,6 +5,7 @@ import android.database.Cursor import android.util.SparseArray import android.util.SparseBooleanArray import android.view.MotionEvent +import android.view.View import android.view.ViewGroup import androidx.annotation.WorkerThread import androidx.core.util.getOrDefault @@ -35,7 +36,7 @@ class ConversationAdapter( private val isReversed: Boolean, private val onItemPress: (MessageRecord, Int, VisibleMessageView, MotionEvent) -> Unit, private val onItemSwipeToReply: (MessageRecord, Int) -> Unit, - private val onItemLongPress: (MessageRecord, Int, VisibleMessageView) -> Unit, + private val onItemLongPress: (MessageRecord, Int, View) -> Unit, private val onDeselect: (MessageRecord, Int) -> Unit, private val onAttachmentNeedsDownload: (DatabaseAttachment) -> Unit, private val glide: RequestManager, @@ -44,6 +45,7 @@ class ConversationAdapter( private val messageDB by lazy { DatabaseComponent.get(context).mmsSmsDatabase() } private val contactDB by lazy { DatabaseComponent.get(context).sessionContactDatabase() } var selectedItems = mutableSetOf() + var isAdmin: Boolean = false private var searchQuery: String? = null var visibleMessageViewDelegate: VisibleMessageViewDelegate? = null @@ -155,12 +157,18 @@ class ConversationAdapter( } else { visibleMessageView.onPress = null visibleMessageView.onSwipeToReply = null - visibleMessageView.onLongPress = null + // you can long press on "marked as deleted" messages + visibleMessageView.onLongPress = + { onItemLongPress(message, viewHolder.adapterPosition, visibleMessageView) } } } is ControlMessageViewHolder -> { - viewHolder.view.bind(message, messageBefore) + viewHolder.view.bind( + message = message, + previous = messageBefore, + longPress = { onItemLongPress(message, viewHolder.adapterPosition, viewHolder.view) } + ) } } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationReactionOverlay.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationReactionOverlay.kt index d445d002cb..a76dead344 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationReactionOverlay.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationReactionOverlay.kt @@ -21,6 +21,7 @@ import android.widget.TextView import androidx.constraintlayout.widget.ConstraintLayout import androidx.core.content.ContextCompat import androidx.core.view.doOnLayout +import androidx.core.view.isVisible import androidx.vectordrawable.graphics.drawable.AnimatorInflaterCompat import com.squareup.phrase.Phrase import dagger.hilt.android.AndroidEntryPoint @@ -222,7 +223,6 @@ class ConversationReactionOverlay : FrameLayout { endScale = spaceAvailableForItem / conversationItemSnapshot.height endX += Util.halfOffsetFromScale(conversationItemSnapshot.width, endScale) * if (isMessageOnLeft) -1 else 1 endY = spaceForReactionBar - Util.halfOffsetFromScale(conversationItemSnapshot.height, endScale) - val contextMenuTop = endY + conversationItemSnapshot.height * endScale reactionBarBackgroundY = reactionBarTopPadding //getReactionBarOffsetForTouch(selectedConversationModel.getBubbleY(), contextMenuTop + Util.halfOffsetFromScale(conversationItemSnapshot.getHeight(), endScale), menuPadding, reactionBarOffset, reactionBarHeight, reactionBarTopPadding, endY); endApparentTop = endY + Util.halfOffsetFromScale(conversationItemSnapshot.height, endScale) } else { @@ -271,11 +271,17 @@ class ConversationReactionOverlay : FrameLayout { revealAnimatorSet.start() if (isWideLayout) { val scrubberRight = scrubberX + scrubberWidth - val offsetX = if (isMessageOnLeft) scrubberRight + menuPadding else scrubberX - contextMenu.getMaxWidth() - menuPadding + val offsetX = when { + isMessageOnLeft -> scrubberRight + menuPadding + else -> scrubberX - contextMenu.getMaxWidth() - menuPadding + } contextMenu.show(offsetX.toInt(), Math.min(backgroundView.y, (overlayHeight - contextMenu.getMaxHeight()).toFloat()).toInt()) } else { val contentX = if (isMessageOnLeft) scrubberHorizontalMargin.toFloat() else selectedConversationModel.bubbleX - val offsetX = if (isMessageOnLeft) contentX else -contextMenu.getMaxWidth() + contentX + bubbleWidth + val offsetX = when { + isMessageOnLeft -> contentX + else -> -contextMenu.getMaxWidth() + contentX + bubbleWidth + } val menuTop = endApparentTop + conversationItemSnapshot.height * endScale contextMenu.show(offsetX.toInt(), (menuTop + menuPadding).toInt()) } @@ -526,19 +532,30 @@ class ConversationReactionOverlay : FrameLayout { val recipient = get(context).threadDatabase().getRecipientForThreadId(message.threadId) ?: return emptyList() val userPublicKey = getLocalNumber(context)!! + + // control messages and "marked as deleted" messages can only delete + val isDeleteOnly = message.isDeleted || message.isControlMessage + // Select message - items += ActionItem(R.attr.menu_select_icon, R.string.select, { handleActionItemClicked(Action.SELECT) }, R.string.AccessibilityId_select) + if(!isDeleteOnly) { + items += ActionItem( + R.attr.menu_select_icon, + R.string.select, + { handleActionItemClicked(Action.SELECT) }, + R.string.AccessibilityId_select + ) + } // Reply val canWrite = openGroup == null || openGroup.canWrite - if (canWrite && !message.isPending && !message.isFailed && !message.isOpenGroupInvitation) { + if (canWrite && !message.isPending && !message.isFailed && !message.isOpenGroupInvitation && !isDeleteOnly) { items += ActionItem(R.attr.menu_reply_icon, R.string.reply, { handleActionItemClicked(Action.REPLY) }, R.string.AccessibilityId_reply) } // Copy message text - if (!containsControlMessage && hasText) { + if (!containsControlMessage && hasText && !isDeleteOnly) { items += ActionItem(R.attr.menu_copy_icon, R.string.copy, { handleActionItemClicked(Action.COPY_MESSAGE) }) } // Copy Account ID - if (!recipient.isCommunityRecipient && message.isIncoming) { + if (!recipient.isCommunityRecipient && message.isIncoming && !isDeleteOnly) { items += ActionItem(R.attr.menu_copy_icon, R.string.accountIDCopy, { handleActionItemClicked(Action.COPY_ACCOUNT_ID) }) } // Delete message @@ -547,15 +564,20 @@ class ConversationReactionOverlay : FrameLayout { R.string.AccessibilityId_deleteMessage, message.subtitle, ThemeUtil.getThemedColor(context, R.attr.danger)) } // Ban user - if (userCanBanSelectedUsers(context, message, openGroup, userPublicKey, blindedPublicKey)) { + if (userCanBanSelectedUsers(context, message, openGroup, userPublicKey, blindedPublicKey) && !isDeleteOnly) { items += ActionItem(R.attr.menu_block_icon, R.string.banUser, { handleActionItemClicked(Action.BAN_USER) }) } // Ban and delete all - if (userCanBanSelectedUsers(context, message, openGroup, userPublicKey, blindedPublicKey)) { + if (userCanBanSelectedUsers(context, message, openGroup, userPublicKey, blindedPublicKey) && !isDeleteOnly) { items += ActionItem(R.attr.menu_trash_icon, R.string.banDeleteAll, { handleActionItemClicked(Action.BAN_AND_DELETE_ALL) }) } // Message detail - items += ActionItem(R.attr.menu_info_icon, R.string.messageInfo, { handleActionItemClicked(Action.VIEW_INFO) }) + if(!isDeleteOnly) { + items += ActionItem( + R.attr.menu_info_icon, + R.string.messageInfo, + { handleActionItemClicked(Action.VIEW_INFO) }) + } // Resend if (message.isFailed) { items += ActionItem(R.attr.menu_reply_icon, R.string.resend, { handleActionItemClicked(Action.RESEND) }) @@ -565,7 +587,7 @@ class ConversationReactionOverlay : FrameLayout { items += ActionItem(R.attr.menu_reply_icon, R.string.resync, { handleActionItemClicked(Action.RESYNC) }) } // Save media.. - if (message.isMms) { + if (message.isMms && !isDeleteOnly) { // ..but only provide the save option if the there is a media attachment which has finished downloading. val mmsMessage = message as MediaMmsMessageRecord if (mmsMessage.containsMediaSlide() && !mmsMessage.isMediaPending) { @@ -576,8 +598,10 @@ class ConversationReactionOverlay : FrameLayout { ) } } - backgroundView.visibility = VISIBLE - foregroundView.visibility = VISIBLE + + // deleted messages have no emoji reactions + backgroundView.isVisible = !isDeleteOnly + foregroundView.isVisible = !isDeleteOnly return items } 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 new file mode 100644 index 0000000000..26775f01c2 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationV2Dialogs.kt @@ -0,0 +1,219 @@ +package org.thoughtcrime.securesms.conversation.v2 + +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.pluralStringResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import network.loki.messenger.R +import org.thoughtcrime.securesms.conversation.v2.ConversationViewModel.Commands.HideDeleteAllDevicesDialog +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.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.LocalType +import org.thoughtcrime.securesms.ui.theme.PreviewTheme +import org.thoughtcrime.securesms.ui.theme.SessionMaterialTheme + +@Composable +fun ConversationV2Dialogs( + dialogsState: ConversationViewModel.DialogsState, + sendCommand: (ConversationViewModel.Commands) -> Unit +){ + SessionMaterialTheme { + // open link confirmation + if(!dialogsState.openLinkDialogUrl.isNullOrEmpty()){ + OpenURLAlertDialog( + url = dialogsState.openLinkDialogUrl, + onDismissRequest = { + // hide dialog + sendCommand(ShowOpenUrlDialog(null)) + } + ) + } + + // delete message(s) for everyone + if(dialogsState.deleteEveryone != null){ + var deleteForEveryone by remember { mutableStateOf(dialogsState.deleteEveryone.defaultToEveryone)} + + AlertDialog( + onDismissRequest = { + // hide dialog + sendCommand(HideDeleteEveryoneDialog) + }, + title = pluralStringResource( + R.plurals.deleteMessage, + dialogsState.deleteEveryone.messages.size, + dialogsState.deleteEveryone.messages.size + ), + text = pluralStringResource( + R.plurals.deleteMessageConfirm, + dialogsState.deleteEveryone.messages.size, + dialogsState.deleteEveryone.messages.size + ), + content = { + // add warning text, if any + dialogsState.deleteEveryone.warning?.let { + Text( + text = it, + textAlign = TextAlign.Center, + style = LocalType.current.small, + color = LocalColors.current.warning, + modifier = Modifier.padding( + top = LocalDimensions.current.xxxsSpacing, + bottom = LocalDimensions.current.xxsSpacing + ) + ) + } + + TitledRadioButton( + contentPadding = PaddingValues( + horizontal = LocalDimensions.current.xxsSpacing, + vertical = 0.dp + ), + option = RadioOption( + value = Unit, + title = GetString(stringResource(R.string.deleteMessageDeviceOnly)), + selected = !deleteForEveryone + ) + ) { + deleteForEveryone = false + } + + TitledRadioButton( + contentPadding = PaddingValues( + horizontal = LocalDimensions.current.xxsSpacing, + vertical = 0.dp + ), + option = RadioOption( + value = Unit, + title = GetString(stringResource(R.string.deleteMessageEveryone)), + selected = deleteForEveryone, + enabled = dialogsState.deleteEveryone.everyoneEnabled + ) + ) { + deleteForEveryone = true + } + }, + buttons = listOf( + DialogButtonModel( + text = GetString(stringResource(id = R.string.delete)), + color = LocalColors.current.danger, + onClick = { + // delete messages based on chosen option + sendCommand( + if(deleteForEveryone) MarkAsDeletedForEveryone( + dialogsState.deleteEveryone.copy(defaultToEveryone = deleteForEveryone) + ) + else MarkAsDeletedLocally(dialogsState.deleteEveryone.messages) + ) + } + ), + DialogButtonModel( + GetString(stringResource(R.string.cancel)) + ) + ) + ) + } + + // delete message(s) for all my devices + if(dialogsState.deleteAllDevices != null){ + var deleteAllDevices by remember { mutableStateOf(dialogsState.deleteAllDevices.defaultToEveryone) } + + AlertDialog( + onDismissRequest = { + // hide dialog + sendCommand(HideDeleteAllDevicesDialog) + }, + title = pluralStringResource( + R.plurals.deleteMessage, + dialogsState.deleteAllDevices.messages.size, + dialogsState.deleteAllDevices.messages.size + ), + text = pluralStringResource( + R.plurals.deleteMessageConfirm, + dialogsState.deleteAllDevices.messages.size, + dialogsState.deleteAllDevices.messages.size + ), + content = { + TitledRadioButton( + contentPadding = PaddingValues( + horizontal = LocalDimensions.current.xxsSpacing, + vertical = 0.dp + ), + option = RadioOption( + value = Unit, + title = GetString(stringResource(R.string.deleteMessageDeviceOnly)), + selected = !deleteAllDevices + ) + ) { + deleteAllDevices = false + } + + TitledRadioButton( + contentPadding = PaddingValues( + horizontal = LocalDimensions.current.xxsSpacing, + vertical = 0.dp + ), + option = RadioOption( + value = Unit, + title = GetString(stringResource(R.string.deleteMessageDevicesAll)), + selected = deleteAllDevices + ) + ) { + deleteAllDevices = true + } + }, + buttons = listOf( + DialogButtonModel( + text = GetString(stringResource(id = R.string.delete)), + color = LocalColors.current.danger, + onClick = { + // delete messages based on chosen option + sendCommand( + if(deleteAllDevices) MarkAsDeletedForEveryone( + dialogsState.deleteAllDevices.copy(defaultToEveryone = deleteAllDevices) + ) + else MarkAsDeletedLocally(dialogsState.deleteAllDevices.messages) + ) + } + ), + DialogButtonModel( + GetString(stringResource(R.string.cancel)) + ) + ) + ) + } + + } +} + +@Preview +@Composable +fun PreviewURLDialog(){ + PreviewTheme { + ConversationV2Dialogs( + dialogsState = ConversationViewModel.DialogsState( + openLinkDialogUrl = "https://google.com" + ), + sendCommand = {} + ) + } +} 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 514dc24ea6..1dbefe7bc0 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 @@ -1,6 +1,8 @@ package org.thoughtcrime.securesms.conversation.v2 +import android.app.Application import android.content.Context +import android.widget.Toast import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModelProvider import androidx.lifecycle.viewModelScope @@ -10,13 +12,14 @@ import dagger.assisted.AssistedInject import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.GlobalScope import kotlinx.coroutines.Job -import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.filterNotNull import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import network.loki.messenger.R import org.session.libsession.database.MessageDataProvider import org.session.libsession.messaging.messages.ExpirationConfiguration import org.session.libsession.messaging.open_groups.OpenGroup @@ -25,25 +28,32 @@ import org.session.libsession.messaging.sending_receiving.attachments.DatabaseAt import org.session.libsession.messaging.utilities.AccountId import org.session.libsession.messaging.utilities.SodiumUtilities import org.session.libsession.utilities.Address +import org.session.libsession.utilities.Address.Companion.fromSerialized +import org.session.libsession.utilities.TextSecurePreferences import org.session.libsession.utilities.recipients.Recipient +import org.session.libsession.utilities.recipients.MessageType +import org.session.libsession.utilities.recipients.getType import org.session.libsignal.utilities.IdPrefix import org.session.libsignal.utilities.Log -import org.thoughtcrime.securesms.database.MmsDatabase import org.thoughtcrime.securesms.audio.AudioSlidePlayer +import org.thoughtcrime.securesms.database.LokiMessageDatabase import org.thoughtcrime.securesms.database.Storage import org.thoughtcrime.securesms.database.model.MessageRecord import org.thoughtcrime.securesms.database.model.MmsMessageRecord import org.thoughtcrime.securesms.groups.OpenGroupManager +import org.thoughtcrime.securesms.mms.AudioSlide import org.thoughtcrime.securesms.repository.ConversationRepository import java.util.UUID class ConversationViewModel( val threadId: Long, val edKeyPair: KeyPair?, + private val application: Application, private val repository: ConversationRepository, private val storage: Storage, private val messageDataProvider: MessageDataProvider, - database: MmsDatabase, + private val lokiMessageDb: LokiMessageDatabase, + private val textSecurePreferences: TextSecurePreferences ) : ViewModel() { val showSendAfterApprovalText: Boolean @@ -52,8 +62,44 @@ class ConversationViewModel( private val _uiState = MutableStateFlow(ConversationUiState(conversationExists = true)) val uiState: StateFlow = _uiState + private val _dialogsState = MutableStateFlow(DialogsState()) + val dialogsState: StateFlow = _dialogsState + + private val _isAdmin = MutableStateFlow(false) + val isAdmin: StateFlow = _isAdmin + private var _recipient: RetrieveOnce = RetrieveOnce { - repository.maybeGetRecipientForThreadId(threadId) + val conversation = repository.maybeGetRecipientForThreadId(threadId) + + // set admin from current conversation + val conversationType = conversation?.getType() + // Determining is the current user is an admin will depend on the kind of conversation we are in + _isAdmin.value = when(conversationType) { + // for Groups V2 + MessageType.GROUPS_V2 -> { + //todo GROUPS V2 add logic where code is commented to determine if user is an admin + false // FANCHAO - properly set up admin for groups v2 here + } + + // for legacy groups, check if the user created the group + MessageType.LEGACY_GROUP -> { + // for legacy groups, we check if the current user is the one who created the group + run { + val localUserAddress = + textSecurePreferences.getLocalNumber() ?: return@run false + val group = storage.getGroup(conversation.address.toGroupString()) + group?.admins?.contains(fromSerialized(localUserAddress)) ?: false + } + } + + // for communities the the `isUserModerator` field + MessageType.COMMUNITY -> isUserCommunityManager() + + // false in other cases + else -> false + } + + conversation } val expirationConfiguration: ExpirationConfiguration? get() = storage.getExpirationConfiguration(threadId) @@ -180,18 +226,394 @@ class ConversationViewModel( repository.deleteThread(threadId) } - fun deleteLocally(message: MessageRecord) { - stopPlayingAudioMessage(message) - val recipient = recipient ?: return Log.w("Loki", "Recipient was null for delete locally action") - repository.deleteLocally(recipient, message) + fun handleMessagesDeletion(messages: Set){ + val conversation = recipient + if (conversation == null) { + Log.w("ConversationActivityV2", "Asked to delete messages but could not obtain viewModel recipient - aborting.") + return + } + + viewModelScope.launch(Dispatchers.IO) { + val allSentByCurrentUser = messages.all { it.isOutgoing } + + val conversationType = conversation.getType() + + // hashes are required if wanting to delete messages from the 'storage server' + // They are not required for communities OR if all messages are outgoing + // also we can only delete deleted messages (marked as deleted) locally + val canDeleteForEveryone = messages.all{ !it.isDeleted } && ( + messages.all { it.isOutgoing } || + conversationType == MessageType.COMMUNITY || + messages.all { lokiMessageDb.getMessageServerHash(it.id, it.isMms) != null + }) + + // There are three types of dialogs for deletion: + // 1- Delete on device only OR all devices - Used for Note to self + // 2- Delete on device only OR for everyone - Used for 'admins' or a user's own messages, as long as the message have a server hash + // 3- Delete on device only - Used otherwise + when { + // the conversation is a note to self + conversationType == MessageType.NOTE_TO_SELF -> { + _dialogsState.update { + it.copy(deleteAllDevices = DeleteForEveryoneDialogData( + messages = messages, + defaultToEveryone = false, + everyoneEnabled = true, + messageType = conversationType + ) + ) + } + } + + // If the user is an admin or is interacting with their own message And are allowed to delete for everyone + (isAdmin.value || allSentByCurrentUser) && canDeleteForEveryone -> { + _dialogsState.update { + it.copy( + deleteEveryone = DeleteForEveryoneDialogData( + messages = messages, + defaultToEveryone = isAdmin.value, + everyoneEnabled = true, + messageType = conversationType + ) + ) + } + } + + // for non admins, users interacting with someone else's message, or control messages + else -> { + _dialogsState.update { + it.copy( + deleteEveryone = DeleteForEveryoneDialogData( + messages = messages, + defaultToEveryone = false, + everyoneEnabled = false, // disable 'delete for everyone' - can only delete locally in this case + messageType = conversationType, + warning = application.resources.getQuantityString( + R.plurals.deleteMessageWarning, messages.count(), messages.count() + ) + ) + ) + } + } + } + } } + /** + * This delete the message locally only. + * Attachments and other related data will be removed from the db. + * If the messages were already marked as deleted they will be removed fully from the db, + * otherwise they will appear as a special type of message + * that says something like "This message was deleted" + */ + fun deleteLocally(messages: Set) { + // make sure to stop audio messages, if any + messages.filterIsInstance() + .mapNotNull { it.slideDeck.audioSlide } + .forEach(::stopMessageAudio) + + // if the message was already marked as deleted, remove it from the db instead + if(messages.all { it.isDeleted }){ + // Remove the message locally (leave nothing behind) + repository.deleteMessages(messages = messages, threadId = threadId) + } else { + // only mark as deleted (message remains behind with "This message was deleted on this device" ) + repository.markAsDeletedLocally( + messages = messages, + displayedMessage = application.getString(R.string.deleteMessageDeletedLocally) + ) + } + + // show confirmation toast + Toast.makeText( + application, + application.resources.getQuantityString(R.plurals.deleteMessageDeleted, messages.count(), messages.count()), + Toast.LENGTH_SHORT + ).show() + } + + /** + * This will mark the messages as deleted, for everyone. + * Attachments and other related data will be removed from the db, + * but the messages themselves won't be removed from the db. + * Instead they will appear as a special type of message + * that says something like "This message was deleted" + */ + 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 + data.messages.filterIsInstance() + .mapNotNull { it.slideDeck.audioSlide } + .forEach(::stopMessageAudio) + + // the exact logic for this will depend on the messages type + when(data.messageType){ + MessageType.NOTE_TO_SELF -> markAsDeletedForEveryoneNoteToSelf(data) + MessageType.ONE_ON_ONE -> markAsDeletedForEveryone1On1(data) + MessageType.LEGACY_GROUP -> markAsDeletedForEveryoneLegacyGroup(data.messages) + MessageType.GROUPS_V2 -> markAsDeletedForEveryoneGroupsV2(data) + MessageType.COMMUNITY -> markAsDeletedForEveryoneCommunity(data) + } + } + + private fun markAsDeletedForEveryoneNoteToSelf(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.deleteNoteToSelfMessagesRemotely(threadId, recipient!!, data.messages) + + // When this is done we simply need to remove the message locally (leave nothing behind) + repository.deleteMessages(messages = data.messages, threadId = threadId) + + // show confirmation toast + withContext(Dispatchers.Main) { + 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 + withContext(Dispatchers.Main) { + 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 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 + withContext(Dispatchers.Main) { + 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 + withContext(Dispatchers.Main) { + 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){ + if(recipient == null) return showMessage(application.getString(R.string.errorUnknown)) + + viewModelScope.launch(Dispatchers.IO) { + // delete remotely + try { + repository.deleteLegacyGroupMessagesRemotely(recipient!!, messages) + + // When this is done we simply need to remove the message locally + repository.markAsDeletedLocally( + messages = messages, + displayedMessage = application.getString(R.string.deleteMessageDeletedGlobally) + ) + + // show confirmation toast + withContext(Dispatchers.Main) { + Toast.makeText( + application, + application.resources.getQuantityString( + R.plurals.deleteMessageDeleted, + messages.count(), + messages.count() + ), + Toast.LENGTH_SHORT + ).show() + } + } catch (e: Exception) { + Log.w("Loki", "FAILED TO delete messages ${messages} ") + // failed to delete - show a toast and get back on the modal + withContext(Dispatchers.Main) { + Toast.makeText( + application, + application.resources.getQuantityString( + R.plurals.deleteMessageFailed, + messages.size, + messages.size + ), Toast.LENGTH_SHORT + ).show() + } + } + } + } + + 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 + withContext(Dispatchers.Main) { + 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 + withContext(Dispatchers.Main) { + 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 + withContext(Dispatchers.Main) { + 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 + withContext(Dispatchers.Main) { + 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 stopPlayingAudioMessage(message: MessageRecord) { + 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() } @@ -200,35 +622,13 @@ class ConversationViewModel( repository.setApproved(recipient, true) } - fun deleteForEveryone(message: MessageRecord) = viewModelScope.launch { - val recipient = recipient ?: return@launch Log.w("Loki", "Recipient was null for delete for everyone - aborting delete operation.") - stopPlayingAudioMessage(message) - - repository.deleteForEveryone(threadId, recipient, message) - .onSuccess { - Log.d("Loki", "Deleted message ${message.id} ") - stopPlayingAudioMessage(message) - } - .onFailure { - Log.w("Loki", "FAILED TO delete message ${message.id} ") - showMessage("Couldn't delete message due to error: $it") - } - } - - fun deleteMessagesWithoutUnsendRequest(messages: Set) = viewModelScope.launch { - repository.deleteMessageWithoutUnsendRequest(threadId, messages) - .onFailure { - showMessage("Couldn't delete message due to error: $it") - } - } - fun banUser(recipient: Recipient) = viewModelScope.launch { repository.banUser(threadId, recipient) .onSuccess { - showMessage("Successfully banned user") + showMessage(application.getString(R.string.banUserBanned)) } .onFailure { - showMessage("Couldn't ban user due to error: $it") + showMessage(application.getString(R.string.banErrorFailed)) } } @@ -237,13 +637,13 @@ class ConversationViewModel( repository.banAndDeleteAll(threadId, messageRecord.individualRecipient) .onSuccess { // At this point the server side messages have been successfully deleted.. - showMessage("Successfully banned user and deleted all their messages") + showMessage(application.getString(R.string.banUserBanned)) // ..so we can now delete all their messages in this thread from local storage & remove the views. repository.deleteAllLocalMessagesInThreadFromSenderOfMessage(messageRecord) } .onFailure { - showMessage("Couldn't execute request due to error: $it") + showMessage(application.getString(R.string.banErrorFailed)) } } @@ -256,7 +656,7 @@ class ConversationViewModel( } } .onFailure { - showMessage("Couldn't accept message request due to error: $it") + Log.w("", "Failed to accept message request: $it") } } @@ -306,6 +706,40 @@ class ConversationViewModel( attachmentDownloadHandler.onAttachmentDownloadRequest(attachment) } + fun onCommand(command: Commands) { + when (command) { + is Commands.ShowOpenUrlDialog -> { + _dialogsState.update { + it.copy(openLinkDialogUrl = command.url) + } + } + + is Commands.HideDeleteEveryoneDialog -> { + _dialogsState.update { + it.copy(deleteEveryone = null) + } + } + + is Commands.HideDeleteAllDevicesDialog -> { + _dialogsState.update { + it.copy(deleteAllDevices = null) + } + } + + is Commands.MarkAsDeletedLocally -> { + // hide dialog first + _dialogsState.update { + it.copy(deleteEveryone = null) + } + + deleteLocally(command.messages) + } + is Commands.MarkAsDeletedForEveryone -> { + markAsDeletedForEveryone(command.data) + } + } + } + @dagger.assisted.AssistedFactory interface AssistedFactory { fun create(threadId: Long, edKeyPair: KeyPair?): Factory @@ -315,23 +749,50 @@ class ConversationViewModel( class Factory @AssistedInject constructor( @Assisted private val threadId: Long, @Assisted private val edKeyPair: KeyPair?, + private val application: Application, private val repository: ConversationRepository, private val storage: Storage, - private val mmsDatabase: MmsDatabase, private val messageDataProvider: MessageDataProvider, + private val lokiMessageDb: LokiMessageDatabase, + private val textSecurePreferences: TextSecurePreferences ) : ViewModelProvider.Factory { override fun create(modelClass: Class): T { return ConversationViewModel( threadId = threadId, edKeyPair = edKeyPair, + application = application, repository = repository, storage = storage, messageDataProvider = messageDataProvider, - database = mmsDatabase + lokiMessageDb = lokiMessageDb, + textSecurePreferences = textSecurePreferences ) as T } } + + data class DialogsState( + val openLinkDialogUrl: String? = null, + val deleteEveryone: DeleteForEveryoneDialogData? = null, + val deleteAllDevices: DeleteForEveryoneDialogData? = null, + ) + + data class DeleteForEveryoneDialogData( + val messages: Set, + val messageType: MessageType, + val defaultToEveryone: Boolean, + val everyoneEnabled: Boolean, + val warning: String? = null + ) + + sealed class Commands { + data class ShowOpenUrlDialog(val url: String?) : Commands() + data object HideDeleteEveryoneDialog : Commands() + data object HideDeleteAllDevicesDialog : Commands() + + data class MarkAsDeletedLocally(val messages: Set): Commands() + data class MarkAsDeletedForEveryone(val data: DeleteForEveryoneDialogData): Commands() + } } data class UiMessage(val id: Long, val message: String) @@ -340,7 +801,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/conversation/v2/input_bar/InputBarButton.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/input_bar/InputBarButton.kt index d2ec4b2d69..c21de8021a 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/input_bar/InputBarButton.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/input_bar/InputBarButton.kt @@ -77,7 +77,9 @@ class InputBarButton : RelativeLayout { result.layoutParams = LayoutParams(size, size) result.scaleType = ImageView.ScaleType.CENTER_INSIDE result.setImageResource(iconID) - result.imageTintList = ColorStateList.valueOf(context.getColorFromAttr(R.attr.input_bar_button_text_color)) + result.imageTintList = if(isSendButton) + ColorStateList.valueOf(context.getColorFromAttr(R.attr.message_sent_text_color)) + else ColorStateList.valueOf(context.getColorFromAttr(R.attr.input_bar_button_text_color)) result } diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/menus/ConversationActionModeCallback.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/menus/ConversationActionModeCallback.kt index 21d5de52cf..abadc06335 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/menus/ConversationActionModeCallback.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/menus/ConversationActionModeCallback.kt @@ -10,6 +10,7 @@ import org.session.libsession.messaging.utilities.AccountId import org.session.libsession.messaging.utilities.SodiumUtilities import org.session.libsession.utilities.TextSecurePreferences import org.session.libsignal.utilities.IdPrefix +import org.session.libsignal.utilities.Log import org.thoughtcrime.securesms.conversation.v2.ConversationAdapter import org.thoughtcrime.securesms.database.model.MediaMmsMessageRecord import org.thoughtcrime.securesms.database.model.MessageRecord @@ -43,6 +44,9 @@ class ConversationActionModeCallback(private val adapter: ConversationAdapter, p // Embedded function fun userCanDeleteSelectedItems(): Boolean { + // admin can delete all combinations + if(adapter.isAdmin) return true + val allSentByCurrentUser = selectedItems.all { it.isOutgoing } val allReceivedByCurrentUser = selectedItems.all { !it.isOutgoing } if (openGroup == null) { return allSentByCurrentUser || allReceivedByCurrentUser } @@ -92,7 +96,7 @@ class ConversationActionModeCallback(private val adapter: ConversationAdapter, p } override fun onActionItemClicked(mode: ActionMode, item: MenuItem): Boolean { - val selectedItems = adapter.selectedItems + val selectedItems = adapter.selectedItems.toSet() when (item.itemId) { R.id.menu_context_delete_message -> delegate?.deleteMessages(selectedItems) R.id.menu_context_ban_user -> delegate?.banUser(selectedItems) diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/messages/ControlMessageView.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/messages/ControlMessageView.kt index 1a7040b031..89591777be 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/messages/ControlMessageView.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/messages/ControlMessageView.kt @@ -6,6 +6,7 @@ import android.content.Intent import android.util.AttributeSet import android.util.Log import android.view.LayoutInflater +import android.view.View import android.widget.LinearLayout import androidx.core.content.res.ResourcesCompat import androidx.core.view.isGone @@ -54,11 +55,13 @@ class ControlMessageView : LinearLayout { @Inject lateinit var disappearingMessages: DisappearingMessages + val controlContentView: View get() = binding.controlContentView + init { layoutParams = RecyclerView.LayoutParams(RecyclerView.LayoutParams.MATCH_PARENT, RecyclerView.LayoutParams.WRAP_CONTENT) } - fun bind(message: MessageRecord, previous: MessageRecord?) { + fun bind(message: MessageRecord, previous: MessageRecord?, longPress: (() -> Unit)? = null) { binding.dateBreakTextView.showDateBreak(message, previous) binding.iconImageView.isGone = true binding.expirationTimerView.isGone = true @@ -84,7 +87,7 @@ class ControlMessageView : LinearLayout { && message.expiryMode != (MessagingModuleConfiguration.shared.storage.getExpirationConfiguration(message.threadId)?.expiryMode ?: ExpiryMode.NONE) && threadRecipient?.isGroupRecipient != true - followSetting.setOnClickListener { disappearingMessages.showFollowSettingDialog(context, message) } + binding.controlContentView.setOnClickListener { disappearingMessages.showFollowSettingDialog(context, message) } } } message.isMediaSavedNotification -> { @@ -128,7 +131,7 @@ class ControlMessageView : LinearLayout { } // remove clicks by default - setOnClickListener(null) + binding.controlContentView.setOnClickListener(null) hideInfo() // handle click behaviour depending on criteria @@ -138,7 +141,7 @@ class ControlMessageView : LinearLayout { // show a dedicated privacy dialog !TextSecurePreferences.isCallNotificationsEnabled(context) -> { showInfo() - setOnClickListener { + binding.controlContentView.setOnClickListener { context.showSessionDialog { val titleTxt = context.getSubbedString( R.string.callsMissedCallFrom, @@ -165,7 +168,7 @@ class ControlMessageView : LinearLayout { // show a dedicated permission dialog !Permissions.hasAll(context, Manifest.permission.RECORD_AUDIO) -> { showInfo() - setOnClickListener { + binding.controlContentView.setOnClickListener { context.showSessionDialog { val titleTxt = context.getSubbedString( R.string.callsMissedCallFrom, @@ -199,6 +202,14 @@ class ControlMessageView : LinearLayout { binding.textView.isGone = message.isCallLog binding.callView.isVisible = message.isCallLog + + // handle long clicked if it was passed on + longPress?.let { + binding.controlContentView.setOnLongClickListener { + longPress.invoke() + true + } + } } fun showInfo(){ diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/messages/DeletedMessageView.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/messages/DeletedMessageView.kt index 5b64df059e..7a495a9478 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/messages/DeletedMessageView.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/messages/DeletedMessageView.kt @@ -21,7 +21,8 @@ class DeletedMessageView : LinearLayout { // region Updating fun bind(message: MessageRecord, @ColorInt textColor: Int) { assert(message.isDeleted) - binding.deleteTitleTextView.text = context.resources.getQuantityString(R.plurals.deleteMessageDeleted, 1, 1) + // set the text to the message's body if it is set, else use a fallback + binding.deleteTitleTextView.text = message.body.ifEmpty { context.resources.getQuantityString(R.plurals.deleteMessageDeleted, 1, 1) } binding.deleteTitleTextView.setTextColor(textColor) binding.deletedMessageViewIconImageView.imageTintList = ColorStateList.valueOf(textColor) } diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/messages/VisibleMessageContentView.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/messages/VisibleMessageContentView.kt index d62cc532c4..d4949347a9 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/messages/VisibleMessageContentView.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/messages/VisibleMessageContentView.kt @@ -71,7 +71,6 @@ class VisibleMessageContentView : ConstraintLayout { binding.contentParent.mainColor = color binding.contentParent.cornerRadius = resources.getDimension(R.dimen.message_corner_radius) - val onlyBodyMessage = message is SmsMessageRecord val mediaThumbnailMessage = contactIsTrusted && message is MmsMessageRecord && message.slideDeck.thumbnailSlide != null // reset visibilities / containers @@ -80,6 +79,7 @@ class VisibleMessageContentView : ConstraintLayout { onContentDoubleTap = null if (message.isDeleted) { + binding.contentParent.isVisible = true binding.deletedMessageView.root.isVisible = true binding.deletedMessageView.root.bind(message, getTextColor(context, message)) binding.bodyTextView.isVisible = false diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/messages/VisibleMessageView.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/messages/VisibleMessageView.kt index 1734d75b08..f4ace02e1a 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/messages/VisibleMessageView.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/messages/VisibleMessageView.kt @@ -280,13 +280,12 @@ class VisibleMessageView : FrameLayout { // Get details regarding how we should display the message (it's delivery icon, icon tint colour, and // the resource string for what text to display (R.string.delivery_status_sent etc.). - val (iconID, iconColor, textId) = getMessageStatusInfo(message) - // If we get any nulls then a message isn't one with a state that we care about (i.e., control messages + // If we get a null messageStatus then the message isn't one with a state that we care about (i.e., control messages // etc.) - so bail. See: `DisplayRecord.is` for the full suite of message state methods. // Also: We set all delivery status elements visibility to false just to make sure we don't display any // stale data. - if (textId == null) return + val messageStatus = getMessageStatusInfo(message) ?: return binding.messageInnerLayout.modifyLayoutParams { gravity = if (message.isOutgoing) Gravity.END else Gravity.START @@ -295,16 +294,17 @@ class VisibleMessageView : FrameLayout { horizontalBias = if (message.isOutgoing) 1f else 0f } - // If the message is incoming AND it is not scheduled to disappear then don't show any status or timer details + // If the message is incoming AND it is not scheduled to disappear + // OR it is a deleted message then don't show any status or timer details val scheduledToDisappear = message.expiresIn > 0 - if (message.isIncoming && !scheduledToDisappear) return + if (message.isDeleted || message.isIncoming && !scheduledToDisappear) return // Set text & icons as appropriate for the message state. Note: Possible message states we care // about are: isFailed, isSyncFailed, isPending, isSyncing, isResyncing, isRead, and isSent. - textId.let(binding.messageStatusTextView::setText) - iconColor?.let(binding.messageStatusTextView::setTextColor) - iconID?.let { ContextCompat.getDrawable(context, it) } - ?.run { iconColor?.let { mutate().apply { setTint(it) } } ?: this } + messageStatus.messageText?.let(binding.messageStatusTextView::setText) + messageStatus.iconTint?.let(binding.messageStatusTextView::setTextColor) + messageStatus.iconId?.let { ContextCompat.getDrawable(context, it) } + ?.run { messageStatus.iconTint?.let { mutate().apply { setTint(it) } } ?: this } ?.let(binding.messageStatusImageView::setImageDrawable) // Potential options at this point are that the message is: @@ -381,7 +381,7 @@ class VisibleMessageView : FrameLayout { @ColorInt val iconTint: Int?, @StringRes val messageText: Int?) - private fun getMessageStatusInfo(message: MessageRecord): MessageStatusInfo = when { + private fun getMessageStatusInfo(message: MessageRecord): MessageStatusInfo? = when { message.isFailed -> MessageStatusInfo(R.drawable.ic_delivery_status_failed, getThemedColor(context, R.attr.danger), @@ -410,7 +410,7 @@ class VisibleMessageView : FrameLayout { ) } } - message.isSyncing || message.isResyncing -> + message.isResyncing -> MessageStatusInfo( R.drawable.ic_delivery_status_sending, context.getColorFromAttr(R.attr.message_status_color), @@ -422,16 +422,21 @@ class VisibleMessageView : FrameLayout { context.getColorFromAttr(R.attr.message_status_color), R.string.read ) - message.isSent -> + message.isSyncing || message.isSent -> // syncing should happen silently in the bg so we can mark it as sent MessageStatusInfo( R.drawable.ic_delivery_status_sent, context.getColorFromAttr(R.attr.message_status_color), R.string.disappearingMessagesSent ) + + // deleted messages do not have a status but we care about styling them so they need to return something + message.isDeleted -> + MessageStatusInfo(null, null, null) + else -> { // The message isn't one we care about for message statuses we display to the user (i.e., // control messages etc. - see the `DisplayRecord.is` suite of methods for options). - MessageStatusInfo(null, null, null) + null } } @@ -480,10 +485,13 @@ class VisibleMessageView : FrameLayout { // region Interaction @SuppressLint("ClickableViewAccessibility") override fun onTouchEvent(event: MotionEvent): Boolean { - if (onPress == null || onSwipeToReply == null || onLongPress == null) { return false } + if (onPress == null && onSwipeToReply == null && onLongPress == null) { return false } when (event.action) { MotionEvent.ACTION_DOWN -> onDown(event) - MotionEvent.ACTION_MOVE -> onMove(event) + MotionEvent.ACTION_MOVE -> { + // only bother with movements if we have swipe to reply + onSwipeToReply?.let { onMove(event) } + } MotionEvent.ACTION_CANCEL -> onCancel(event) MotionEvent.ACTION_UP -> onUp(event) } diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/MessagingDatabase.java b/app/src/main/java/org/thoughtcrime/securesms/database/MessagingDatabase.java index bc74496dda..2377ccf301 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/MessagingDatabase.java +++ b/app/src/main/java/org/thoughtcrime/securesms/database/MessagingDatabase.java @@ -46,7 +46,7 @@ public abstract class MessagingDatabase extends Database implements MmsSmsColumn public abstract void markUnidentified(long messageId, boolean unidentified); - public abstract void markAsDeleted(long messageId, boolean read, boolean hasMention); + public abstract void markAsDeleted(long messageId, boolean isOutgoing, String displayedMessage); public abstract boolean deleteMessage(long messageId); public abstract boolean deleteMessages(long[] messageId, long threadId); diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/MmsDatabase.kt b/app/src/main/java/org/thoughtcrime/securesms/database/MmsDatabase.kt index 5a2a9155de..63460744f6 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/MmsDatabase.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/database/MmsDatabase.kt @@ -304,18 +304,21 @@ class MmsDatabase(context: Context, databaseHelper: SQLCipherOpenHelper) : Messa db.update(TABLE_NAME, contentValues, ID_WHERE, arrayOf(messageId.toString())) } - override fun markAsDeleted(messageId: Long, read: Boolean, hasMention: Boolean) { + override fun markAsDeleted(messageId: Long, isOutgoing: Boolean, displayedMessage: String) { val database = databaseHelper.writableDatabase val contentValues = ContentValues() contentValues.put(READ, 1) - contentValues.put(BODY, "") + contentValues.put(BODY, displayedMessage) contentValues.put(HAS_MENTION, 0) database.update(TABLE_NAME, contentValues, ID_WHERE, arrayOf(messageId.toString())) val attachmentDatabase = get(context).attachmentDatabase() queue(Runnable { attachmentDatabase.deleteAttachmentsForMessage(messageId) }) val threadId = getThreadIdForMessage(messageId) - markAs(messageId, MmsSmsColumns.Types.BASE_DELETED_TYPE, threadId) + val deletedType = if (isOutgoing) { MmsSmsColumns.Types.BASE_DELETED_OUTGOING_TYPE} else { + MmsSmsColumns.Types.BASE_DELETED_INCOMING_TYPE + } + markAs(messageId, deletedType, threadId) } override fun markExpireStarted(messageId: Long, startedTimestamp: Long) { diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/MmsSmsColumns.java b/app/src/main/java/org/thoughtcrime/securesms/database/MmsSmsColumns.java index de5094fbd6..a5db6ca4d3 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/MmsSmsColumns.java +++ b/app/src/main/java/org/thoughtcrime/securesms/database/MmsSmsColumns.java @@ -42,6 +42,7 @@ public interface MmsSmsColumns { protected static final long JOINED_TYPE = 4; protected static final long FIRST_MISSED_CALL_TYPE = 5; + protected static final long BASE_DELETED_INCOMING_TYPE = 19; protected static final long BASE_INBOX_TYPE = 20; protected static final long BASE_OUTBOX_TYPE = 21; protected static final long BASE_SENDING_TYPE = 22; @@ -50,7 +51,7 @@ public interface MmsSmsColumns { protected static final long BASE_PENDING_SECURE_SMS_FALLBACK = 25; protected static final long BASE_PENDING_INSECURE_SMS_FALLBACK = 26; public static final long BASE_DRAFT_TYPE = 27; - protected static final long BASE_DELETED_TYPE = 28; + protected static final long BASE_DELETED_OUTGOING_TYPE = 28; protected static final long BASE_SYNCING_TYPE = 29; protected static final long BASE_RESYNCING_TYPE = 30; protected static final long BASE_SYNC_FAILED_TYPE = 31; @@ -61,6 +62,7 @@ public interface MmsSmsColumns { BASE_SENDING_TYPE, BASE_SENT_FAILED_TYPE, BASE_PENDING_SECURE_SMS_FALLBACK, BASE_PENDING_INSECURE_SMS_FALLBACK, + BASE_DELETED_OUTGOING_TYPE, OUTGOING_CALL_TYPE}; @@ -182,7 +184,9 @@ public interface MmsSmsColumns { return (type & BASE_TYPE_MASK) == BASE_INBOX_TYPE; } - public static boolean isDeletedMessage(long type) { return (type & BASE_TYPE_MASK) == BASE_DELETED_TYPE; } + public static boolean isDeletedMessage(long type) { + return (type & BASE_TYPE_MASK) == BASE_DELETED_OUTGOING_TYPE || (type & BASE_TYPE_MASK) == BASE_DELETED_INCOMING_TYPE; + } public static boolean isJoinedType(long type) { return (type & BASE_TYPE_MASK) == JOINED_TYPE; diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/ReactionDatabase.kt b/app/src/main/java/org/thoughtcrime/securesms/database/ReactionDatabase.kt index 87c0b6c182..150ec073e1 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/ReactionDatabase.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/database/ReactionDatabase.kt @@ -159,7 +159,7 @@ class ReactionDatabase(context: Context, helper: SQLCipherOpenHelper) : Database ) } - private fun deleteReactions(messageId: MessageId, query: String, args: Array, notifyUnread: Boolean) { + private fun deleteReactions(messageId: MessageId, query: String, args: Array, notifyUnread: Boolean) { writableDatabase.beginTransaction() try { writableDatabase.delete(TABLE_NAME, query, args) @@ -174,7 +174,54 @@ class ReactionDatabase(context: Context, helper: SQLCipherOpenHelper) : Database } finally { writableDatabase.endTransaction() } - } + } + + fun deleteMessageReactions(messageIds: List) { + if (messageIds.isEmpty()) return // Early exit if the list is empty + + val conditions = mutableListOf() + val args = mutableListOf() + + for (messageId in messageIds) { + conditions.add("($MESSAGE_ID = ? AND $IS_MMS = ?)") + args.add(messageId.id.toString()) + args.add(if (messageId.mms) "1" else "0") + } + + val query = conditions.joinToString(" OR ") + + deleteReactions( + messageIds = messageIds, + query = query, + args = args.toTypedArray(), + notifyUnread = false + ) + } + + private fun deleteReactions(messageIds: List, query: String, args: Array, notifyUnread: Boolean) { + writableDatabase.beginTransaction() + try { + writableDatabase.delete(TABLE_NAME, query, args) + + // Update unread status for each message + for (messageId in messageIds) { + val hasReaction = hasReactions(messageId) + if (messageId.mms) { + DatabaseComponent.get(context).mmsDatabase().updateReactionsUnread( + writableDatabase, messageId.id, hasReaction, true, notifyUnread + ) + } else { + DatabaseComponent.get(context).smsDatabase().updateReactionsUnread( + writableDatabase, messageId.id, hasReaction, true, notifyUnread + ) + } + } + + writableDatabase.setTransactionSuccessful() + } finally { + writableDatabase.endTransaction() + } + } private fun hasReactions(messageId: MessageId): Boolean { val query = "$MESSAGE_ID = ? AND $IS_MMS = ?" diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/SmsDatabase.java b/app/src/main/java/org/thoughtcrime/securesms/database/SmsDatabase.java index f02498112f..5088b76d29 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/SmsDatabase.java +++ b/app/src/main/java/org/thoughtcrime/securesms/database/SmsDatabase.java @@ -237,14 +237,17 @@ public class SmsDatabase extends MessagingDatabase { } @Override - public void markAsDeleted(long messageId, boolean read, boolean hasMention) { + public void markAsDeleted(long messageId, boolean isOutgoing, String displayedMessage) { SQLiteDatabase database = databaseHelper.getWritableDatabase(); ContentValues contentValues = new ContentValues(); contentValues.put(READ, 1); - contentValues.put(BODY, ""); + contentValues.put(BODY, displayedMessage); contentValues.put(HAS_MENTION, 0); database.update(TABLE_NAME, contentValues, ID_WHERE, new String[] {String.valueOf(messageId)}); - updateTypeBitmask(messageId, Types.BASE_TYPE_MASK, Types.BASE_DELETED_TYPE); + + updateTypeBitmask(messageId, Types.BASE_TYPE_MASK, + isOutgoing? MmsSmsColumns.Types.BASE_DELETED_OUTGOING_TYPE : MmsSmsColumns.Types.BASE_DELETED_INCOMING_TYPE + ); } @Override diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/Storage.kt b/app/src/main/java/org/thoughtcrime/securesms/database/Storage.kt index 28794e8ce6..a1903cc891 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/Storage.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/database/Storage.kt @@ -2,7 +2,6 @@ package org.thoughtcrime.securesms.database import android.content.Context import android.net.Uri -import network.loki.messenger.R import java.security.MessageDigest import network.loki.messenger.libsession_util.ConfigBase import network.loki.messenger.libsession_util.ConfigBase.Companion.PRIORITY_HIDDEN @@ -74,6 +73,8 @@ import org.session.libsession.utilities.SSKEnvironment.ProfileManagerProtocol.Co import org.session.libsession.utilities.TextSecurePreferences import org.session.libsession.utilities.recipients.Recipient import org.session.libsession.utilities.recipients.Recipient.DisappearingState +import org.session.libsession.utilities.recipients.MessageType +import org.session.libsession.utilities.recipients.getType import org.session.libsignal.crypto.ecc.DjbECPrivateKey import org.session.libsignal.crypto.ecc.DjbECPublicKey import org.session.libsignal.crypto.ecc.ECKeyPair @@ -758,6 +759,12 @@ open class Storage( return database.getMessageFor(timestamp, address)?.run { getId() to isMms } } + override fun getMessageType(timestamp: Long, author: String): MessageType? { + val database = DatabaseComponent.get(context).mmsSmsDatabase() + val address = fromSerialized(author) + return database.getMessageFor(timestamp, address)?.individualRecipient?.getType() + } + override fun updateSentTimestamp( messageID: Long, isMms: Boolean, @@ -1728,6 +1735,12 @@ open class Storage( DatabaseComponent.get(context).reactionDatabase().deleteMessageReactions(MessageId(messageId, mms)) } + override fun deleteReactions(messageIds: List, mms: Boolean) { + DatabaseComponent.get(context).reactionDatabase().deleteMessageReactions( + messageIds.map { MessageId(it, mms) } + ) + } + override fun setBlocked(recipients: Iterable, isBlocked: Boolean, fromConfigUpdate: Boolean) { val recipientDb = DatabaseComponent.get(context).recipientDatabase() recipientDb.setBlocked(recipients, isBlocked) diff --git a/app/src/main/java/org/thoughtcrime/securesms/home/HomeActivity.kt b/app/src/main/java/org/thoughtcrime/securesms/home/HomeActivity.kt index 2bb3d63cc0..5a19263cac 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/home/HomeActivity.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/home/HomeActivity.kt @@ -553,7 +553,6 @@ class HomeActivity : PassphraseRequiredActionBarActivity(), } else { showMuteDialog(this) { until -> lifecycleScope.launch(Dispatchers.IO) { - Log.d("", "**** until: $until") recipientDatabase.setMuted(thread.recipient, until) withContext(Dispatchers.Main) { binding.recyclerView.adapter!!.notifyDataSetChanged() diff --git a/app/src/main/java/org/thoughtcrime/securesms/notifications/PushReceiver.kt b/app/src/main/java/org/thoughtcrime/securesms/notifications/PushReceiver.kt index 8eaca4000b..e4a0fdc5a0 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/notifications/PushReceiver.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/notifications/PushReceiver.kt @@ -1,6 +1,9 @@ package org.thoughtcrime.securesms.notifications +import android.Manifest import android.content.Context +import android.content.pm.PackageManager +import androidx.core.app.ActivityCompat import androidx.core.app.NotificationCompat import androidx.core.app.NotificationManagerCompat import androidx.core.content.ContextCompat.getString @@ -30,27 +33,55 @@ private const val TAG = "PushHandler" class PushReceiver @Inject constructor(@ApplicationContext val context: Context) { private val json = Json { ignoreUnknownKeys = true } - fun onPush(dataMap: Map?) { - onPush(dataMap?.asByteArray()) + /** + * Both push services should hit this method once they receive notification data + * As long as it is properly formatted + */ + fun onPushDataReceived(dataMap: Map?) { + addMessageReceiveJob(dataMap?.asPushData()) } - fun onPush(data: ByteArray?) { - if (data == null) { - onPush() + /** + * This is a fallback method in case the Huawei data is malformated, + * but it shouldn't happen. Old code used to send different data so this is kept as a safety + */ + fun onPushDataReceived(data: ByteArray?) { + addMessageReceiveJob(PushData(data = data, metadata = null)) + } + + private fun addMessageReceiveJob(pushData: PushData?){ + // send a generic notification if we have no data + if (pushData?.data == null) { + sendGenericNotification() return } try { - val envelopeAsData = MessageWrapper.unwrap(data).toByteArray() - val job = BatchMessageReceiveJob(listOf(MessageReceiveParameters(envelopeAsData)), null) + val envelopeAsData = MessageWrapper.unwrap(pushData.data).toByteArray() + val job = BatchMessageReceiveJob(listOf( + MessageReceiveParameters( + data = envelopeAsData, + serverHash = pushData.metadata?.msg_hash + ) + ), null) JobQueue.shared.add(job) } catch (e: Exception) { Log.d(TAG, "Failed to unwrap data for message due to error.", e) } } - private fun onPush() { + private fun sendGenericNotification() { Log.d(TAG, "Failed to decode data for message.") + + // no need to do anything if notification permissions are not granted + if (ActivityCompat.checkSelfPermission( + context, + Manifest.permission.POST_NOTIFICATIONS + ) != PackageManager.PERMISSION_GRANTED + ) { + return + } + val builder = NotificationCompat.Builder(context, NotificationChannels.OTHER) .setSmallIcon(R.drawable.ic_notification) .setColor(context.getColor(R.color.textsecure_primary)) @@ -61,10 +92,11 @@ class PushReceiver @Inject constructor(@ApplicationContext val context: Context) .setPriority(NotificationCompat.PRIORITY_DEFAULT) .setAutoCancel(true) + NotificationManagerCompat.from(context).notify(11111, builder.build()) } - private fun Map.asByteArray() = + private fun Map.asPushData(): PushData = when { // this is a v2 push notification containsKey("spns") -> { @@ -72,14 +104,14 @@ class PushReceiver @Inject constructor(@ApplicationContext val context: Context) decrypt(Base64.decode(this["enc_payload"])) } catch (e: Exception) { Log.e(TAG, "Invalid push notification", e) - null + PushData(null, null) } } // old v1 push notification; we still need this for receiving legacy closed group notifications - else -> this["ENCRYPTED_DATA"]?.let(Base64::decode) + else -> PushData(this["ENCRYPTED_DATA"]?.let(Base64::decode), null) } - private fun decrypt(encPayload: ByteArray): ByteArray? { + private fun decrypt(encPayload: ByteArray): PushData { Log.d(TAG, "decrypt() called") val encKey = getOrCreateNotificationKey() @@ -95,9 +127,12 @@ class PushReceiver @Inject constructor(@ApplicationContext val context: Context) val metadataJson = (expectedList[0] as? BencodeString)?.value ?: error("no metadata") val metadata: PushNotificationMetadata = json.decodeFromString(String(metadataJson)) - return (expectedList.getOrNull(1) as? BencodeString)?.value.also { - // null content is valid only if we got a "data_too_long" flag - it?.let { check(metadata.data_len == it.size) { "wrong message data size" } } + return PushData( + data = (expectedList.getOrNull(1) as? BencodeString)?.value, + metadata = metadata + ).also { pushData -> + // null data content is valid only if we got a "data_too_long" flag + pushData.data?.let { check(metadata.data_len == it.size) { "wrong message data size" } } ?: check(metadata.data_too_long) { "missing message data, but no too-long flag" } } } @@ -115,4 +150,9 @@ class PushReceiver @Inject constructor(@ApplicationContext val context: Context) ) ) } + + data class PushData( + val data: ByteArray?, + val metadata: PushNotificationMetadata? + ) } diff --git a/app/src/main/java/org/thoughtcrime/securesms/preferences/ShareLogsDialog.kt b/app/src/main/java/org/thoughtcrime/securesms/preferences/ShareLogsDialog.kt index 29ce563c5d..89225d2562 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/preferences/ShareLogsDialog.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/preferences/ShareLogsDialog.kt @@ -115,7 +115,7 @@ class ShareLogsDialog(private val updateCallback: (Boolean)->Unit): DialogFragme } catch (e: Exception) { withContext(Main) { Log.e("Loki", "Error saving logs", e) - Toast.makeText(context,"Error saving logs", Toast.LENGTH_LONG).show() + Toast.makeText(context,getString(R.string.errorUnknown), Toast.LENGTH_LONG).show() } } }.also { shareJob -> 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 b472c2c0c0..4695e21825 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/repository/ConversationRepository.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/repository/ConversationRepository.kt @@ -1,18 +1,16 @@ package org.thoughtcrime.securesms.repository -import network.loki.messenger.libsession_util.util.ExpiryMode - import android.content.ContentResolver import android.content.Context import app.cash.copper.Query import app.cash.copper.flow.observeQuery import dagger.hilt.android.qualifiers.ApplicationContext -import kotlin.coroutines.resume -import kotlin.coroutines.suspendCoroutine import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.map +import network.loki.messenger.libsession_util.util.ExpiryMode import org.session.libsession.database.MessageDataProvider import org.session.libsession.messaging.messages.Destination +import org.session.libsession.messaging.messages.MarkAsDeletedMessage import org.session.libsession.messaging.messages.control.MessageRequestResponse import org.session.libsession.messaging.messages.control.UnsendRequest import org.session.libsession.messaging.messages.signal.OutgoingTextMessage @@ -21,12 +19,11 @@ 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.toHexString import org.thoughtcrime.securesms.database.DatabaseContentProviders import org.thoughtcrime.securesms.database.DraftDatabase import org.thoughtcrime.securesms.database.ExpirationConfigurationDatabase @@ -44,6 +41,8 @@ import org.thoughtcrime.securesms.database.model.ThreadRecord import org.thoughtcrime.securesms.dependencies.ConfigFactory import org.thoughtcrime.securesms.dependencies.DatabaseComponent import javax.inject.Inject +import kotlin.coroutines.resume +import kotlin.coroutines.suspendCoroutine interface ConversationRepository { fun maybeGetRecipientForThreadId(threadId: Long): Recipient? @@ -55,18 +54,35 @@ interface ConversationRepository { fun clearDrafts(threadId: Long) fun inviteContacts(threadId: Long, contacts: List) fun setBlocked(recipient: Recipient, blocked: Boolean) - fun deleteLocally(recipient: Recipient, message: MessageRecord) + fun markAsDeletedLocally(messages: Set, displayedMessage: String) + fun deleteMessages(messages: Set, threadId: Long) fun deleteAllLocalMessagesInThreadFromSenderOfMessage(messageRecord: MessageRecord) fun setApproved(recipient: Recipient, isApproved: Boolean) - suspend fun deleteForEveryone(threadId: Long, recipient: Recipient, message: MessageRecord): Result + + suspend fun deleteCommunityMessagesRemotely(threadId: Long, messages: Set) + suspend fun delete1on1MessagesRemotely( + threadId: Long, + recipient: Recipient, + messages: Set + ) + suspend fun deleteNoteToSelfMessagesRemotely( + threadId: Long, + recipient: Recipient, + messages: Set + ) + suspend fun deleteLegacyGroupMessagesRemotely( + recipient: Recipient, + messages: Set + ) + fun buildUnsendRequest(recipient: Recipient, message: MessageRecord): UnsendRequest? - suspend fun deleteMessageWithoutUnsendRequest(threadId: Long, messages: Set): Result suspend fun banUser(threadId: Long, recipient: Recipient): Result suspend fun banAndDeleteAll(threadId: Long, recipient: Recipient): Result suspend fun deleteThread(threadId: Long): Result suspend fun deleteMessageRequest(thread: ThreadRecord): Result suspend fun clearAllMessageRequests(block: Boolean): Result suspend fun acceptMessageRequest(threadId: Long, recipient: Recipient): Result + fun declineMessageRequest(threadId: Long) fun hasReceived(threadId: Long): Boolean } @@ -158,13 +174,57 @@ class DefaultConversationRepository @Inject constructor( storage.setBlocked(listOf(recipient), blocked) } - override fun deleteLocally(recipient: Recipient, message: MessageRecord) { - buildUnsendRequest(recipient, message)?.let { unsendRequest -> - textSecurePreferences.getLocalNumber()?.let { - MessageSender.send(unsendRequest, Address.fromSerialized(it)) - } + /** + * This will delete these messages from the db + * Not to be confused with 'marking messages as deleted' + */ + override fun deleteMessages(messages: Set, threadId: Long) { + // split the messages into mms and sms + val (mms, sms) = messages.partition { it.isMms } + + if(mms.isNotEmpty()){ + messageDataProvider.deleteMessages(mms.map { it.id }, threadId, isSms = false) + } + + if(sms.isNotEmpty()){ + messageDataProvider.deleteMessages(sms.map { it.id }, threadId, isSms = true) + } + } + + /** + * This will mark the messages as deleted. + * They won't be removed from the db but instead will appear as a special type + * of message that says something like "This message was deleted" + */ + override fun markAsDeletedLocally(messages: Set, displayedMessage: String) { + // split the messages into mms and sms + val (mms, sms) = messages.partition { it.isMms } + + if(mms.isNotEmpty()){ + messageDataProvider.markMessagesAsDeleted(mms.map { MarkAsDeletedMessage( + messageId = it.id, + isOutgoing = it.isOutgoing + ) }, + isSms = false, + displayedMessage = displayedMessage + ) + + // delete reactions + storage.deleteReactions(messageIds = mms.map { it.id }, mms = true) + } + + if(sms.isNotEmpty()){ + messageDataProvider.markMessagesAsDeleted(sms.map { MarkAsDeletedMessage( + messageId = it.id, + isOutgoing = it.isOutgoing + ) }, + isSms = true, + displayedMessage = displayedMessage + ) + + // delete reactions + storage.deleteReactions(messageIds = sms.map { it.id }, mms = false) } - messageDataProvider.deleteMessage(message.id, !message.isMms) } override fun deleteAllLocalMessagesInThreadFromSenderOfMessage(messageRecord: MessageRecord) { @@ -180,59 +240,82 @@ class DefaultConversationRepository @Inject constructor( storage.setRecipientApproved(recipient, isApproved) } - override suspend fun deleteForEveryone( + override suspend fun deleteCommunityMessagesRemotely( + threadId: Long, + messages: Set + ) { + val community = checkNotNull(lokiThreadDb.getOpenGroupChat(threadId)) { "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, - message: MessageRecord - ): Result = suspendCoroutine { continuation -> - buildUnsendRequest(recipient, message)?.let { unsendRequest -> - MessageSender.send(unsendRequest, recipient.address) - } + messages: Set + ) { + // delete the messages remotely + val publicKey = recipient.address.serialize() + val userAddress: Address? = textSecurePreferences.getLocalNumber()?.let { Address.fromSerialized(it) } - val openGroup = lokiThreadDb.getOpenGroupChat(threadId) - if (openGroup != null) { - val serverId = lokiMessageDb.getServerID(message.id, !message.isMms)?.let { messageServerID -> - OpenGroupApi.deleteMessage(messageServerID, openGroup.room, openGroup.server) - .success { - messageDataProvider.deleteMessage(message.id, !message.isMms) - continuation.resume(Result.success(Unit)) - }.fail { error -> - Log.w("TAG", "Call to OpenGroupApi.deleteForEveryone failed - attempting to resume..") - continuation.resume(Result.failure(error)) - } + messages.forEach { message -> + // delete from swarm + messageDataProvider.getServerHashForMessage(message.id, message.isMms) + ?.let { serverHash -> + SnodeAPI.deleteMessage(publicKey, listOf(serverHash)) + } + + // send an UnsendRequest to user's swarm + buildUnsendRequest(recipient, message)?.let { unsendRequest -> + userAddress?.let { MessageSender.send(unsendRequest, it) } } - // If the server ID is null then this message is stuck in limbo (it has likely been - // deleted remotely but that deletion did not occur locally) - so we'll delete the - // message locally to clean up. - if (serverId == null) { - Log.w("ConversationRepository","Found community message without a server ID - deleting locally.") + // send an UnsendRequest to recipient's swarm + buildUnsendRequest(recipient, message)?.let { unsendRequest -> + MessageSender.send(unsendRequest, recipient.address) + } + } + } - // Caution: The bool returned from `deleteMessage` is NOT "Was the message - // successfully deleted?" - it is "Was the thread itself also deleted because - // removing that message resulted in an empty thread?". - if (message.isMms) { - mmsDb.deleteMessage(message.id) - } else { - smsDb.deleteMessage(message.id) + override suspend fun deleteLegacyGroupMessagesRemotely( + recipient: Recipient, + messages: Set + ) { + if (recipient.isClosedGroupRecipient) { + val publicKey = recipient.address + + messages.forEach { message -> + // send an UnsendRequest to group's swarm + buildUnsendRequest(recipient, message)?.let { unsendRequest -> + MessageSender.send(unsendRequest, publicKey) } } } - else // If this thread is NOT in a Community - { - messageDataProvider.deleteMessage(message.id, !message.isMms) - messageDataProvider.getServerHashForMessage(message.id, message.isMms)?.let { serverHash -> - var publicKey = recipient.address.serialize() - if (recipient.isClosedGroupRecipient) { - publicKey = GroupUtil.doubleDecodeGroupID(publicKey).toHexString() + } + + override suspend fun deleteNoteToSelfMessagesRemotely( + 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)) } - SnodeAPI.deleteMessage(publicKey, listOf(serverHash)) - .success { - continuation.resume(Result.success(Unit)) - }.fail { error -> - Log.w("ConversationRepository", "Call to SnodeAPI.deleteMessage failed - attempting to resume..") - continuation.resume(Result.failure(error)) - } + + // send an UnsendRequest to user's swarm + buildUnsendRequest(recipient, message)?.let { unsendRequest -> + userAddress?.let { MessageSender.send(unsendRequest, it) } } } } @@ -246,38 +329,6 @@ class DefaultConversationRepository @Inject constructor( ) } - override suspend fun deleteMessageWithoutUnsendRequest( - threadId: Long, - messages: Set - ): Result = suspendCoroutine { continuation -> - val openGroup = lokiThreadDb.getOpenGroupChat(threadId) - if (openGroup != null) { - val messageServerIDs = mutableMapOf() - for (message in messages) { - val messageServerID = - lokiMessageDb.getServerID(message.id, !message.isMms) ?: continue - messageServerIDs[messageServerID] = message - } - messageServerIDs.forEach { (messageServerID, message) -> - OpenGroupApi.deleteMessage(messageServerID, openGroup.room, openGroup.server) - .success { - messageDataProvider.deleteMessage(message.id, !message.isMms) - }.fail { error -> - continuation.resume(Result.failure(error)) - } - } - } else { - for (message in messages) { - if (message.isMms) { - mmsDb.deleteMessage(message.id) - } else { - smsDb.deleteMessage(message.id) - } - } - } - continuation.resume(Result.success(Unit)) - } - override suspend fun banUser(threadId: Long, recipient: Recipient): Result = suspendCoroutine { continuation -> val accountID = recipient.address.toString() diff --git a/app/src/main/java/org/thoughtcrime/securesms/ui/components/RadioButton.kt b/app/src/main/java/org/thoughtcrime/securesms/ui/components/RadioButton.kt index cabe536767..d272a0d4f7 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/ui/components/RadioButton.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/ui/components/RadioButton.kt @@ -29,6 +29,7 @@ import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.RectangleShape import androidx.compose.ui.semantics.Role import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import network.loki.messenger.libsession_util.util.ExpiryMode import org.thoughtcrime.securesms.conversation.disappearingmessages.ExpiryType @@ -116,16 +117,20 @@ private fun RadioButtonIndicator( @Composable fun TitledRadioButton( modifier: Modifier = Modifier, + contentPadding: PaddingValues = PaddingValues( + horizontal = LocalDimensions.current.spacing, + vertical = LocalDimensions.current.smallSpacing + ), option: RadioOption, onClick: () -> Unit ) { RadioButton( - modifier = modifier.heightIn(min = 60.dp) + modifier = modifier .contentDescription(option.contentDescription), onClick = onClick, selected = option.selected, enabled = option.enabled, - contentPadding = PaddingValues(horizontal = LocalDimensions.current.spacing), + contentPadding = contentPadding, content = { Column( modifier = Modifier diff --git a/app/src/main/java/org/thoughtcrime/securesms/ui/theme/ThemeColors.kt b/app/src/main/java/org/thoughtcrime/securesms/ui/theme/ThemeColors.kt index 252497f023..c3d4709b7f 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/ui/theme/ThemeColors.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/ui/theme/ThemeColors.kt @@ -19,6 +19,7 @@ interface ThemeColors { val isLight: Boolean val primary: Color val danger: Color + val warning: Color val disabled: Color val background: Color val backgroundSecondary: Color @@ -100,6 +101,7 @@ fun dangerButtonColors() = ButtonDefaults.buttonColors( data class ClassicDark(override val primary: Color = primaryGreen) : ThemeColors { override val isLight = false override val danger = dangerDark + override val warning = primaryOrange override val disabled = disabledDark override val background = classicDark0 override val backgroundSecondary = classicDark1 @@ -118,6 +120,7 @@ data class ClassicDark(override val primary: Color = primaryGreen) : ThemeColors data class ClassicLight(override val primary: Color = primaryGreen) : ThemeColors { override val isLight = true override val danger = dangerLight + override val warning = primaryOrange override val disabled = disabledLight override val background = classicLight6 override val backgroundSecondary = classicLight5 @@ -136,6 +139,7 @@ data class ClassicLight(override val primary: Color = primaryGreen) : ThemeColor data class OceanDark(override val primary: Color = primaryBlue) : ThemeColors { override val isLight = false override val danger = dangerDark + override val warning = primaryOrange override val disabled = disabledDark override val background = oceanDark2 override val backgroundSecondary = oceanDark1 @@ -154,6 +158,7 @@ data class OceanDark(override val primary: Color = primaryBlue) : ThemeColors { data class OceanLight(override val primary: Color = primaryBlue) : ThemeColors { override val isLight = true override val danger = dangerLight + override val warning = primaryOrange override val disabled = disabledLight override val background = oceanLight7 override val backgroundSecondary = oceanLight6 diff --git a/app/src/main/res/drawable/ic_arrow_up_circle_24.xml b/app/src/main/res/drawable/ic_arrow_up_circle_24.xml deleted file mode 100644 index fc53bc0971..0000000000 --- a/app/src/main/res/drawable/ic_arrow_up_circle_24.xml +++ /dev/null @@ -1,11 +0,0 @@ - - - diff --git a/app/src/main/res/drawable/ic_copy.xml b/app/src/main/res/drawable/ic_copy.xml index d1d99d4327..4c6eec2c14 100644 --- a/app/src/main/res/drawable/ic_copy.xml +++ b/app/src/main/res/drawable/ic_copy.xml @@ -2,11 +2,12 @@ android:width="24dp" android:height="24dp" android:viewportWidth="50" - android:viewportHeight="50"> + android:viewportHeight="50" + android:tint="?attr/colorControlNormal"> + android:fillColor="#FFFFFF"/> + android:fillColor="#FFFFFF"/> diff --git a/app/src/main/res/drawable/ic_radio_selected.xml b/app/src/main/res/drawable/ic_radio_selected.xml index 82021810e9..4739fdcad6 100644 --- a/app/src/main/res/drawable/ic_radio_selected.xml +++ b/app/src/main/res/drawable/ic_radio_selected.xml @@ -10,4 +10,5 @@ android:strokeWidth="1" android:pathData="M13.567,13.713m-12.5,0a12.5,12.5 0,1 1,25 0a12.5,12.5 0,1 1,-25 0" android:strokeColor="?textColorAlert"/> - \ No newline at end of file + + diff --git a/app/src/main/res/drawable/ic_radio_unselected.xml b/app/src/main/res/drawable/ic_radio_unselected.xml index 285dbb276e..a1ff8671ac 100644 --- a/app/src/main/res/drawable/ic_radio_unselected.xml +++ b/app/src/main/res/drawable/ic_radio_unselected.xml @@ -8,4 +8,5 @@ android:pathData="M13.567,13.713m-12.5,0a12.5,12.5 0,1 1,25 0a12.5,12.5 0,1 1,-25 0" android:fillColor="#00000000" android:strokeColor="?textColorAlert"/> - \ No newline at end of file + + 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" /> + + + + + + diff --git a/app/src/main/res/layout/activity_home.xml b/app/src/main/res/layout/activity_home.xml index b47c67e5c9..8a2bc7180a 100644 --- a/app/src/main/res/layout/activity_home.xml +++ b/app/src/main/res/layout/activity_home.xml @@ -173,7 +173,8 @@ android:layout_centerHorizontal="true" android:layout_marginBottom="@dimen/new_conversation_button_bottom_offset" app:rippleColor="@color/button_primary_ripple" - android:src="@drawable/ic_plus" /> + android:src="@drawable/ic_plus" + android:tint="?message_sent_text_color"/> diff --git a/app/src/main/res/layout/mediasend_fragment.xml b/app/src/main/res/layout/mediasend_fragment.xml index 36e1e854f3..7224cabd19 100644 --- a/app/src/main/res/layout/mediasend_fragment.xml +++ b/app/src/main/res/layout/mediasend_fragment.xml @@ -118,10 +118,11 @@ android:id="@+id/mediasend_send_button" android:layout_width="match_parent" android:layout_height="match_parent" - android:scaleType="fitXY" + android:scaleType="fitCenter" + android:padding="10dp" android:contentDescription="@string/send" - android:src="?conversation_transport_sms_indicator" - android:background="@drawable/circle_touch_highlight_background"/> + android:src="@drawable/ic_arrow_up" + android:background="@drawable/accent_dot"/> diff --git a/app/src/main/res/layout/view_control_message.xml b/app/src/main/res/layout/view_control_message.xml index 5d79f1908a..0ca39ca387 100644 --- a/app/src/main/res/layout/view_control_message.xml +++ b/app/src/main/res/layout/view_control_message.xml @@ -27,7 +27,7 @@ android:visibility="gone" app:tint="?android:textColorTertiary" tools:src="@drawable/ic_timer" - tools:visibility="visible"/> + tools:visibility="visible" /> + tools:visibility="visible" /> - - - + android:orientation="vertical"> + android:layout_height="wrap_content" + android:contentDescription="@string/AccessibilityId_control_message" + android:gravity="center" + android:textColor="?android:textColorTertiary" + android:textSize="@dimen/very_small_font_size" + tools:text="You disabled disappearing messages" /> - + - + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/view_deleted_message.xml b/app/src/main/res/layout/view_deleted_message.xml index ceb391a342..719783a0d9 100644 --- a/app/src/main/res/layout/view_deleted_message.xml +++ b/app/src/main/res/layout/view_deleted_message.xml @@ -2,12 +2,11 @@ + android:padding="@dimen/small_spacing"> - - diff --git a/app/src/main/res/values/styles.xml b/app/src/main/res/values/styles.xml index 7841191e1e..d951d99c40 100644 --- a/app/src/main/res/values/styles.xml +++ b/app/src/main/res/values/styles.xml @@ -116,7 +116,7 @@ @drawable/unimportant_dialog_text_button_background bold - +