feat(manager/social): chat purge

This commit is contained in:
rhunk
2023-10-15 00:30:31 +02:00
parent a38e96906d
commit c533983fb3
4 changed files with 200 additions and 16 deletions

View File

@ -1,21 +1,21 @@
package me.rhunk.snapenhance.ui.manager.sections.social package me.rhunk.snapenhance.ui.manager.sections.social
import androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.foundation.layout.* import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyListState import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.material3.Card import androidx.compose.material.icons.Icons
import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material.icons.filled.Close
import androidx.compose.material3.MaterialTheme import androidx.compose.material.icons.filled.Delete
import androidx.compose.material3.Text import androidx.compose.material.icons.filled.DeleteForever
import androidx.compose.material3.*
import androidx.compose.runtime.* import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.*
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlinx.coroutines.withTimeout
import me.rhunk.snapenhance.RemoteSideContext import me.rhunk.snapenhance.RemoteSideContext
import me.rhunk.snapenhance.bridge.snapclient.MessagingBridge import me.rhunk.snapenhance.bridge.snapclient.MessagingBridge
import me.rhunk.snapenhance.bridge.snapclient.types.Message import me.rhunk.snapenhance.bridge.snapclient.types.Message
@ -23,22 +23,177 @@ import me.rhunk.snapenhance.common.data.ContentType
import me.rhunk.snapenhance.common.data.SocialScope import me.rhunk.snapenhance.common.data.SocialScope
import me.rhunk.snapenhance.common.util.protobuf.ProtoReader import me.rhunk.snapenhance.common.util.protobuf.ProtoReader
import me.rhunk.snapenhance.common.util.snap.SnapWidgetBroadcastReceiverHelper import me.rhunk.snapenhance.common.util.snap.SnapWidgetBroadcastReceiverHelper
import me.rhunk.snapenhance.ui.util.AlertDialogs
import me.rhunk.snapenhance.ui.util.Dialog
class MessagingPreview( class MessagingPreview(
private val context: RemoteSideContext, private val context: RemoteSideContext,
private val scope: SocialScope, private val scope: SocialScope,
private val scopeId: String private val scopeId: String
) { ) {
private val alertDialogs by lazy { AlertDialogs(context.translation) }
private lateinit var coroutineScope: CoroutineScope private lateinit var coroutineScope: CoroutineScope
private lateinit var messagingBridge: MessagingBridge private lateinit var messagingBridge: MessagingBridge
private lateinit var previewScrollState: LazyListState private lateinit var previewScrollState: LazyListState
private val myUserId by lazy { messagingBridge.myUserId }
private var conversationId: String? = null private var conversationId: String? = null
private val messages = sortedMapOf<Long, Message>() private val messages = sortedMapOf<Long, Message>()
private var messageSize by mutableIntStateOf(0) private var messageSize by mutableIntStateOf(0)
private var lastMessageId = Long.MAX_VALUE private var lastMessageId = Long.MAX_VALUE
private val selectedMessages = mutableStateListOf<Long>()
private fun toggleSelectedMessage(messageId: Long) {
if (selectedMessages.contains(messageId)) selectedMessages.remove(messageId)
else selectedMessages.add(messageId)
}
@Composable
fun TopBarAction() {
var deletedMessageCount by remember { mutableIntStateOf(0) }
var messageDeleteJob by remember { mutableStateOf(null as Job?) }
fun deleteIndividualMessage(serverMessageId: Long) {
val message = messages[serverMessageId] ?: return
if (message.senderId != myUserId) return
val error = messagingBridge.updateMessage(conversationId, message.clientMessageId, "ERASE")
if (error != null) {
context.shortToast("Failed to delete message: $error")
} else {
coroutineScope.launch {
deletedMessageCount++
messages.remove(serverMessageId)
messageSize = messages.size
}
}
}
if (messageDeleteJob != null) {
Dialog(onDismissRequest = {
messageDeleteJob?.cancel()
messageDeleteJob = null
}) {
Card {
Column(
modifier = Modifier
.padding(20.dp)
.fillMaxWidth(),
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally
) {
Text("Deleting messages ($deletedMessageCount)")
Spacer(modifier = Modifier.height(10.dp))
CircularProgressIndicator(
modifier = Modifier
.padding()
.size(30.dp),
strokeWidth = 3.dp,
color = MaterialTheme.colorScheme.primary
)
}
}
}
}
if (selectedMessages.isNotEmpty()) {
IconButton(onClick = {
deletedMessageCount = 0
messageDeleteJob = coroutineScope.launch(Dispatchers.IO) {
selectedMessages.toList().also {
selectedMessages.clear()
}.forEach { messageId ->
deleteIndividualMessage(messageId)
}
}.apply {
invokeOnCompletion {
context.shortToast("Successfully deleted $deletedMessageCount messages")
messageDeleteJob = null
}
}
}) {
Icon(
imageVector = Icons.Filled.Delete,
contentDescription = "Delete"
)
}
IconButton(onClick = {
selectedMessages.clear()
}) {
Icon(
imageVector = Icons.Filled.Close,
contentDescription = "Close"
)
}
} else {
var deleteAllConfirmationDialog by remember { mutableStateOf(false) }
if (deleteAllConfirmationDialog) {
Dialog(onDismissRequest = { deleteAllConfirmationDialog = false }) {
alertDialogs.ConfirmDialog(title = "Are you sure you want to delete all your messages?", onDismiss = {
deleteAllConfirmationDialog = false
}, onConfirm = {
deletedMessageCount = 0
deleteAllConfirmationDialog = false
messageDeleteJob = coroutineScope.launch(Dispatchers.IO) {
var lastMessageId = Long.MAX_VALUE
do {
val fetchedMessages = messagingBridge.fetchConversationWithMessagesPaginated(
conversationId!!,
100,
lastMessageId
)
if (fetchedMessages == null) {
context.shortToast("Failed to fetch messages")
return@launch
}
if (fetchedMessages.isEmpty()) {
break
}
fetchedMessages.forEach {
deleteIndividualMessage(it.serverMessageId)
}
lastMessageId = fetchedMessages.first().clientMessageId
} while (true)
}.apply {
invokeOnCompletion {
messageDeleteJob = null
context.shortToast("Successfully deleted $deletedMessageCount messages")
}
}
})
}
}
IconButton(onClick = {
deleteAllConfirmationDialog = true
}) {
Icon(
imageVector = Icons.Filled.DeleteForever,
contentDescription = "Delete"
)
}
}
}
@Composable @Composable
private fun ConversationPreview() { private fun ConversationPreview() {
DisposableEffect(Unit) {
onDispose {
selectedMessages.clear()
}
}
LazyColumn( LazyColumn(
modifier = Modifier modifier = Modifier
.fillMaxSize(), .fillMaxSize(),
@ -64,12 +219,29 @@ class MessagingPreview(
} }
} }
items(messageSize) {index -> items(messageSize) {index ->
val elementKey = remember(index) { messages.entries.elementAt(index).key }
val messageReader = ProtoReader(messages.entries.elementAt(index).value.content) val messageReader = ProtoReader(messages.entries.elementAt(index).value.content)
val contentType = ContentType.fromMessageContainer(messageReader) val contentType = ContentType.fromMessageContainer(messageReader)
Card( Card(
modifier = Modifier modifier = Modifier
.padding(5.dp) .padding(5.dp)
.pointerInput(Unit) {
if (contentType == ContentType.STATUS) return@pointerInput
detectTapGestures(
onLongPress = {
toggleSelectedMessage(elementKey)
},
onTap = {
if (selectedMessages.isNotEmpty()) {
toggleSelectedMessage(elementKey)
}
}
)
},
colors = CardDefaults.cardColors(
containerColor = if (selectedMessages.contains(elementKey)) MaterialTheme.colorScheme.primaryContainer else MaterialTheme.colorScheme.surfaceVariant
),
) { ) {
Row( Row(
modifier = Modifier modifier = Modifier

View File

@ -47,6 +47,7 @@ class SocialSection : Section() {
} }
private var currentScopeContent: ScopeContent? = null private var currentScopeContent: ScopeContent? = null
private var currentMessagingPreview by mutableStateOf(null as MessagingPreview?)
private val addFriendDialog by lazy { private val addFriendDialog by lazy {
AddFriendDialog(context, this) AddFriendDialog(context, this)
@ -83,12 +84,16 @@ class SocialSection : Section() {
} }
} }
composable(MESSAGING_PREVIEW_ROUTE) { composable(MESSAGING_PREVIEW_ROUTE) { navBackStackEntry ->
val id = it.arguments?.getString("id") ?: return@composable val id = navBackStackEntry.arguments?.getString("id") ?: return@composable
val scope = it.arguments?.getString("scope") ?: return@composable val scope = navBackStackEntry.arguments?.getString("scope") ?: return@composable
remember { val messagePreview = remember {
MessagingPreview(context, SocialScope.getByName(scope), id) MessagingPreview(context, SocialScope.getByName(scope), id)
}.Content() }
LaunchedEffect(key1 = id) {
currentMessagingPreview = messagePreview
}
messagePreview.Content()
} }
} }
} }
@ -112,6 +117,10 @@ class SocialSection : Section() {
} }
} }
if (currentRoute == MESSAGING_PREVIEW_ROUTE) {
currentMessagingPreview?.TopBarAction()
}
if (currentRoute == SocialScope.FRIEND.tabRoute || currentRoute == SocialScope.GROUP.tabRoute) { if (currentRoute == SocialScope.FRIEND.tabRoute || currentRoute == SocialScope.GROUP.tabRoute) {
IconButton( IconButton(
onClick = { deleteConfirmDialog = true }, onClick = { deleteConfirmDialog = true },

View File

@ -4,13 +4,15 @@ import java.util.List;
import me.rhunk.snapenhance.bridge.snapclient.types.Message; import me.rhunk.snapenhance.bridge.snapclient.types.Message;
interface MessagingBridge { interface MessagingBridge {
String getMyUserId();
@nullable Message fetchMessage(String conversationId, String clientMessageId); @nullable Message fetchMessage(String conversationId, String clientMessageId);
@nullable Message fetchMessageByServerId(String conversationId, String serverMessageId); @nullable Message fetchMessageByServerId(String conversationId, String serverMessageId);
@nullable List<Message> fetchConversationWithMessagesPaginated(String conversationId, int limit, long beforeMessageId); @nullable List<Message> fetchConversationWithMessagesPaginated(String conversationId, int limit, long beforeMessageId);
@nullable String updateMessage(String conversationId, String clientMessageId, String messageUpdate); @nullable String updateMessage(String conversationId, long clientMessageId, String messageUpdate);
@nullable String getOneToOneConversationId(String userId); @nullable String getOneToOneConversationId(String userId);
} }

View File

@ -28,6 +28,7 @@ class CoreMessagingBridge(
private val context: ModContext private val context: ModContext
) : MessagingBridge.Stub() { ) : MessagingBridge.Stub() {
private val conversationManager get() = context.feature(Messaging::class).conversationManager private val conversationManager get() = context.feature(Messaging::class).conversationManager
override fun getMyUserId() = context.database.myUserId
override fun fetchMessage(conversationId: String, clientMessageId: String): Message? { override fun fetchMessage(conversationId: String, clientMessageId: String): Message? {
return runBlocking { return runBlocking {
@ -116,7 +117,7 @@ class CoreMessagingBridge(
override fun updateMessage( override fun updateMessage(
conversationId: String, conversationId: String,
clientMessageId: String, clientMessageId: Long,
messageUpdate: String messageUpdate: String
): String? { ): String? {
return runBlocking { return runBlocking {