commit a355ea966727fe86e35cd962dd2c9c59b649e8a8
parent 5ebd48cd9b1dc8367b2fd53b5a71d6d63109d5cf
Author: rhunk <101876869+rhunk@users.noreply.github.com>
Date:   Mon, 10 Jun 2024 14:38:33 +0200

feat(core): compose conversation preview

Signed-off-by: rhunk <101876869+rhunk@users.noreply.github.com>

Diffstat:
Mcommon/src/main/assets/lang/en_US.json | 7++-----
Mcommon/src/main/kotlin/me/rhunk/snapenhance/common/config/impl/MessagingTweaks.kt | 1-
Mcore/src/main/kotlin/me/rhunk/snapenhance/core/database/DatabaseAccess.kt | 7++++---
Mcore/src/main/kotlin/me/rhunk/snapenhance/core/ui/menu/impl/FriendFeedInfoMenu.kt | 251+++++++++++++++++++++++++++++++++++++++++++++++++++++--------------------------
4 files changed, 175 insertions(+), 91 deletions(-)

diff --git a/common/src/main/assets/lang/en_US.json b/common/src/main/assets/lang/en_US.json @@ -635,10 +635,6 @@ } } }, - "message_preview_length": { - "name": "Message Preview Length", - "description": "Specify the amount of messages to get previewed" - }, "call_start_confirmation": { "name": "Call Start Confirmation", "description": "Shows a confirmation dialog when starting a call" @@ -1443,7 +1439,8 @@ "streak_expiration": "expires in {day} days {hour} hours {minute} minutes", "total_messages": "Total sent/received messages: {count}", "title": "Preview", - "unknown_user": "Unknown User" + "unknown_user": "Unknown User", + "no_messages": "No messages found!" }, "profile_info": { diff --git a/common/src/main/kotlin/me/rhunk/snapenhance/common/config/impl/MessagingTweaks.kt b/common/src/main/kotlin/me/rhunk/snapenhance/common/config/impl/MessagingTweaks.kt @@ -62,7 +62,6 @@ class MessagingTweaks : ConfigContainer() { val loopMediaPlayback = boolean("loop_media_playback") { requireRestart() } val disableReplayInFF = boolean("disable_replay_in_ff") val halfSwipeNotifier = container("half_swipe_notifier", HalfSwipeNotifierConfig()) { requireRestart()} - val messagePreviewLength = integer("message_preview_length", defaultValue = 20) val callStartConfirmation = boolean("call_start_confirmation") { requireRestart() } val unlimitedConversationPinning = boolean("unlimited_conversation_pinning") { requireRestart() } val autoSaveMessagesInConversations = multiple("auto_save_messages_in_conversations", diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/core/database/DatabaseAccess.kt b/core/src/main/kotlin/me/rhunk/snapenhance/core/database/DatabaseAccess.kt @@ -351,12 +351,13 @@ class DatabaseAccess( fun getMessagesFromConversationId( conversationId: String, - limit: Int + limit: Int, + page: Int = 0, ): List<ConversationMessage>? { return useDatabase(DatabaseType.ARROYO)?.performOperation { safeRawQuery( - "SELECT * FROM conversation_message WHERE client_conversation_id = ? ORDER BY creation_timestamp DESC LIMIT ?", - arrayOf(conversationId, limit.toString()) + "SELECT * FROM conversation_message WHERE client_conversation_id = ? ORDER BY creation_timestamp DESC LIMIT ? OFFSET ?", + arrayOf(conversationId, limit.toString(), (limit * page).toString()) )?.use { query -> if (!query.moveToFirst()) { return@performOperation null diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/core/ui/menu/impl/FriendFeedInfoMenu.kt b/core/src/main/kotlin/me/rhunk/snapenhance/core/ui/menu/impl/FriendFeedInfoMenu.kt @@ -11,12 +11,14 @@ import android.widget.LinearLayout import androidx.compose.foundation.background 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.material.icons.Icons +import androidx.compose.material.icons.automirrored.outlined.Message import androidx.compose.material.icons.filled.CheckCircleOutline import androidx.compose.material.icons.filled.Info import androidx.compose.material.icons.filled.NotInterested -import androidx.compose.material.icons.outlined.EditNote -import androidx.compose.material.icons.outlined.RemoveRedEye +import androidx.compose.material.icons.outlined.* import androidx.compose.material3.* import androidx.compose.runtime.* import androidx.compose.ui.Alignment @@ -28,8 +30,12 @@ import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.font.Font import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp +import kotlinx.coroutines.Dispatchers +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.ConversationMessage @@ -37,6 +43,7 @@ import me.rhunk.snapenhance.common.database.impl.FriendInfo import me.rhunk.snapenhance.common.scripting.ui.EnumScriptInterface import me.rhunk.snapenhance.common.scripting.ui.InterfaceManager import me.rhunk.snapenhance.common.scripting.ui.ScriptInterface +import me.rhunk.snapenhance.common.ui.createComposeAlertDialog import me.rhunk.snapenhance.common.ui.createComposeView import me.rhunk.snapenhance.common.util.protobuf.ProtoReader import me.rhunk.snapenhance.common.util.snap.BitmojiSelfie @@ -148,92 +155,170 @@ class FriendFeedInfoMenu : AbstractMenu() { } } - private fun showPreview(userId: String?, conversationId: String) { - //query message - val messageLogger = context.feature(MessageLogger::class) - val endToEndEncryption = context.feature(EndToEndEncryption::class) - val messages: List<ConversationMessage> = context.database.getMessagesFromConversationId( - conversationId, - context.config.messaging.messagePreviewLength.get() - )?.reversed() ?: emptyList() - - val participants: Map<String, FriendInfo> = context.database.getConversationParticipants(conversationId)!! - .map { context.database.getFriendInfo(it)!! } - .associateBy { it.userId!! } - - val messageBuilder = StringBuilder() - val translation = context.translation.getCategory("content_type") - - messages.forEach { message -> - val sender = participants[message.senderId] - val messageProtoReader = - ( - messageLogger.takeIf { it.isEnabled && message.contentType == ContentType.STATUS.id }?.getMessageProto(conversationId, message.clientMessageId.toLong()) // process deleted messages if message logger is enabled - ?: ProtoReader(message.messageContent!!).followPath(4, 4) // database message - )?.let { - if (endToEndEncryption.isEnabled) endToEndEncryption.decryptDatabaseMessage(message) else it // try to decrypt message if e2ee is enabled - } ?: return@forEach - - val contentType = ContentType.fromMessageContainer(messageProtoReader) ?: ContentType.fromId(message.contentType) - var messageString = if (contentType == ContentType.CHAT) { - messageProtoReader.getString(2, 1) ?: return@forEach - } else translation.getOrNull(contentType.name) ?: contentType.name - - if (contentType == ContentType.SNAP) { - messageString = "\uD83D\uDFE5" //red square - if (message.readTimestamp > 0) { - messageString += " \uD83D\uDC40 " //eyes - messageString += DateFormat.getDateTimeInstance( - DateFormat.SHORT, - DateFormat.SHORT - ).format(Date(message.readTimestamp)) - } - } + private suspend fun showConversationPreview( + targetUser: String?, + conversationId: String + ) { + val friendInfo = targetUser?.let { context.database.getFriendInfo(it) } + val conversationInfo = conversationId.takeIf { targetUser == null }?.let { context.database.getFeedEntryByConversationId(it) } + val participants by lazy { + context.database.getConversationParticipants(conversationId)!! + .map { context.database.getFriendInfo(it)!! } + .associateBy { it.userId!! } + } - var displayUsername = sender?.displayName ?: sender?.usernameForSorting?: context.translation["conversation_preview.unknown_user"] + withContext(Dispatchers.Main) { + createComposeAlertDialog( + context.mainActivity!!, + ) { + var pageIndex by remember { mutableStateOf(0) } + val messages = remember { mutableStateListOf<@Composable () -> Unit>() } + var totalMessages by remember { mutableIntStateOf(-1) } + val coroutineScope = rememberCoroutineScope() + + suspend fun loadMore() { + val conversationMessages = context.database.getMessagesFromConversationId( + conversationId, + 50, + page = pageIndex++ + ) ?: emptyList() + + if (totalMessages == -1) { + totalMessages = conversationMessages.firstOrNull()?.serverMessageId ?: 0 + } - if (displayUsername.length > 12) { - displayUsername = displayUsername.substring(0, 13) + "... " - } + val messageLogger = context.feature(MessageLogger::class) + val endToEndEncryption = context.feature(EndToEndEncryption::class) + + val parsedMessages = conversationMessages.mapNotNull<ConversationMessage, @Composable () -> Unit> { message -> + val sender = participants[message.senderId] + val messageProtoReader = + (messageLogger.takeIf { it.isEnabled && message.contentType == ContentType.STATUS.id }?.getMessageProto(conversationId, message.clientMessageId.toLong()) // process deleted messages if message logger is enabled + ?: ProtoReader(message.messageContent!!).followPath(4, 4) // database message + )?.let { + if (endToEndEncryption.isEnabled) endToEndEncryption.decryptDatabaseMessage(message) else it // try to decrypt message if e2ee is enabled + } ?: return@mapNotNull null + + val contentType = ContentType.fromMessageContainer(messageProtoReader) ?: ContentType.fromId(message.contentType) + var messageString = if (contentType == ContentType.CHAT) { + messageProtoReader.getString(2, 1) ?: return@mapNotNull null + } else "[${context.translation.getOrNull("content_type.${contentType.name}") ?: contentType.name}]" + + if (contentType == ContentType.SNAP) { + messageString = "\uD83D\uDFE5" //red square + if (message.readTimestamp > 0) { + messageString += " \uD83D\uDC40 " //eyes + messageString += DateFormat.getDateTimeInstance( + DateFormat.SHORT, + DateFormat.SHORT + ).format(Date(message.readTimestamp)) + } + } - messageBuilder.append(displayUsername).append(": ").append(messageString).append("\n") - } + var displayUsername = sender?.displayName ?: sender?.usernameForSorting?: context.translation["conversation_preview.unknown_user"] - val targetPerson = if (userId == null) null else participants[userId] - - targetPerson?.streakExpirationTimestamp?.takeIf { it > 0 }?.let { - val timeSecondDiff = ((it - System.currentTimeMillis()) / 1000 / 60).toInt() - if (timeSecondDiff <= 0) return@let - messageBuilder.append("\n") - .append("\uD83D\uDD25 ") //fire emoji - .append( - context.translation.format("conversation_preview.streak_expiration", - "day" to (timeSecondDiff / 60 / 24).toString(), - "hour" to (timeSecondDiff / 60 % 24).toString(), - "minute" to (timeSecondDiff % 60).toString() - )) - } + if (displayUsername.length > 12) { + displayUsername = displayUsername.substring(0, 13) + "... " + } - messages.lastOrNull()?.let { - messageBuilder - .append("\n\n") - .append(context.translation.format("conversation_preview.total_messages", "count" to it.serverMessageId.toString())) - .append("\n") - } + { + Text( + text = "$displayUsername: $messageString", + modifier = Modifier.padding(4.dp) + ) + } + } - //alert dialog - val builder = ViewAppearanceHelper.newAlertDialogBuilder(context.mainActivity) - builder.setTitle(context.translation["conversation_preview.title"]) - builder.setMessage(messageBuilder.toString()) - builder.setPositiveButton( - "OK" - ) { dialog: DialogInterface, _: Int -> dialog.dismiss() } - targetPerson?.let { - builder.setNegativeButton(context.translation["modal_option.profile_info"]) { _, _ -> - context.executeAsync { showProfileInfo(it) } - } + withContext(Dispatchers.Main) { + messages.addAll(parsedMessages) + } + } + + Column( + modifier = Modifier.fillMaxHeight(fraction = 0.85f) + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(10.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + @Composable + fun Entry(icon: ImageVector, text: String?, title: Boolean) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(3.dp) + ) { + Icon(icon, contentDescription = null) + Text( + text = text ?: "", + fontWeight = if (title) FontWeight.Bold else FontWeight.Light, + fontSize = if (title) 16.sp else 14.sp + ) + } + } + + Column( + modifier = Modifier.weight(1f), + ) { + friendInfo?.let { friendInfo -> + Entry(Icons.Outlined.Person, friendInfo.displayName?.let { "$it (${friendInfo.usernameForSorting})" } ?: friendInfo.usernameForSorting, true) + friendInfo.streakExpirationTimestamp.takeIf { it > 0L && friendInfo.streakLength > 0 && System.currentTimeMillis() < it }?.let { timestamp -> + Entry(Icons.Outlined.LocalFireDepartment, context.translation.format("conversation_preview.streak_expiration", + "day" to ((timestamp - System.currentTimeMillis()) / 1000 / 60 / 60 / 24).toString(), + "hour" to ((timestamp - System.currentTimeMillis()) / 1000 / 60 / 60 % 24).toString(), + "minute" to ((timestamp - System.currentTimeMillis()) / 1000 / 60 % 60).toString() + ), false) + } + } + conversationInfo?.let { + Entry(Icons.Outlined.Group, (it.feedDisplayName ?: it.key).toString(), true) + } + Entry(Icons.AutoMirrored.Outlined.Message, context.translation.format("conversation_preview.total_messages", "count" to totalMessages.toString()), false) + } + friendInfo?.let { + IconButton( + onClick = { + coroutineScope.launch(Dispatchers.IO) { showProfileInfo(it) } + } + ) { + Icon(Icons.Outlined.MoreVert, contentDescription = null) + } + } + } + Spacer(modifier = Modifier.height(1.dp).fillMaxWidth().background(MaterialTheme.colorScheme.onSurface.copy(alpha = 0.1f))) + LazyColumn( + contentPadding = PaddingValues(8.dp), + reverseLayout = true + ) { + items(messages) { message -> + Row( + modifier = Modifier.fillMaxWidth(), + ) { + message() + } + } + item { + Spacer(modifier = Modifier.height(10.dp)) + LaunchedEffect(Unit) { + withContext(Dispatchers.IO) { + loadMore() + } + } + if (messages.isEmpty()) { + Text( + text = context.translation["conversation_preview.no_messages"], + modifier = Modifier + .padding(4.dp) + .fillMaxWidth(), + textAlign = TextAlign.Center + ) + } + } + } + } + }.show() } - builder.show() } @Composable @@ -312,7 +397,9 @@ class FriendFeedInfoMenu : AbstractMenu() { Icons.Outlined.RemoveRedEye, translation["preview"], onClick = { - showPreview(targetUser, conversationId) + context.coroutineScope.launch { + showConversationPreview(targetUser, conversationId) + } } ) }