mirror of
https://github.com/rhunk/SnapEnhance.git
synced 2025-06-13 13:47:47 +02:00
feat(core): compose bulk messaging action
This commit is contained in:
@ -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()
|
||||
}
|
||||
.setNegativeButton(context.translation["button.negative"]) { _, _ -> }
|
||||
.show()
|
||||
}
|
||||
|
||||
override fun run() {
|
||||
val userIdBlacklist = arrayOf(
|
||||
context.database.myUserId,
|
||||
"b42f1f70-5a8b-4c53-8c25-34e7ec9e6781", // myai
|
||||
"84ee8839-3911-492d-8b94-72dd80f3713a", // teamsnapchat
|
||||
)
|
||||
}
|
||||
|
||||
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
|
||||
@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()
|
||||
}
|
||||
|
||||
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!!)
|
||||
} else {
|
||||
selectedFriends.remove(friends[which].userId)
|
||||
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 }
|
||||
}
|
||||
.setPositiveButton(translation["selection_dialog_continue_button"]) { _, _ ->
|
||||
confirmationDialog {
|
||||
when (bulkAction) {
|
||||
EnumBulkAction.REMOVE_FRIENDS -> {
|
||||
removeAction(selectedFriends) {
|
||||
removeFriend(it)
|
||||
}
|
||||
}
|
||||
EnumBulkAction.CLEAR_CONVERSATIONS -> clearConversations(selectedFriends)
|
||||
if (sortReverseOrder) newFriends.reverse()
|
||||
withContext(Dispatchers.Main) {
|
||||
friends.addAll(newFriends)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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) }
|
||||
|
||||
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)
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
.setNegativeButton(context.translation["button.cancel"]) { dialog, _ ->
|
||||
dialog.dismiss()
|
||||
|
||||
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)
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
.setCancelable(false)
|
||||
.show()
|
||||
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Checkbox(
|
||||
checked = sortReverseOrder,
|
||||
onCheckedChange = { sortReverseOrder = it },
|
||||
)
|
||||
Text(text = "Reverse order", fontSize = 15.sp, fontWeight = FontWeight.Light)
|
||||
}
|
||||
}
|
||||
|
||||
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 {
|
||||
Text(text = "No friends found", fontSize = 12.sp, fontWeight = FontWeight.Light, modifier = Modifier.fillMaxWidth(), textAlign = TextAlign.Center)
|
||||
}
|
||||
}
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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")
|
||||
}
|
||||
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")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
Reference in New Issue
Block a user