commit fa7071284ff012f831818fd668c06552d9bbb6bd
parent edda0d1488990bbaaa2bacb77f7b2310ef47d6be
Author: rhunk <101876869+rhunk@users.noreply.github.com>
Date:   Sat, 17 Feb 2024 17:58:37 +0100

feat(core): compose bulk messaging action

Diffstat:
Mcommon/src/main/kotlin/me/rhunk/snapenhance/common/data/SnapEnums.kt | 2+-
Mcommon/src/main/kotlin/me/rhunk/snapenhance/common/database/impl/FriendInfo.kt | 9+++++----
Mcommon/src/main/kotlin/me/rhunk/snapenhance/common/util/snap/BitmojiSelfie.kt | 4+++-
Mcore/src/main/kotlin/me/rhunk/snapenhance/core/action/impl/BulkMessagingAction.kt | 457+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--------------
4 files changed, 386 insertions(+), 86 deletions(-)

diff --git a/common/src/main/kotlin/me/rhunk/snapenhance/common/data/SnapEnums.kt b/common/src/main/kotlin/me/rhunk/snapenhance/common/data/SnapEnums.kt @@ -145,7 +145,7 @@ enum class FriendLinkType(val value: Int, val shortName: String) { companion object { fun fromValue(value: Int): FriendLinkType { - return entries.firstOrNull { it.value == value } ?: MUTUAL + return entries.firstOrNull { it.value == value } ?: SUGGESTED } } } diff --git a/common/src/main/kotlin/me/rhunk/snapenhance/common/database/impl/FriendInfo.kt b/common/src/main/kotlin/me/rhunk/snapenhance/common/database/impl/FriendInfo.kt @@ -3,6 +3,7 @@ package me.rhunk.snapenhance.common.database.impl import android.annotation.SuppressLint import android.database.Cursor import me.rhunk.snapenhance.common.database.DatabaseObject +import me.rhunk.snapenhance.common.util.ktx.getIntOrNull import me.rhunk.snapenhance.common.util.ktx.getInteger import me.rhunk.snapenhance.common.util.ktx.getLong import me.rhunk.snapenhance.common.util.ktx.getStringOrNull @@ -32,6 +33,7 @@ data class FriendInfo( var usernameForSorting: String? = null, var friendLinkType: Int = 0, var postViewEmoji: String? = null, + var businessCategory: Int = 0, ) : DatabaseObject { val mutableUsername get() = username?.split("|")?.last() val firstCreatedUsername get() = username?.split("|")?.first() @@ -61,10 +63,9 @@ data class FriendInfo( usernameForSorting = getStringOrNull("usernameForSorting") friendLinkType = getInteger("friendLinkType") postViewEmoji = getStringOrNull("postViewEmoji") - if (getColumnIndex("isPinnedBestFriend") != -1) isPinnedBestFriend = - getInteger("isPinnedBestFriend") - if (getColumnIndex("plusBadgeVisibility") != -1) plusBadgeVisibility = - getInteger("plusBadgeVisibility") + businessCategory = getIntOrNull("businessCategory") ?: 0 + isPinnedBestFriend = getIntOrNull("isPinnedBestFriend") ?: 0 + plusBadgeVisibility = getIntOrNull("plusBadgeVisibility") ?: 0 } } diff --git a/common/src/main/kotlin/me/rhunk/snapenhance/common/util/snap/BitmojiSelfie.kt b/common/src/main/kotlin/me/rhunk/snapenhance/common/util/snap/BitmojiSelfie.kt @@ -5,7 +5,8 @@ object BitmojiSelfie { val prefixUrl: String, ) { STANDARD("https://sdk.bitmoji.com/render/panel/"), - THREE_D("https://images.bitmoji.com/3d/render/") + THREE_D("https://images.bitmoji.com/3d/render/"), + NEW_THREE_D("https://images.bitmoji.com/3d/render/"), } fun getBitmojiSelfie(selfieId: String?, avatarId: String?, type: BitmojiSelfieType): String? { @@ -15,6 +16,7 @@ object BitmojiSelfie { return when (type) { BitmojiSelfieType.STANDARD -> "${type.prefixUrl}$selfieId-$avatarId-v1.webp?transparent=1" BitmojiSelfieType.THREE_D -> "${type.prefixUrl}$selfieId-$avatarId-v1.webp?trim=circle" + BitmojiSelfieType.NEW_THREE_D -> "${type.prefixUrl}$selfieId-$avatarId-v1.webp?trim=circle&ua=1" } } } \ 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 @@ -1,31 +1,77 @@ package me.rhunk.snapenhance.core.action.impl +import android.content.Context +import android.graphics.Bitmap +import android.graphics.BitmapFactory import android.widget.ProgressBar +import androidx.compose.foundation.Image +import androidx.compose.foundation.clickable +import androidx.compose.foundation.gestures.detectTapGestures +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.asImageBitmap +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +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.suspendCancellableCoroutine import kotlinx.coroutines.withContext import me.rhunk.snapenhance.common.data.FriendLinkType +import me.rhunk.snapenhance.common.database.impl.FriendInfo +import me.rhunk.snapenhance.common.ui.createComposeAlertDialog +import me.rhunk.snapenhance.common.util.ktx.copyToClipboard +import me.rhunk.snapenhance.common.util.snap.BitmojiSelfie import me.rhunk.snapenhance.core.action.AbstractAction import me.rhunk.snapenhance.core.features.impl.experiments.AddFriendSourceSpoof import me.rhunk.snapenhance.core.features.impl.messaging.Messaging -import me.rhunk.snapenhance.core.messaging.EnumBulkAction import me.rhunk.snapenhance.core.ui.ViewAppearanceHelper +import me.rhunk.snapenhance.core.util.EvictingMap import me.rhunk.snapenhance.mapper.impl.FriendRelationshipChangerMapper +import java.net.URL +import java.text.DateFormat +import java.util.Date class BulkMessagingAction : AbstractAction() { + enum class SortBy { + NONE, + USERNAME, + ADDED_TIMESTAMP, + SNAP_SCORE, + STREAK_LENGTH, + } + + enum class Filter { + ALL, + MY_FRIENDS, + BLOCKED, + REMOVED_ME, + DELETED, + SUGGESTED, + BUSINESS_ACCOUNTS, + } + private val translation by lazy { context.translation.getCategory("bulk_messaging_action") } - private fun removeAction(ids: List<String>, action: (String) -> Unit = {}) { + private fun removeAction(ctx: Context, ids: List<String>, action: (String) -> Unit = {}): Job { var index = 0 - val dialog = ViewAppearanceHelper.newAlertDialogBuilder(context.mainActivity) + val dialog = ViewAppearanceHelper.newAlertDialogBuilder(ctx) .setTitle("...") - .setView(ProgressBar(context.mainActivity)) + .setView(ProgressBar(ctx)) .setCancelable(false) .show() - context.coroutineScope.launch { + return context.coroutineScope.launch { ids.forEach { id -> runCatching { action(id) @@ -47,101 +93,352 @@ class BulkMessagingAction : AbstractAction() { } } - private suspend fun askActionType() = suspendCancellableCoroutine { cont -> - context.runOnUiThread { - ViewAppearanceHelper.newAlertDialogBuilder(context.mainActivity) - .setTitle(translation["choose_action_title"]) - .setItems(EnumBulkAction.entries.map { translation["actions.${it.key}"] }.toTypedArray()) { _, which -> - cont.resumeWith(Result.success(EnumBulkAction.entries[which])) + @Composable + private fun ConfirmationDialog( + onConfirm: () -> Unit, + onCancel: () -> Unit, + ) { + AlertDialog( + onDismissRequest = onCancel, + title = { Text(text = translation["confirmation_dialog.title"]) }, + text = { Text(text = translation["confirmation_dialog.message"]) }, + confirmButton = { + TextButton(onClick = onConfirm) { + Text(text = context.translation["button.positive"]) } - .setOnCancelListener { - cont.resumeWith(Result.success(null)) + }, + dismissButton = { + TextButton(onClick = onCancel) { + Text(text = context.translation["button.negative"]) } - .show() - } + } + ) } - private fun confirmationDialog(onConfirm: () -> Unit) { - ViewAppearanceHelper.newAlertDialogBuilder(context.mainActivity) - .setTitle(translation["confirmation_dialog.title"]) - .setMessage(translation["confirmation_dialog.message"]) - .setPositiveButton(context.translation["button.positive"]) { _, _ -> - onConfirm() + + @OptIn(ExperimentalMaterial3Api::class) + @Composable + private fun BulkMessagingDialog() { + var sortBy by remember { mutableStateOf(SortBy.USERNAME) } + var filter by remember { mutableStateOf(Filter.REMOVED_ME) } + var sortReverseOrder by remember { mutableStateOf(false) } + val selectedFriends = remember { mutableStateListOf<String>() } + val friends = remember { mutableStateListOf<FriendInfo>() } + val bitmojiCache = remember { EvictingMap<String, Bitmap>(50) } + val noBitmojiBitmap = remember { BitmapFactory.decodeResource(context.resources, android.R.drawable.ic_menu_report_image).asImageBitmap() } + + suspend fun refreshList() { + withContext(Dispatchers.Main) { + selectedFriends.clear() + friends.clear() } - .setNegativeButton(context.translation["button.negative"]) { _, _ -> } - .show() - } + withContext(Dispatchers.IO) { + val userIdBlacklist = arrayOf( + context.database.myUserId, + "b42f1f70-5a8b-4c53-8c25-34e7ec9e6781", // myai + "84ee8839-3911-492d-8b94-72dd80f3713a", // teamsnapchat + ) + val newFriends = context.database.getAllFriends().filter { + it.userId !in userIdBlacklist && when (filter) { + Filter.ALL -> true + Filter.MY_FRIENDS -> it.friendLinkType == FriendLinkType.MUTUAL.value && it.addedTimestamp > 0 + Filter.BLOCKED -> it.friendLinkType == FriendLinkType.BLOCKED.value + Filter.REMOVED_ME -> it.friendLinkType == FriendLinkType.OUTGOING.value && it.addedTimestamp > 0 && it.businessCategory == 0 // ignore followed accounts + Filter.SUGGESTED -> it.friendLinkType == FriendLinkType.SUGGESTED.value + Filter.DELETED -> it.friendLinkType == FriendLinkType.DELETED.value + Filter.BUSINESS_ACCOUNTS -> it.businessCategory > 0 + } + }.toMutableList() + when (sortBy) { + SortBy.NONE -> {} + SortBy.USERNAME -> newFriends.sortBy { it.mutableUsername } + SortBy.ADDED_TIMESTAMP -> newFriends.sortBy { it.addedTimestamp } + SortBy.SNAP_SCORE -> newFriends.sortBy { it.snapScore } + SortBy.STREAK_LENGTH -> newFriends.sortBy { it.streakLength } + } + if (sortReverseOrder) newFriends.reverse() + withContext(Dispatchers.Main) { + friends.addAll(newFriends) + } + } + } - override fun run() { - val userIdBlacklist = arrayOf( - context.database.myUserId, - "b42f1f70-5a8b-4c53-8c25-34e7ec9e6781", // myai - "84ee8839-3911-492d-8b94-72dd80f3713a", // teamsnapchat - ) + Column( + modifier = Modifier + .fillMaxWidth() + .padding(10.dp), + verticalArrangement = Arrangement.spacedBy(10.dp) + ) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceEvenly, + verticalAlignment = Alignment.CenterVertically + ) { + var filterMenuExpanded by remember { mutableStateOf(false) } - context.coroutineScope.launch(Dispatchers.Main) { - val bulkAction = askActionType() ?: return@launch - - val friends = context.database.getAllFriends().filter { - it.userId !in userIdBlacklist && - it.addedTimestamp != -1L && - it.friendLinkType == FriendLinkType.MUTUAL.value || - it.friendLinkType == FriendLinkType.OUTGOING.value - }.sortedByDescending { - it.friendLinkType == FriendLinkType.OUTGOING.value + ExposedDropdownMenuBox( + expanded = filterMenuExpanded, + onExpandedChange = { filterMenuExpanded = it }, + ) { + ElevatedCard( + modifier = Modifier.menuAnchor() + ) { + Text(text = filter.name, modifier = Modifier.padding(5.dp)) + } + + DropdownMenu( + expanded = filterMenuExpanded, + onDismissRequest = { filterMenuExpanded = false } + ) { + Filter.entries.forEach { entry -> + DropdownMenuItem(onClick = { + filter = entry + filterMenuExpanded = false + }, text = { + Text(text = entry.name, fontWeight = if (entry == filter) FontWeight.Bold else FontWeight.Normal) + }) + } + } + } + + var sortMenuExpanded by remember { mutableStateOf(false) } + + ExposedDropdownMenuBox( + expanded = sortMenuExpanded, + onExpandedChange = { sortMenuExpanded = it }, + ) { + ElevatedCard( + modifier = Modifier.menuAnchor() + ) { + Text(text = "Sort by", modifier = Modifier.padding(5.dp)) + } + + DropdownMenu( + expanded = sortMenuExpanded, + onDismissRequest = { sortMenuExpanded = false } + ) { + SortBy.entries.forEach { entry -> + DropdownMenuItem(onClick = { + sortBy = entry + sortMenuExpanded = false + }, text = { + Text(text = entry.name, fontWeight = if (entry == sortBy) FontWeight.Bold else FontWeight.Normal) + }) + } + } + } + + Row( + verticalAlignment = Alignment.CenterVertically + ) { + Checkbox( + checked = sortReverseOrder, + onCheckedChange = { sortReverseOrder = it }, + ) + Text(text = "Reverse order", fontSize = 15.sp, fontWeight = FontWeight.Light) + } } - val selectedFriends = mutableListOf<String>() - - ViewAppearanceHelper.newAlertDialogBuilder(context.mainActivity) - .setTitle(translation["actions.${bulkAction.key}"]) - .setMultiChoiceItems(friends.map { friend -> - (friend.displayName?.let { - "$it (${friend.mutableUsername})" - } ?: friend.mutableUsername) + - ": ${context.translation["friendship_link_type.${FriendLinkType.fromValue(friend.friendLinkType).shortName}"]}" - }.toTypedArray(), null) { _, which, isChecked -> - if (isChecked) { - selectedFriends.add(friends[which].userId!!) + LazyColumn( + modifier = Modifier + .fillMaxWidth() + .weight(1f), + verticalArrangement = Arrangement.spacedBy(2.dp) + ) { + item { + if (friends.isNotEmpty()) { + Row( + modifier = Modifier.fillMaxWidth().padding(bottom = 2.dp), + horizontalArrangement = Arrangement.End, + verticalAlignment = Alignment.CenterVertically, + ) { + Text(text = "Selected " + selectedFriends.size + " friends", fontSize = 12.sp, fontWeight = FontWeight.Light) + Checkbox( + checked = selectedFriends.size == friends.size, + onCheckedChange = { state -> + if (state) { + friends.mapNotNull { it.userId }.forEach { userId -> + if (!selectedFriends.contains(userId)) { + selectedFriends.add(userId) + } + } + } else selectedFriends.clear() + } + ) + } } else { - selectedFriends.remove(friends[which].userId) + Text(text = "No friends found", fontSize = 12.sp, fontWeight = FontWeight.Light, modifier = Modifier.fillMaxWidth(), textAlign = TextAlign.Center) } } - .setPositiveButton(translation["selection_dialog_continue_button"]) { _, _ -> - confirmationDialog { - when (bulkAction) { - EnumBulkAction.REMOVE_FRIENDS -> { - removeAction(selectedFriends) { - removeFriend(it) + items(friends) { friendInfo -> + var bitmojiBitmap by remember(friendInfo) { mutableStateOf(bitmojiCache[friendInfo.bitmojiAvatarId]) } + + fun selectFriend(state: Boolean) { + friendInfo.userId?.let { + if (state) { + selectedFriends.add(it) + } else { + selectedFriends.remove(it) + } + } + } + + Row( + modifier = Modifier + .fillMaxWidth() + .clickable { + selectFriend(!selectedFriends.contains(friendInfo.userId)) + }.pointerInput(Unit) { + detectTapGestures( + onLongPress = { context.androidContext.copyToClipboard(friendInfo.mutableUsername.toString()) } + ) + }, + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(10.dp) + ) { + LaunchedEffect(friendInfo) { + withContext(Dispatchers.IO) { + if (bitmojiBitmap != null || friendInfo.bitmojiAvatarId == null || friendInfo.bitmojiSelfieId == null) return@withContext + + val bitmojiUrl = BitmojiSelfie.getBitmojiSelfie(friendInfo.bitmojiSelfieId, friendInfo.bitmojiAvatarId, BitmojiSelfie.BitmojiSelfieType.NEW_THREE_D) ?: return@withContext + runCatching { + URL(bitmojiUrl).openStream().use { input -> + bitmojiCache[friendInfo.bitmojiAvatarId ?: return@withContext] = BitmapFactory.decodeStream(input) + } + bitmojiBitmap = bitmojiCache[friendInfo.bitmojiAvatarId ?: return@withContext] + }.onFailure { + context.log.error("Failed to load bitmoji", it) } } - EnumBulkAction.CLEAR_CONVERSATIONS -> clearConversations(selectedFriends) } + + Image( + bitmap = remember (bitmojiBitmap) { bitmojiBitmap?.asImageBitmap() ?: noBitmojiBitmap }, + contentDescription = null, + modifier = Modifier.size(35.dp) + ) + + Column( + modifier = Modifier.weight(1f), + ) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(3.dp), + verticalAlignment = Alignment.CenterVertically + ){ + 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) + } + } + + Checkbox( + checked = selectedFriends.contains(friendInfo.userId), + onCheckedChange = { selectFriend(it) } + ) + } + } + } + + var showConfirmationDialog by remember { mutableStateOf(false) } + var action by remember { mutableStateOf({}) } + + if (showConfirmationDialog) { + ConfirmationDialog( + onConfirm = { + action() + action = {} + showConfirmationDialog = false + }, + onCancel = { + action = {} + showConfirmationDialog = false } + ) + } + + 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()) { + messaging.clearConversationFromFeed(it, onError = { error -> + context.shortToast("Failed to clear conversation: $error") + }) + }.invokeOnCompletion { + context.coroutineScope.launch { refreshList() } + } + } + }) + selectedFriends.clear() + } + } + }, + enabled = selectedFriends.isNotEmpty() + ) { + Text(text = "Clear " + selectedFriends.size + " conversations") } - .setNegativeButton(context.translation["button.cancel"]) { dialog, _ -> - dialog.dismiss() + Button( + modifier = Modifier + .fillMaxWidth() + .padding(2.dp), + onClick = { + showConfirmationDialog = true + action = { + removeAction(ctx, selectedFriends.also { + selectedFriends.clear() + }) { removeFriend(it) }.invokeOnCompletion { + context.coroutineScope.launch { refreshList() } + } + } + }, + enabled = selectedFriends.isNotEmpty() + ) { + Text(text = "Remove " + selectedFriends.size + " friends") } - .setCancelable(false) - .show() + } + } + + LaunchedEffect(filter, sortBy, sortReverseOrder) { + refreshList() } } - private fun clearConversations(friendIds: List<String>) { - val messaging = context.feature(Messaging::class) - - messaging.conversationManager?.apply { - getOneOnOneConversationIds(friendIds, onError = { error -> - context.shortToast("Failed to fetch conversations: $error") - }, onSuccess = { conversations -> - context.runOnUiThread { - removeAction(conversations.map { it.second }.distinct()) { - messaging.clearConversationFromFeed(it, onError = { error -> - context.shortToast("Failed to clear conversation: $error") - }) - } - } - }) + override fun run() { + context.coroutineScope.launch(Dispatchers.Main) { + createComposeAlertDialog(context.mainActivity!!) { + BulkMessagingDialog() + }.apply { + setCanceledOnTouchOutside(false) + show() + } } }