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