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:
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()
+ }
}