commit fe777ff1d8a1e0fcb9662c14d988ed321627382e
parent 53204a2b4275a56d2d38e25a80bebffdd9bde65f
Author: rhunk <101876869+rhunk@users.noreply.github.com>
Date: Sun, 11 Feb 2024 23:10:10 +0100
feat: message logger history viewer
Diffstat:
10 files changed, 444 insertions(+), 34 deletions(-)
diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/Routes.kt b/app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/Routes.kt
@@ -15,6 +15,7 @@ import androidx.navigation.NavDestination.Companion.hierarchy
import androidx.navigation.NavGraph.Companion.findStartDestination
import androidx.navigation.NavGraphBuilder
import me.rhunk.snapenhance.RemoteSideContext
+import me.rhunk.snapenhance.ui.manager.pages.LoggerHistoryRoot
import me.rhunk.snapenhance.ui.manager.pages.TasksRoot
import me.rhunk.snapenhance.ui.manager.pages.features.FeaturesRoot
import me.rhunk.snapenhance.ui.manager.pages.home.HomeLogs
@@ -51,6 +52,7 @@ class Routes(
val home = route(RouteInfo("home", icon = Icons.Default.Home, primary = true), HomeRoot())
val settings = route(RouteInfo("home_settings"), HomeSettings()).parent(home)
val homeLogs = route(RouteInfo("home_logs"), HomeLogs()).parent(home)
+ val loggerHistory = route(RouteInfo("logger_history"), LoggerHistoryRoot()).parent(home)
val social = route(RouteInfo("social", icon = Icons.Default.Group, primary = true), SocialRoot())
val manageScope = route(RouteInfo("manage_scope/?scope={scope}&id={id}"), ManageScope()).parent(social)
diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/pages/LoggerHistoryRoot.kt b/app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/pages/LoggerHistoryRoot.kt
@@ -0,0 +1,354 @@
+package me.rhunk.snapenhance.ui.manager.pages
+
+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.foundation.text.KeyboardActions
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.Close
+import androidx.compose.material.icons.filled.Download
+import androidx.compose.material.icons.filled.Search
+import androidx.compose.material3.*
+import androidx.compose.runtime.*
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.focus.FocusRequester
+import androidx.compose.ui.focus.focusRequester
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.input.pointer.pointerInput
+import androidx.compose.ui.text.style.TextAlign
+import androidx.compose.ui.unit.dp
+import androidx.navigation.NavBackStackEntry
+import com.google.gson.JsonParser
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.withContext
+import me.rhunk.snapenhance.bridge.DownloadCallback
+import me.rhunk.snapenhance.common.bridge.wrapper.LoggedMessage
+import me.rhunk.snapenhance.common.bridge.wrapper.MessageLoggerWrapper
+import me.rhunk.snapenhance.common.data.ContentType
+import me.rhunk.snapenhance.common.data.download.*
+import me.rhunk.snapenhance.common.util.ktx.copyToClipboard
+import me.rhunk.snapenhance.common.util.ktx.longHashCode
+import me.rhunk.snapenhance.common.util.protobuf.ProtoReader
+import me.rhunk.snapenhance.core.features.impl.downloader.decoder.DecodedAttachment
+import me.rhunk.snapenhance.core.features.impl.downloader.decoder.MessageDecoder
+import me.rhunk.snapenhance.download.DownloadProcessor
+import me.rhunk.snapenhance.ui.manager.Routes
+import kotlin.math.absoluteValue
+
+
+class LoggerHistoryRoot : Routes.Route() {
+ private lateinit var messageLoggerWrapper: MessageLoggerWrapper
+ private var selectedConversation by mutableStateOf<String?>(null)
+ private var stringFilter by mutableStateOf("")
+ private var reverseOrder by mutableStateOf(true)
+
+ private inline fun decodeMessage(message: LoggedMessage, result: (contentType: ContentType, messageReader: ProtoReader, attachments: List<DecodedAttachment>) -> Unit) {
+ runCatching {
+ val messageObject = JsonParser.parseString(String(message.messageData, Charsets.UTF_8)).asJsonObject
+ val messageContent = messageObject.getAsJsonObject("mMessageContent")
+ val messageReader = messageContent.getAsJsonArray("mContent").map { it.asByte }.toByteArray().let { ProtoReader(it) }
+ result(ContentType.fromMessageContainer(messageReader) ?: ContentType.UNKNOWN, messageReader, MessageDecoder.decode(messageContent))
+ }.onFailure {
+ context.log.error("Failed to decode message", it)
+ }
+ }
+
+ private fun downloadAttachment(creationTimestamp: Long, attachment: DecodedAttachment) {
+ context.shortToast("Download started!")
+ val attachmentHash = attachment.mediaUniqueId!!.longHashCode().absoluteValue.toString()
+
+ DownloadProcessor(
+ remoteSideContext = context,
+ callback = object: DownloadCallback.Default() {
+ override fun onSuccess(outputPath: String?) {
+ context.shortToast("Downloaded to $outputPath")
+ }
+
+ override fun onFailure(message: String?, throwable: String?) {
+ context.shortToast("Failed to download $message")
+ }
+ }
+ ).enqueue(
+ DownloadRequest(
+ inputMedias = arrayOf(
+ InputMedia(
+ content = attachment.mediaUrlKey!!,
+ type = DownloadMediaType.PROTO_MEDIA,
+ encryption = attachment.attachmentInfo?.encryption,
+ )
+ )
+ ),
+ DownloadMetadata(
+ mediaIdentifier = attachmentHash,
+ outputPath = createNewFilePath(
+ context.config.root,
+ attachment.mediaUniqueId!!,
+ MediaDownloadSource.MESSAGE_LOGGER,
+ attachmentHash,
+ creationTimestamp
+ ),
+ iconUrl = null,
+ mediaAuthor = null,
+ downloadSource = MediaDownloadSource.MESSAGE_LOGGER.translate(context.translation),
+ )
+ )
+ }
+
+ @OptIn(ExperimentalLayoutApi::class)
+ @Composable
+ private fun MessageView(message: LoggedMessage) {
+ var contentView by remember { mutableStateOf<@Composable () -> Unit>({
+ Spacer(modifier = Modifier.height(30.dp))
+ }) }
+
+ OutlinedCard(
+ modifier = Modifier
+ .padding(2.dp)
+ .fillMaxWidth()
+ ) {
+ Row(
+ modifier = Modifier
+ .padding(4.dp)
+ .fillMaxWidth(),
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ contentView()
+
+ LaunchedEffect(Unit, message) {
+ runCatching {
+ decodeMessage(message) { contentType, messageReader, attachments ->
+ if (contentType == ContentType.CHAT) {
+ val content = messageReader.getString(2, 1) ?: "[empty chat message]"
+ contentView = {
+ Text(content, modifier = Modifier
+ .fillMaxWidth()
+ .pointerInput(Unit) {
+ detectTapGestures(onLongPress = {
+ context.androidContext.copyToClipboard(content)
+ })
+ })
+ }
+ return@runCatching
+ }
+ contentView = {
+ Column column@{
+ Text("[$contentType]")
+ if (attachments.isEmpty()) return@column
+
+ FlowRow(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(2.dp),
+ horizontalArrangement = Arrangement.spacedBy(4.dp),
+ ) {
+ attachments.forEachIndexed { index, attachment ->
+ ElevatedButton(onClick = {
+ context.coroutineScope.launch {
+ runCatching {
+ downloadAttachment(message.timestamp, attachment)
+ }.onFailure {
+ context.log.error("Failed to download attachment", it)
+ context.shortToast("Failed to download attachment")
+ }
+ }
+ }) {
+ Icon(
+ imageVector = Icons.Default.Download,
+ contentDescription = "Download",
+ modifier = Modifier.padding(end = 4.dp)
+ )
+ Text("Attachment ${index + 1}")
+ }
+ }
+ }
+ }
+ }
+ }
+ }.onFailure {
+ context.log.error("Failed to parse message", it)
+ contentView = {
+ Text("[Failed to parse message]")
+ }
+ }
+ }
+ }
+ }
+ }
+
+
+ @OptIn(ExperimentalMaterial3Api::class)
+ override val content: @Composable (NavBackStackEntry) -> Unit = {
+ LaunchedEffect(Unit) {
+ messageLoggerWrapper = MessageLoggerWrapper(
+ context.androidContext.getDatabasePath("message_logger.db")
+ )
+ }
+
+ Column {
+ var expanded by remember { mutableStateOf(false) }
+
+ ExposedDropdownMenuBox(
+ expanded = expanded,
+ onExpandedChange = { expanded = it },
+ ) {
+ OutlinedTextField(
+ value = selectedConversation ?: "Select a conversation",
+ onValueChange = {},
+ readOnly = true,
+ modifier = Modifier
+ .menuAnchor()
+ .fillMaxWidth()
+ )
+
+ val conversations = remember { mutableStateListOf<String>() }
+
+ LaunchedEffect(Unit) {
+ conversations.clear()
+ withContext(Dispatchers.IO) {
+ conversations.addAll(messageLoggerWrapper.getAllConversations())
+ }
+ }
+
+ ExposedDropdownMenu(expanded = expanded, onDismissRequest = { expanded = false }) {
+ conversations.forEach { conversation ->
+ DropdownMenuItem(onClick = {
+ selectedConversation = conversation
+ expanded = false
+ }, text = {
+ Text(conversation)
+ })
+ }
+ }
+ }
+
+ Row(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(2.dp),
+ horizontalArrangement = Arrangement.End
+ ) {
+ Row(
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.spacedBy(2.dp),
+ ) {
+ Text("Reverse order")
+ Checkbox(checked = reverseOrder, onCheckedChange = {
+ reverseOrder = it
+ })
+ }
+ }
+
+ var hasReachedEnd by remember(selectedConversation, stringFilter, reverseOrder) { mutableStateOf(false) }
+ var lastFetchMessageTimestamp by remember(selectedConversation, stringFilter, reverseOrder) { mutableLongStateOf(if (reverseOrder) Long.MAX_VALUE else Long.MIN_VALUE) }
+ val messages = remember(selectedConversation, stringFilter, reverseOrder) { mutableStateListOf<LoggedMessage>() }
+
+ LazyColumn {
+ items(messages) { message ->
+ MessageView(message)
+ }
+ item {
+ if (selectedConversation != null) {
+ if (hasReachedEnd) {
+ Text("No more messages", modifier = Modifier
+ .padding(8.dp)
+ .fillMaxWidth(), textAlign = TextAlign.Center)
+ } else {
+ Row(
+ horizontalArrangement = Arrangement.Center,
+ modifier = Modifier.fillMaxWidth()
+ ) {
+ CircularProgressIndicator(
+ modifier = Modifier
+ .height(20.dp)
+ .padding(8.dp)
+ )
+ }
+ }
+ }
+ LaunchedEffect(Unit, selectedConversation, stringFilter, reverseOrder) {
+ withContext(Dispatchers.IO) {
+ val newMessages = messageLoggerWrapper.fetchMessages(
+ selectedConversation ?: return@withContext,
+ lastFetchMessageTimestamp,
+ 30,
+ reverseOrder
+ ) { messageData ->
+ if (stringFilter.isEmpty()) return@fetchMessages true
+ var isMatch = false
+ decodeMessage(messageData) { contentType, messageReader, _ ->
+ if (contentType == ContentType.CHAT) {
+ val content = messageReader.getString(2, 1) ?: return@decodeMessage
+ isMatch = content.contains(stringFilter, ignoreCase = true)
+ }
+ }
+ isMatch
+ }
+ if (newMessages.isEmpty()) {
+ hasReachedEnd = true
+ return@withContext
+ }
+ lastFetchMessageTimestamp = newMessages.lastOrNull()?.timestamp ?: return@withContext
+ withContext(Dispatchers.Main) {
+ messages.addAll(newMessages)
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+
+ override val topBarActions: @Composable (RowScope.() -> Unit) = {
+ val focusRequester = remember { FocusRequester() }
+ var showSearchTextField by remember { mutableStateOf(false) }
+
+ if (showSearchTextField) {
+ var searchValue by remember { mutableStateOf("") }
+
+ TextField(
+ value = searchValue,
+ onValueChange = { keyword ->
+ searchValue = keyword
+ },
+ keyboardActions = KeyboardActions(onDone = { focusRequester.freeFocus() }),
+ modifier = Modifier
+ .focusRequester(focusRequester)
+ .weight(1f, fill = true)
+ .padding(end = 10.dp)
+ .height(70.dp),
+ singleLine = true,
+ colors = TextFieldDefaults.colors(
+ unfocusedContainerColor = MaterialTheme.colorScheme.surface,
+ focusedContainerColor = MaterialTheme.colorScheme.surface,
+ focusedIndicatorColor = Color.Transparent,
+ unfocusedIndicatorColor = Color.Transparent,
+ disabledIndicatorColor = Color.Transparent,
+ cursorColor = MaterialTheme.colorScheme.primary
+ )
+ )
+ ElevatedButton(onClick = {
+ stringFilter = searchValue
+ }) {
+ Text("Search")
+ }
+
+ LaunchedEffect(Unit) {
+ focusRequester.requestFocus()
+ }
+ }
+
+ IconButton(onClick = {
+ showSearchTextField = !showSearchTextField
+ stringFilter = ""
+ }) {
+ Icon(
+ imageVector = if (showSearchTextField) Icons.Filled.Close
+ else Icons.Filled.Search,
+ contentDescription = null
+ )
+ }
+ }
+}+
\ No newline at end of file
diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/pages/home/HomeSettings.kt b/app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/pages/home/HomeSettings.kt
@@ -183,6 +183,14 @@ class HomeSettings : Routes.Route() {
Text(text = "Clear")
}
}
+ OutlinedButton(
+ modifier = Modifier.fillMaxWidth().padding(5.dp),
+ onClick = {
+ routes.loggerHistory.navigate()
+ }
+ ) {
+ Text(text = "View Message History")
+ }
}
}
diff --git a/common/src/main/assets/lang/en_US.json b/common/src/main/assets/lang/en_US.json
@@ -28,6 +28,8 @@
"home": "Home",
"home_settings": "Settings",
"home_logs": "Logs",
+ "logger_history": "Logger History",
+ "logged_stories": "Logged Stories",
"social": "Social",
"manage_scope": "Manage Scope",
"messaging_preview": "Preview",
@@ -940,6 +942,7 @@
"spotlight": "Spotlight",
"profile_picture": "Profile Picture",
"story_logger": "Story Logger",
+ "message_logger": "Message Logger",
"merged": "Merged"
},
diff --git a/common/src/main/kotlin/me/rhunk/snapenhance/common/bridge/wrapper/MessageLoggerWrapper.kt b/common/src/main/kotlin/me/rhunk/snapenhance/common/bridge/wrapper/MessageLoggerWrapper.kt
@@ -12,6 +12,13 @@ import me.rhunk.snapenhance.common.util.ktx.getStringOrNull
import java.io.File
import java.util.UUID
+
+class LoggedMessage(
+ val messageId: Long,
+ val timestamp: Long,
+ val messageData: ByteArray
+)
+
class MessageLoggerWrapper(
val databaseFile: File
): MessageLoggerInterface.Stub() {
@@ -60,29 +67,24 @@ class MessageLoggerWrapper(
runCatching { UUID.fromString(it) }.isFailure
}) return longArrayOf()
- val cursor = database.rawQuery("SELECT message_id FROM messages WHERE conversation_id IN (${
- conversationId.joinToString(
- ","
- ) { "'$it'" }
- }) ORDER BY message_id DESC LIMIT $limit", null)
-
- val ids = mutableListOf<Long>()
- while (cursor.moveToNext()) {
- ids.add(cursor.getLong(0))
+ return database.rawQuery("SELECT message_id FROM messages WHERE conversation_id IN (${
+ conversationId.joinToString(",") { "'$it'" }
+ }) ORDER BY message_id DESC LIMIT $limit", null).use {
+ val ids = mutableListOf<Long>()
+ while (it.moveToNext()) {
+ ids.add(it.getLong(0))
+ }
+ ids.toLongArray()
}
- cursor.close()
- return ids.toLongArray()
}
override fun getMessage(conversationId: String?, id: Long): ByteArray? {
- val cursor = database.rawQuery("SELECT message_data FROM messages WHERE conversation_id = ? AND message_id = ?", arrayOf(conversationId, id.toString()))
- val message: ByteArray? = if (cursor.moveToFirst()) {
- cursor.getBlob(0)
- } else {
- null
+ return database.rawQuery(
+ "SELECT message_data FROM messages WHERE conversation_id = ? AND message_id = ?",
+ arrayOf(conversationId, id.toString())
+ ).use {
+ if (it.moveToFirst()) it.getBlob(0) else null
}
- cursor.close()
- return message
}
override fun addMessage(conversationId: String, messageId: Long, serializedMessage: ByteArray) {
@@ -116,19 +118,17 @@ class MessageLoggerWrapper(
}
fun getStoredMessageCount(): Int {
- val cursor = database.rawQuery("SELECT COUNT(*) FROM messages", null)
- cursor.moveToFirst()
- val count = cursor.getInt(0)
- cursor.close()
- return count
+ return database.rawQuery("SELECT COUNT(*) FROM messages", null).use {
+ it.moveToFirst()
+ it.getInt(0)
+ }
}
fun getStoredStoriesCount(): Int {
- val cursor = database.rawQuery("SELECT COUNT(*) FROM stories", null)
- cursor.moveToFirst()
- val count = cursor.getInt(0)
- cursor.close()
- return count
+ return database.rawQuery("SELECT COUNT(*) FROM stories", null).use {
+ it.moveToFirst()
+ it.getInt(0)
+ }
}
override fun deleteMessage(conversationId: String, messageId: Long) {
@@ -174,4 +174,39 @@ class MessageLoggerWrapper(
}
return stories
}
+
+ fun getAllConversations(): List<String> {
+ return database.rawQuery("SELECT DISTINCT conversation_id FROM messages", null).use {
+ val conversations = mutableListOf<String>()
+ while (it.moveToNext()) {
+ conversations.add(it.getString(0))
+ }
+ conversations
+ }
+ }
+
+ fun fetchMessages(
+ conversationId: String,
+ fromTimestamp: Long,
+ limit: Int,
+ reverseOrder: Boolean = true,
+ filter: ((LoggedMessage) -> Boolean)? = null
+ ): List<LoggedMessage> {
+ val messages = mutableListOf<LoggedMessage>()
+ database.rawQuery(
+ "SELECT * FROM messages WHERE conversation_id = ? AND added_timestamp ${if (reverseOrder) "<" else ">"} ? ORDER BY added_timestamp ${if (reverseOrder) "DESC" else "ASC"}",
+ arrayOf(conversationId, fromTimestamp.toString())
+ ).use {
+ while (it.moveToNext() && messages.size < limit) {
+ val message = LoggedMessage(
+ messageId = it.getLongOrNull("message_id") ?: continue,
+ timestamp = it.getLongOrNull("added_timestamp") ?: continue,
+ messageData = it.getBlobOrNull("message_data") ?: continue
+ )
+ if (filter != null && !filter(message)) continue
+ messages.add(message)
+ }
+ }
+ return messages
+ }
}
\ No newline at end of file
diff --git a/common/src/main/kotlin/me/rhunk/snapenhance/common/data/download/MediaDownloadSource.kt b/common/src/main/kotlin/me/rhunk/snapenhance/common/data/download/MediaDownloadSource.kt
@@ -15,6 +15,7 @@ enum class MediaDownloadSource(
SPOTLIGHT("spotlight", "spotlight"),
PROFILE_PICTURE("profile_picture", "profile_picture"),
STORY_LOGGER("story_logger", "story_logger"),
+ MESSAGE_LOGGER("message_logger", "message_logger"),
MERGED("merged", "merged");
fun matches(source: String?): Boolean {
diff --git a/common/src/main/kotlin/me/rhunk/snapenhance/common/util/ktx/AndroidCompatExtensions.kt b/common/src/main/kotlin/me/rhunk/snapenhance/common/util/ktx/AndroidCompatExtensions.kt
@@ -1,5 +1,7 @@
package me.rhunk.snapenhance.common.util.ktx
+import android.content.ClipData
+import android.content.Context
import android.content.pm.PackageManager
import android.content.pm.PackageManager.ApplicationInfoFlags
import android.os.Build
@@ -11,3 +13,8 @@ fun PackageManager.getApplicationInfoCompat(packageName: String, flags: Int) =
@Suppress("DEPRECATION")
getApplicationInfo(packageName, flags)
}
+
+fun Context.copyToClipboard(data: String, label: String = "Copied Text") {
+ getSystemService(android.content.ClipboardManager::class.java).setPrimaryClip(
+ ClipData.newPlainText(label, data))
+}+
\ No newline at end of file
diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/core/ModContext.kt b/core/src/main/kotlin/me/rhunk/snapenhance/core/ModContext.kt
@@ -158,8 +158,4 @@ class ModContext(
fun getConfigLocale(): String {
return _config.locale
}
-
- fun copyToClipboard(data: String, label: String = "Copied Text") {
- androidContext.getSystemService(android.content.ClipboardManager::class.java).setPrimaryClip(ClipData.newPlainText(label, data))
- }
}
\ No newline at end of file
diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/downloader/MediaDownloader.kt b/core/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/downloader/MediaDownloader.kt
@@ -19,6 +19,7 @@ import me.rhunk.snapenhance.common.data.MessagingRuleType
import me.rhunk.snapenhance.common.data.download.*
import me.rhunk.snapenhance.common.database.impl.ConversationMessage
import me.rhunk.snapenhance.common.database.impl.FriendInfo
+import me.rhunk.snapenhance.common.util.ktx.copyToClipboard
import me.rhunk.snapenhance.common.util.ktx.longHashCode
import me.rhunk.snapenhance.common.util.protobuf.ProtoReader
import me.rhunk.snapenhance.common.util.snap.BitmojiSelfie
@@ -161,7 +162,7 @@ class MediaDownloader : MessagingRuleFeature("MediaDownloader", MessagingRuleTyp
setTitle("Debug Media Info")
setView(debugEditText(context, mediaInfoText))
setNeutralButton("Copy") { _, _ ->
- this@MediaDownloader.context.copyToClipboard(mediaInfoText)
+ context.copyToClipboard(mediaInfoText)
}
setNegativeButton("Cancel") { dialog, _ -> dialog.dismiss() }
}.show()
diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/core/ui/menu/impl/ChatActionMenu.kt b/core/src/main/kotlin/me/rhunk/snapenhance/core/ui/menu/impl/ChatActionMenu.kt
@@ -19,6 +19,7 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import me.rhunk.snapenhance.common.data.ContentType
import me.rhunk.snapenhance.common.ui.createComposeView
+import me.rhunk.snapenhance.common.util.ktx.copyToClipboard
import me.rhunk.snapenhance.common.util.protobuf.ProtoReader
import me.rhunk.snapenhance.common.util.snap.RemoteMediaResolver
import me.rhunk.snapenhance.core.features.impl.downloader.MediaDownloader
@@ -74,7 +75,7 @@ class ChatActionMenu : AbstractMenu() {
setView(debugEditText(context, text))
setPositiveButton("OK") { dialog, _ -> dialog.dismiss() }
setNegativeButton("Copy") { _, _ ->
- this@ChatActionMenu.context.copyToClipboard(text, title)
+ context.copyToClipboard(text, title)
}
}.show()
}