feat(core): compose conversation preview

Signed-off-by: rhunk <101876869+rhunk@users.noreply.github.com>
This commit is contained in:
rhunk 2024-06-10 14:38:33 +02:00
parent 5ebd48cd9b
commit a355ea9667
4 changed files with 174 additions and 90 deletions

View File

@ -635,10 +635,6 @@
} }
} }
}, },
"message_preview_length": {
"name": "Message Preview Length",
"description": "Specify the amount of messages to get previewed"
},
"call_start_confirmation": { "call_start_confirmation": {
"name": "Call Start Confirmation", "name": "Call Start Confirmation",
"description": "Shows a confirmation dialog when starting a call" "description": "Shows a confirmation dialog when starting a call"
@ -1443,7 +1439,8 @@
"streak_expiration": "expires in {day} days {hour} hours {minute} minutes", "streak_expiration": "expires in {day} days {hour} hours {minute} minutes",
"total_messages": "Total sent/received messages: {count}", "total_messages": "Total sent/received messages: {count}",
"title": "Preview", "title": "Preview",
"unknown_user": "Unknown User" "unknown_user": "Unknown User",
"no_messages": "No messages found!"
}, },
"profile_info": { "profile_info": {

View File

@ -62,7 +62,6 @@ class MessagingTweaks : ConfigContainer() {
val loopMediaPlayback = boolean("loop_media_playback") { requireRestart() } val loopMediaPlayback = boolean("loop_media_playback") { requireRestart() }
val disableReplayInFF = boolean("disable_replay_in_ff") val disableReplayInFF = boolean("disable_replay_in_ff")
val halfSwipeNotifier = container("half_swipe_notifier", HalfSwipeNotifierConfig()) { requireRestart()} val halfSwipeNotifier = container("half_swipe_notifier", HalfSwipeNotifierConfig()) { requireRestart()}
val messagePreviewLength = integer("message_preview_length", defaultValue = 20)
val callStartConfirmation = boolean("call_start_confirmation") { requireRestart() } val callStartConfirmation = boolean("call_start_confirmation") { requireRestart() }
val unlimitedConversationPinning = boolean("unlimited_conversation_pinning") { requireRestart() } val unlimitedConversationPinning = boolean("unlimited_conversation_pinning") { requireRestart() }
val autoSaveMessagesInConversations = multiple("auto_save_messages_in_conversations", val autoSaveMessagesInConversations = multiple("auto_save_messages_in_conversations",

View File

@ -351,12 +351,13 @@ class DatabaseAccess(
fun getMessagesFromConversationId( fun getMessagesFromConversationId(
conversationId: String, conversationId: String,
limit: Int limit: Int,
page: Int = 0,
): List<ConversationMessage>? { ): List<ConversationMessage>? {
return useDatabase(DatabaseType.ARROYO)?.performOperation { return useDatabase(DatabaseType.ARROYO)?.performOperation {
safeRawQuery( safeRawQuery(
"SELECT * FROM conversation_message WHERE client_conversation_id = ? ORDER BY creation_timestamp DESC LIMIT ?", "SELECT * FROM conversation_message WHERE client_conversation_id = ? ORDER BY creation_timestamp DESC LIMIT ? OFFSET ?",
arrayOf(conversationId, limit.toString()) arrayOf(conversationId, limit.toString(), (limit * page).toString())
)?.use { query -> )?.use { query ->
if (!query.moveToFirst()) { if (!query.moveToFirst()) {
return@performOperation null return@performOperation null

View File

@ -11,12 +11,14 @@ import android.widget.LinearLayout
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.gestures.detectTapGestures 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.items
import androidx.compose.material.icons.Icons 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.CheckCircleOutline
import androidx.compose.material.icons.filled.Info import androidx.compose.material.icons.filled.Info
import androidx.compose.material.icons.filled.NotInterested import androidx.compose.material.icons.filled.NotInterested
import androidx.compose.material.icons.outlined.EditNote import androidx.compose.material.icons.outlined.*
import androidx.compose.material.icons.outlined.RemoveRedEye
import androidx.compose.material3.* import androidx.compose.material3.*
import androidx.compose.runtime.* import androidx.compose.runtime.*
import androidx.compose.ui.Alignment 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.Font
import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight 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.dp
import androidx.compose.ui.unit.sp 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.ContentType
import me.rhunk.snapenhance.common.data.FriendLinkType import me.rhunk.snapenhance.common.data.FriendLinkType
import me.rhunk.snapenhance.common.database.impl.ConversationMessage 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.EnumScriptInterface
import me.rhunk.snapenhance.common.scripting.ui.InterfaceManager import me.rhunk.snapenhance.common.scripting.ui.InterfaceManager
import me.rhunk.snapenhance.common.scripting.ui.ScriptInterface 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.ui.createComposeView
import me.rhunk.snapenhance.common.util.protobuf.ProtoReader import me.rhunk.snapenhance.common.util.protobuf.ProtoReader
import me.rhunk.snapenhance.common.util.snap.BitmojiSelfie import me.rhunk.snapenhance.common.util.snap.BitmojiSelfie
@ -148,92 +155,170 @@ class FriendFeedInfoMenu : AbstractMenu() {
} }
} }
private fun showPreview(userId: String?, conversationId: String) { private suspend fun showConversationPreview(
//query message targetUser: String?,
val messageLogger = context.feature(MessageLogger::class) conversationId: String
val endToEndEncryption = context.feature(EndToEndEncryption::class) ) {
val messages: List<ConversationMessage> = context.database.getMessagesFromConversationId( val friendInfo = targetUser?.let { context.database.getFriendInfo(it) }
conversationId, val conversationInfo = conversationId.takeIf { targetUser == null }?.let { context.database.getFeedEntryByConversationId(it) }
context.config.messaging.messagePreviewLength.get() val participants by lazy {
)?.reversed() ?: emptyList() context.database.getConversationParticipants(conversationId)!!
.map { context.database.getFriendInfo(it)!! }
.associateBy { it.userId!! }
}
val participants: Map<String, FriendInfo> = context.database.getConversationParticipants(conversationId)!! withContext(Dispatchers.Main) {
.map { context.database.getFriendInfo(it)!! } createComposeAlertDialog(
.associateBy { it.userId!! } context.mainActivity!!,
) {
val messageBuilder = StringBuilder() var pageIndex by remember { mutableStateOf(0) }
val translation = context.translation.getCategory("content_type") val messages = remember { mutableStateListOf<@Composable () -> Unit>() }
var totalMessages by remember { mutableIntStateOf(-1) }
val coroutineScope = rememberCoroutineScope()
messages.forEach { message -> suspend fun loadMore() {
val sender = participants[message.senderId] val conversationMessages = context.database.getMessagesFromConversationId(
val messageProtoReader = conversationId,
( 50,
messageLogger.takeIf { it.isEnabled && message.contentType == ContentType.STATUS.id }?.getMessageProto(conversationId, message.clientMessageId.toLong()) // process deleted messages if message logger is enabled page = pageIndex++
?: ProtoReader(message.messageContent!!).followPath(4, 4) // database message ) ?: emptyList()
)?.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) if (totalMessages == -1) {
var messageString = if (contentType == ContentType.CHAT) { totalMessages = conversationMessages.firstOrNull()?.serverMessageId ?: 0
messageProtoReader.getString(2, 1) ?: return@forEach }
} else translation.getOrNull(contentType.name) ?: contentType.name
if (contentType == ContentType.SNAP) { val messageLogger = context.feature(MessageLogger::class)
messageString = "\uD83D\uDFE5" //red square val endToEndEncryption = context.feature(EndToEndEncryption::class)
if (message.readTimestamp > 0) {
messageString += " \uD83D\uDC40 " //eyes val parsedMessages = conversationMessages.mapNotNull<ConversationMessage, @Composable () -> Unit> { message ->
messageString += DateFormat.getDateTimeInstance( val sender = participants[message.senderId]
DateFormat.SHORT, val messageProtoReader =
DateFormat.SHORT (messageLogger.takeIf { it.isEnabled && message.contentType == ContentType.STATUS.id }?.getMessageProto(conversationId, message.clientMessageId.toLong()) // process deleted messages if message logger is enabled
).format(Date(message.readTimestamp)) ?: 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))
}
}
var displayUsername = sender?.displayName ?: sender?.usernameForSorting?: context.translation["conversation_preview.unknown_user"]
if (displayUsername.length > 12) {
displayUsername = displayUsername.substring(0, 13) + "... "
}
{
Text(
text = "$displayUsername: $messageString",
modifier = Modifier.padding(4.dp)
)
}
}
withContext(Dispatchers.Main) {
messages.addAll(parsedMessages)
}
} }
}
var displayUsername = sender?.displayName ?: sender?.usernameForSorting?: context.translation["conversation_preview.unknown_user"] 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
)
}
}
if (displayUsername.length > 12) { Column(
displayUsername = displayUsername.substring(0, 13) + "... " modifier = Modifier.weight(1f),
} ) {
friendInfo?.let { friendInfo ->
messageBuilder.append(displayUsername).append(": ").append(messageString).append("\n") 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()
} }
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()
))
}
messages.lastOrNull()?.let {
messageBuilder
.append("\n\n")
.append(context.translation.format("conversation_preview.total_messages", "count" to it.serverMessageId.toString()))
.append("\n")
}
//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) }
}
}
builder.show()
} }
@Composable @Composable
@ -312,7 +397,9 @@ class FriendFeedInfoMenu : AbstractMenu() {
Icons.Outlined.RemoveRedEye, Icons.Outlined.RemoveRedEye,
translation["preview"], translation["preview"],
onClick = { onClick = {
showPreview(targetUser, conversationId) context.coroutineScope.launch {
showConversationPreview(targetUser, conversationId)
}
} }
) )
} }