commit 7fc3ec9d104c1df6c9a441736b7d9b60f69d89f5
parent b92378bffd59d6457f3c9c28c3f5cf92fa82ad34
Author: rhunk <101876869+rhunk@users.noreply.github.com>
Date:   Fri, 26 Apr 2024 16:34:14 +0200

feat(core): bulk clean conversations

Diffstat:
Dapp/src/main/kotlin/me/rhunk/snapenhance/messaging/MessagingTask.kt | 127-------------------------------------------------------------------------------
Mapp/src/main/kotlin/me/rhunk/snapenhance/ui/manager/pages/social/MessagingPreview.kt | 15+++++++++------
Acommon/src/main/kotlin/me/rhunk/snapenhance/common/messaging/MessagingTask.kt | 127+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcore/src/main/kotlin/me/rhunk/snapenhance/core/action/impl/BulkMessagingAction.kt | 218++++++++++++++++++++++++++++++++++++++++++++++++-------------------------------
4 files changed, 269 insertions(+), 218 deletions(-)

diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/messaging/MessagingTask.kt b/app/src/main/kotlin/me/rhunk/snapenhance/messaging/MessagingTask.kt @@ -1,126 +0,0 @@ -package me.rhunk.snapenhance.messaging - -import androidx.compose.runtime.MutableIntState -import kotlinx.coroutines.delay -import me.rhunk.snapenhance.bridge.snapclient.MessagingBridge -import me.rhunk.snapenhance.bridge.snapclient.types.Message -import me.rhunk.snapenhance.common.data.ContentType -import kotlin.random.Random - - -enum class MessagingTaskType( - val key: String -) { - SAVE("SAVE"), - UNSAVE("UNSAVE"), - DELETE("ERASE"), - READ("READ"), -} - -typealias MessagingTaskConstraint = Message.() -> Boolean - -object MessagingConstraints { - val USER_ID: (String) -> MessagingTaskConstraint = { userId: String -> - { - this.senderId == userId - } - } - val NO_USER_ID: (String) -> MessagingTaskConstraint = { userId: String -> - { - this.senderId != userId - } - } - val MY_USER_ID: (messagingBridge: MessagingBridge) -> MessagingTaskConstraint = { - val myUserId = it.myUserId - { - this.senderId == myUserId - } - } - val CONTENT_TYPE: (Array<ContentType>) -> MessagingTaskConstraint = { - val contentTypes = it.map { type -> type.id }; - { - contentTypes.contains(this.contentType) - } - } -} - -class MessagingTask( - private val messagingBridge: MessagingBridge, - private val conversationId: String, - val taskType: MessagingTaskType, - val constraints: List<MessagingTaskConstraint>, - private val processedMessageCount: MutableIntState, - val onSuccess: (message: Message) -> Unit = {}, - private val onFailure: (message: Message, reason: String) -> Unit = { _, _ -> }, - private val overrideClientMessageIds: List<Long>? = null, - private val amountToProcess: Int? = null, -) { - private suspend fun processMessages( - messages: List<Message> - ) { - messages.forEach { message -> - if (constraints.any { !it(message) }) { - return@forEach - } - - val error = messagingBridge.updateMessage(conversationId, message.clientMessageId, taskType.key) - error?.takeIf { error != "DUPLICATE_REQUEST" }?.let { - onFailure(message, error) - } - onSuccess(message) - processedMessageCount.intValue++ - delay(Random.nextLong(20, 50)) - } - } - - fun hasFixedGoal() = overrideClientMessageIds?.takeIf { it.isNotEmpty() } != null || amountToProcess?.takeIf { it > 0 } != null - - suspend fun run() { - var processedOverrideMessages = 0 - var lastMessageId = Long.MAX_VALUE - - do { - val fetchedMessages = messagingBridge.fetchConversationWithMessagesPaginated( - conversationId, - 100, - lastMessageId - ) ?: return - - if (fetchedMessages.isEmpty()) { - break - } - - lastMessageId = fetchedMessages.first().clientMessageId - - overrideClientMessageIds?.let { ids -> - fetchedMessages.retainAll { message -> - ids.contains(message.clientMessageId) - } - } - - amountToProcess?.let { amount -> - while (processedMessageCount.intValue + fetchedMessages.size > amount) { - fetchedMessages.removeLastOrNull() - } - } - - processMessages(fetchedMessages.reversed()) - - overrideClientMessageIds?.let { ids -> - processedOverrideMessages += fetchedMessages.count { message -> - ids.contains(message.clientMessageId) - } - - if (processedOverrideMessages >= ids.size) { - return - } - } - - amountToProcess?.let { amount -> - if (processedMessageCount.intValue >= amount) { - return - } - } - } while (true) - } -}- \ No newline at end of file diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/pages/social/MessagingPreview.kt b/app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/pages/social/MessagingPreview.kt @@ -33,12 +33,12 @@ import me.rhunk.snapenhance.common.Constants import me.rhunk.snapenhance.common.ReceiversConfig import me.rhunk.snapenhance.common.data.ContentType import me.rhunk.snapenhance.common.data.SocialScope +import me.rhunk.snapenhance.common.messaging.MessagingConstraints +import me.rhunk.snapenhance.common.messaging.MessagingTask +import me.rhunk.snapenhance.common.messaging.MessagingTaskConstraint +import me.rhunk.snapenhance.common.messaging.MessagingTaskType import me.rhunk.snapenhance.common.util.protobuf.ProtoReader import me.rhunk.snapenhance.common.util.snap.SnapWidgetBroadcastReceiverHelper -import me.rhunk.snapenhance.messaging.MessagingConstraints -import me.rhunk.snapenhance.messaging.MessagingTask -import me.rhunk.snapenhance.messaging.MessagingTaskConstraint -import me.rhunk.snapenhance.messaging.MessagingTaskType import me.rhunk.snapenhance.ui.manager.Routes import me.rhunk.snapenhance.ui.util.Dialog @@ -299,14 +299,17 @@ class MessagingPreview: Routes.Route() { else selectConstraintsDialog = true } ActionButton(text = translation[if (hasSelection) "mark_selection_as_seen_option" else "mark_all_as_seen_option"], icon = Icons.Rounded.RemoveRedEye) { - launchMessagingTask(MessagingTaskType.READ, listOf( + launchMessagingTask( + MessagingTaskType.READ, listOf( MessagingConstraints.NO_USER_ID(messagingBridge.myUserId), MessagingConstraints.CONTENT_TYPE(arrayOf(ContentType.SNAP)) )) runCurrentTask() } ActionButton(text = translation[if (hasSelection) "delete_selection_option" else "delete_all_option"], icon = Icons.Rounded.DeleteForever) { - launchMessagingTask(MessagingTaskType.DELETE, listOf(MessagingConstraints.USER_ID(messagingBridge.myUserId))) { message -> + launchMessagingTask(MessagingTaskType.DELETE, listOf(MessagingConstraints.USER_ID(messagingBridge.myUserId), { + contentType != ContentType.STATUS.id + })) { message -> coroutineScope.launch { message.contentType = ContentType.STATUS.id } diff --git a/common/src/main/kotlin/me/rhunk/snapenhance/common/messaging/MessagingTask.kt b/common/src/main/kotlin/me/rhunk/snapenhance/common/messaging/MessagingTask.kt @@ -0,0 +1,126 @@ +package me.rhunk.snapenhance.common.messaging + +import androidx.compose.runtime.MutableIntState +import kotlinx.coroutines.delay +import me.rhunk.snapenhance.bridge.snapclient.MessagingBridge +import me.rhunk.snapenhance.bridge.snapclient.types.Message +import me.rhunk.snapenhance.common.data.ContentType +import kotlin.random.Random + + +enum class MessagingTaskType( + val key: String +) { + SAVE("SAVE"), + UNSAVE("UNSAVE"), + DELETE("ERASE"), + READ("READ"), +} + +typealias MessagingTaskConstraint = Message.() -> Boolean + +object MessagingConstraints { + val USER_ID: (String) -> MessagingTaskConstraint = { userId: String -> + { + this.senderId == userId + } + } + val NO_USER_ID: (String) -> MessagingTaskConstraint = { userId: String -> + { + this.senderId != userId + } + } + val MY_USER_ID: (messagingBridge: MessagingBridge) -> MessagingTaskConstraint = { + val myUserId = it.myUserId + { + this.senderId == myUserId + } + } + val CONTENT_TYPE: (Array<ContentType>) -> MessagingTaskConstraint = { + val contentTypes = it.map { type -> type.id }; + { + contentTypes.contains(this.contentType) + } + } +} + +class MessagingTask( + private val messagingBridge: MessagingBridge, + private val conversationId: String, + val taskType: MessagingTaskType, + val constraints: List<MessagingTaskConstraint>, + private val processedMessageCount: MutableIntState, + val onSuccess: (message: Message) -> Unit = {}, + private val onFailure: (message: Message, reason: String) -> Unit = { _, _ -> }, + private val overrideClientMessageIds: List<Long>? = null, + private val amountToProcess: Int? = null, +) { + private suspend fun processMessages( + messages: List<Message> + ) { + messages.forEach { message -> + if (constraints.any { !it(message) }) { + return@forEach + } + + val error = messagingBridge.updateMessage(conversationId, message.clientMessageId, taskType.key) + error?.takeIf { error != "DUPLICATE_REQUEST" }?.let { + onFailure(message, error) + } + processedMessageCount.intValue++ + onSuccess(message) + delay(Random.nextLong(50, 80)) + } + } + + fun hasFixedGoal() = overrideClientMessageIds?.takeIf { it.isNotEmpty() } != null || amountToProcess?.takeIf { it > 0 } != null + + suspend fun run() { + var processedOverrideMessages = 0 + var lastMessageId = Long.MAX_VALUE + + do { + val fetchedMessages = messagingBridge.fetchConversationWithMessagesPaginated( + conversationId, + 100, + lastMessageId + ) ?: return + + if (fetchedMessages.isEmpty()) { + break + } + + lastMessageId = fetchedMessages.first().clientMessageId + + overrideClientMessageIds?.let { ids -> + fetchedMessages.retainAll { message -> + ids.contains(message.clientMessageId) + } + } + + amountToProcess?.let { amount -> + while (processedMessageCount.intValue + fetchedMessages.size > amount) { + fetchedMessages.removeLastOrNull() + } + } + + processMessages(fetchedMessages.reversed()) + + overrideClientMessageIds?.let { ids -> + processedOverrideMessages += fetchedMessages.count { message -> + ids.contains(message.clientMessageId) + } + + if (processedOverrideMessages >= ids.size) { + return + } + } + + amountToProcess?.let { amount -> + if (processedMessageCount.intValue >= amount) { + return + } + } + } while (true) + } +}+ \ No newline at end of file diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/core/action/impl/BulkMessagingAction.kt b/core/src/main/kotlin/me/rhunk/snapenhance/core/action/impl/BulkMessagingAction.kt @@ -3,7 +3,11 @@ package me.rhunk.snapenhance.core.action.impl import android.content.Context import android.graphics.Bitmap import android.graphics.BitmapFactory +import android.view.Gravity +import android.view.View +import android.widget.LinearLayout import android.widget.ProgressBar +import android.widget.TextView import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.Image import androidx.compose.foundation.background @@ -27,12 +31,15 @@ import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.Job import kotlinx.coroutines.delay import kotlinx.coroutines.launch import kotlinx.coroutines.withContext +import me.rhunk.snapenhance.common.data.ContentType import me.rhunk.snapenhance.common.data.FriendLinkType import me.rhunk.snapenhance.common.database.impl.FriendInfo +import me.rhunk.snapenhance.common.messaging.MessagingConstraints +import me.rhunk.snapenhance.common.messaging.MessagingTask +import me.rhunk.snapenhance.common.messaging.MessagingTaskType import me.rhunk.snapenhance.common.ui.createComposeAlertDialog import me.rhunk.snapenhance.common.util.ktx.copyToClipboard import me.rhunk.snapenhance.common.util.snap.BitmojiSelfie @@ -72,34 +79,45 @@ class BulkMessagingAction : AbstractAction() { ctx: Context, ids: List<String>, delay: Pair<Long, Long>, - action: (String) -> Unit = {}, - ): Job { - var index = 0 - val dialog = ViewAppearanceHelper.newAlertDialogBuilder(ctx) - .setTitle("...") - .setView(ProgressBar(ctx)) - .setCancelable(false) - .show() - - return context.coroutineScope.launch { - ids.forEach { id -> - runCatching { - action(id) - }.onFailure { - context.log.error("Failed to process $it", it) - context.shortToast("Failed to process $id") - } - index++ - withContext(Dispatchers.Main) { - dialog.setTitle( - translation.format("progress_status", "index" to index.toString(), "total" to ids.size.toString()) - ) - } - delay(Random.nextLong(delay.first, delay.second)) + action: suspend (id: String, setDialogMessage: (String) -> Unit) -> Unit = { _, _ -> } + ) = context.coroutineScope.launch { + val statusTextView = TextView(ctx) + val dialog = withContext(Dispatchers.Main) { + ViewAppearanceHelper.newAlertDialogBuilder(ctx) + .setTitle("...") + .setView(LinearLayout(ctx).apply { + orientation = LinearLayout.VERTICAL + gravity = Gravity.CENTER + addView(statusTextView.apply { + layoutParams = LinearLayout.LayoutParams(LinearLayout.LayoutParams.MATCH_PARENT, LinearLayout.LayoutParams.WRAP_CONTENT) + textAlignment = View.TEXT_ALIGNMENT_CENTER + }) + addView(ProgressBar(ctx)) + }) + .setCancelable(false) + .show() + } + + ids.forEachIndexed { index, id -> + launch(Dispatchers.Main) { + dialog.setTitle( + translation.format("progress_status", "index" to (index + 1).toString(), "total" to ids.size.toString()) + ) } - withContext(Dispatchers.Main) { - dialog.dismiss() + runCatching { + action(id) { + launch(Dispatchers.Main) { + statusTextView.text = it + } + } + }.onFailure { + context.log.error("Failed to process $it", it) + context.shortToast("Failed to process $id") } + delay(Random.nextLong(delay.first, delay.second)) + } + withContext(Dispatchers.Main) { + dialog.dismiss() } } @@ -378,20 +396,22 @@ class BulkMessagingAction : AbstractAction() { Text(text = (friendInfo.displayName ?: friendInfo.mutableUsername).toString(), fontSize = 16.sp, fontWeight = FontWeight.Bold, overflow = TextOverflow.Ellipsis, maxLines = 1) Text(text = friendInfo.mutableUsername.toString(), fontSize = 10.sp, fontWeight = FontWeight.Light, overflow = TextOverflow.Ellipsis, maxLines = 1) } - Text(text = "Relationship: " + remember(friendInfo) { - context.translation["friendship_link_type.${FriendLinkType.fromValue(friendInfo.friendLinkType).shortName}"] - }, fontSize = 12.sp, fontWeight = FontWeight.Light) - remember(friendInfo) { friendInfo.addedTimestamp.takeIf { it > 0L }?.let { - DateFormat.getDateTimeInstance().format(Date(friendInfo.addedTimestamp)) - } }?.let { - Text(text = "Added $it", fontSize = 12.sp, fontWeight = FontWeight.Light) - } - remember(friendInfo) { friendInfo.snapScore.takeIf { it > 0 } }?.let { - Text(text = "Snap Score: $it", fontSize = 12.sp, fontWeight = FontWeight.Light) - } - remember(friendInfo) { friendInfo.streakLength.takeIf { it > 0 } }?.let { - Text(text = "Streaks length: $it", fontSize = 12.sp, fontWeight = FontWeight.Light) + val userInfo = remember(friendInfo) { + buildString { + append("Relationship: ") + append(context.translation["friendship_link_type.${FriendLinkType.fromValue(friendInfo.friendLinkType).shortName}"]) + friendInfo.addedTimestamp.takeIf { it > 0L }?.let { + append("\nAdded ${DateFormat.getDateTimeInstance().format(Date(it))}") + } + friendInfo.snapScore.takeIf { it > 0 }?.let { + append("\nSnap Score: $it") + } + friendInfo.streakLength.takeIf { it > 0 }?.let { + append("\nStreaks length: $it") + } + } } + Text(text = userInfo, fontSize = 12.sp, fontWeight = FontWeight.Light, lineHeight = 16.sp, overflow = TextOverflow.Ellipsis) } Checkbox( @@ -421,56 +441,65 @@ class BulkMessagingAction : AbstractAction() { val ctx = LocalContext.current - Column( - modifier = Modifier.fillMaxWidth(), - ) { - Button( - modifier = Modifier - .fillMaxWidth() - .padding(2.dp), - onClick = { - showConfirmationDialog = true - action = { - val messaging = context.feature(Messaging::class) - messaging.conversationManager?.apply { - getOneOnOneConversationIds(selectedFriends, onError = { error -> - context.shortToast("Failed to fetch conversations: $error") - }, onSuccess = { conversations -> - context.runOnUiThread { - removeAction(ctx, conversations.map { it.second }.distinct(), delay = 100L to 400L) { - messaging.clearConversationFromFeed(it, onError = { error -> - context.shortToast("Failed to clear conversation: $error") - }) - }.invokeOnCompletion { - coroutineScope.launch { refreshList() } - } - } - }) - selectedFriends.clear() + val actions = remember { + mapOf<() -> String, () -> Unit>( + { "Clean " + selectedFriends.size + " conversations" } to { + context.feature(Messaging::class).conversationManager?.getOneOnOneConversationIds(selectedFriends.toList().also { + selectedFriends.clear() + }, onError = { error -> + context.shortToast("Failed to fetch conversations: $error") + }, onSuccess = { conversations -> + removeAction(ctx, conversations.map { it.second }.distinct(), delay = 10L to 40L) { conversationId, setDialogMessage -> + cleanConversation( + conversationId, setDialogMessage + ) + }.invokeOnCompletion { + coroutineScope.launch { refreshList() } } + }) + }, + { "Remove " + selectedFriends.size + " friends" } to { + removeAction(ctx, selectedFriends.toList().also { + selectedFriends.clear() + }, delay = 500L to 1200L) { userId, _ -> removeFriend(userId) }.invokeOnCompletion { + coroutineScope.launch { refreshList() } } }, - enabled = selectedFriends.isNotEmpty() - ) { - Text(text = "Clear " + selectedFriends.size + " conversations") - } - Button( - modifier = Modifier - .fillMaxWidth() - .padding(2.dp), - onClick = { - showConfirmationDialog = true - action = { - removeAction(ctx, selectedFriends.toList().also { - selectedFriends.clear() - }, delay = 500L to 1200L) { removeFriend(it) }.invokeOnCompletion { + { "Clean " + selectedFriends.size + " conversations and remove " + selectedFriends.size + " friends" } to { + context.feature(Messaging::class).conversationManager?.getOneOnOneConversationIds(selectedFriends.toList().also { + selectedFriends.clear() + }, onError = { error -> + context.shortToast("Failed to fetch conversations: $error") + }, onSuccess = { conversations -> + removeAction(ctx, conversations.map { it.second }.distinct(), delay = 500L to 1200L) { conversationId, setDialogMessage -> + cleanConversation( + conversationId, setDialogMessage + ) + removeFriend(conversations.firstOrNull { it.second == conversationId }?.first ?: return@removeAction) + }.invokeOnCompletion { coroutineScope.launch { refreshList() } } - } - }, - enabled = selectedFriends.isNotEmpty() - ) { - Text(text = "Remove " + selectedFriends.size + " friends") + }) + } + ) + } + + Column( + modifier = Modifier.fillMaxWidth(), + ) { + actions.forEach { (text, actionFunction) -> + Button( + modifier = Modifier + .fillMaxWidth() + .padding(2.dp), + onClick = { + showConfirmationDialog = true + action = actionFunction + }, + enabled = selectedFriends.isNotEmpty() + ) { + Text(text = remember(selectedFriends.size) { text() }) + } } } } @@ -520,4 +549,23 @@ class BulkMessagingAction : AbstractAction() { }.invoke(completable) } } + + private suspend fun cleanConversation( + conversationId: String, + setDialogMessage: (String) -> Unit + ) { + val messageCount = mutableIntStateOf(0) + MessagingTask( + context.messagingBridge, + conversationId, + taskType = MessagingTaskType.DELETE, + constraints = listOf(MessagingConstraints.MY_USER_ID(context.messagingBridge), { + contentType != ContentType.STATUS.id + }), + processedMessageCount = messageCount, + onSuccess = { + setDialogMessage("${messageCount.intValue} deleted messages") + }, + ).run() + } }