diff --git a/app/schemas/com.nextcloud.talk.data.source.local.TalkDatabase/19.json b/app/schemas/com.nextcloud.talk.data.source.local.TalkDatabase/19.json new file mode 100644 index 0000000000..428c03d0a6 --- /dev/null +++ b/app/schemas/com.nextcloud.talk.data.source.local.TalkDatabase/19.json @@ -0,0 +1,751 @@ +{ + "formatVersion": 1, + "database": { + "version": 19, + "identityHash": "7330dad871a0b42e36931ffe8c7d4bcf", + "entities": [ + { + "tableName": "User", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `userId` TEXT, `username` TEXT, `baseUrl` TEXT, `token` TEXT, `displayName` TEXT, `pushConfigurationState` TEXT, `capabilities` TEXT, `serverVersion` TEXT DEFAULT '', `clientCertificate` TEXT, `externalSignalingServer` TEXT, `current` INTEGER NOT NULL, `scheduledForDeletion` INTEGER NOT NULL)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT" + }, + { + "fieldPath": "username", + "columnName": "username", + "affinity": "TEXT" + }, + { + "fieldPath": "baseUrl", + "columnName": "baseUrl", + "affinity": "TEXT" + }, + { + "fieldPath": "token", + "columnName": "token", + "affinity": "TEXT" + }, + { + "fieldPath": "displayName", + "columnName": "displayName", + "affinity": "TEXT" + }, + { + "fieldPath": "pushConfigurationState", + "columnName": "pushConfigurationState", + "affinity": "TEXT" + }, + { + "fieldPath": "capabilities", + "columnName": "capabilities", + "affinity": "TEXT" + }, + { + "fieldPath": "serverVersion", + "columnName": "serverVersion", + "affinity": "TEXT", + "defaultValue": "''" + }, + { + "fieldPath": "clientCertificate", + "columnName": "clientCertificate", + "affinity": "TEXT" + }, + { + "fieldPath": "externalSignalingServer", + "columnName": "externalSignalingServer", + "affinity": "TEXT" + }, + { + "fieldPath": "current", + "columnName": "current", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "scheduledForDeletion", + "columnName": "scheduledForDeletion", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + } + }, + { + "tableName": "ArbitraryStorage", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`accountIdentifier` INTEGER NOT NULL, `key` TEXT NOT NULL, `object` TEXT, `value` TEXT, PRIMARY KEY(`accountIdentifier`, `key`))", + "fields": [ + { + "fieldPath": "accountIdentifier", + "columnName": "accountIdentifier", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "key", + "columnName": "key", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "storageObject", + "columnName": "object", + "affinity": "TEXT" + }, + { + "fieldPath": "value", + "columnName": "value", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "accountIdentifier", + "key" + ] + } + }, + { + "tableName": "Conversations", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`internalId` TEXT NOT NULL, `accountId` INTEGER NOT NULL, `token` TEXT NOT NULL, `displayName` TEXT NOT NULL, `actorId` TEXT NOT NULL, `actorType` TEXT NOT NULL, `avatarVersion` TEXT NOT NULL, `callFlag` INTEGER NOT NULL, `callRecording` INTEGER NOT NULL, `callStartTime` INTEGER NOT NULL, `canDeleteConversation` INTEGER NOT NULL, `canLeaveConversation` INTEGER NOT NULL, `canStartCall` INTEGER NOT NULL, `description` TEXT NOT NULL, `hasCall` INTEGER NOT NULL, `hasPassword` INTEGER NOT NULL, `isCustomAvatar` INTEGER NOT NULL, `isFavorite` INTEGER NOT NULL, `lastActivity` INTEGER NOT NULL, `lastCommonReadMessage` INTEGER NOT NULL, `lastMessage` TEXT, `lastPing` INTEGER NOT NULL, `lastReadMessage` INTEGER NOT NULL, `lobbyState` TEXT NOT NULL, `lobbyTimer` INTEGER NOT NULL, `messageExpiration` INTEGER NOT NULL, `name` TEXT NOT NULL, `notificationCalls` INTEGER NOT NULL, `notificationLevel` TEXT NOT NULL, `objectType` TEXT NOT NULL, `objectId` TEXT NOT NULL, `participantType` TEXT NOT NULL, `permissions` INTEGER NOT NULL, `readOnly` TEXT NOT NULL, `recordingConsent` INTEGER NOT NULL, `remoteServer` TEXT, `remoteToken` TEXT, `sessionId` TEXT NOT NULL, `status` TEXT, `statusClearAt` INTEGER, `statusIcon` TEXT, `statusMessage` TEXT, `type` TEXT NOT NULL, `unreadMention` INTEGER NOT NULL, `unreadMentionDirect` INTEGER NOT NULL, `unreadMessages` INTEGER NOT NULL, `hasArchived` INTEGER NOT NULL, `hasSensitive` INTEGER NOT NULL, `hasImportant` INTEGER NOT NULL, `messageDraft` TEXT, PRIMARY KEY(`internalId`), FOREIGN KEY(`accountId`) REFERENCES `User`(`id`) ON UPDATE CASCADE ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "internalId", + "columnName": "internalId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "accountId", + "columnName": "accountId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "token", + "columnName": "token", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "displayName", + "columnName": "displayName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "actorId", + "columnName": "actorId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "actorType", + "columnName": "actorType", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "avatarVersion", + "columnName": "avatarVersion", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "callFlag", + "columnName": "callFlag", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "callRecording", + "columnName": "callRecording", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "callStartTime", + "columnName": "callStartTime", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "canDeleteConversation", + "columnName": "canDeleteConversation", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "canLeaveConversation", + "columnName": "canLeaveConversation", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "canStartCall", + "columnName": "canStartCall", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "description", + "columnName": "description", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "hasCall", + "columnName": "hasCall", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "hasPassword", + "columnName": "hasPassword", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "hasCustomAvatar", + "columnName": "isCustomAvatar", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "favorite", + "columnName": "isFavorite", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastActivity", + "columnName": "lastActivity", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastCommonReadMessage", + "columnName": "lastCommonReadMessage", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastMessage", + "columnName": "lastMessage", + "affinity": "TEXT" + }, + { + "fieldPath": "lastPing", + "columnName": "lastPing", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastReadMessage", + "columnName": "lastReadMessage", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lobbyState", + "columnName": "lobbyState", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lobbyTimer", + "columnName": "lobbyTimer", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "messageExpiration", + "columnName": "messageExpiration", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "notificationCalls", + "columnName": "notificationCalls", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "notificationLevel", + "columnName": "notificationLevel", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "objectType", + "columnName": "objectType", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "objectId", + "columnName": "objectId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "participantType", + "columnName": "participantType", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "permissions", + "columnName": "permissions", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "conversationReadOnlyState", + "columnName": "readOnly", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "recordingConsentRequired", + "columnName": "recordingConsent", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "remoteServer", + "columnName": "remoteServer", + "affinity": "TEXT" + }, + { + "fieldPath": "remoteToken", + "columnName": "remoteToken", + "affinity": "TEXT" + }, + { + "fieldPath": "sessionId", + "columnName": "sessionId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "status", + "columnName": "status", + "affinity": "TEXT" + }, + { + "fieldPath": "statusClearAt", + "columnName": "statusClearAt", + "affinity": "INTEGER" + }, + { + "fieldPath": "statusIcon", + "columnName": "statusIcon", + "affinity": "TEXT" + }, + { + "fieldPath": "statusMessage", + "columnName": "statusMessage", + "affinity": "TEXT" + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "unreadMention", + "columnName": "unreadMention", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "unreadMentionDirect", + "columnName": "unreadMentionDirect", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "unreadMessages", + "columnName": "unreadMessages", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "hasArchived", + "columnName": "hasArchived", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "hasSensitive", + "columnName": "hasSensitive", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "hasImportant", + "columnName": "hasImportant", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "messageDraft", + "columnName": "messageDraft", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "internalId" + ] + }, + "indices": [ + { + "name": "index_Conversations_accountId", + "unique": false, + "columnNames": [ + "accountId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_Conversations_accountId` ON `${TABLE_NAME}` (`accountId`)" + } + ], + "foreignKeys": [ + { + "table": "User", + "onDelete": "CASCADE", + "onUpdate": "CASCADE", + "columns": [ + "accountId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "ChatMessages", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`internalId` TEXT NOT NULL, `accountId` INTEGER NOT NULL, `token` TEXT NOT NULL, `id` INTEGER NOT NULL, `internalConversationId` TEXT NOT NULL, `threadId` INTEGER, `isThread` INTEGER NOT NULL, `actorDisplayName` TEXT NOT NULL, `message` TEXT NOT NULL, `actorId` TEXT NOT NULL, `actorType` TEXT NOT NULL, `deleted` INTEGER NOT NULL, `expirationTimestamp` INTEGER NOT NULL, `isReplyable` INTEGER NOT NULL, `isTemporary` INTEGER NOT NULL, `lastEditActorDisplayName` TEXT, `lastEditActorId` TEXT, `lastEditActorType` TEXT, `lastEditTimestamp` INTEGER, `markdown` INTEGER, `messageParameters` TEXT, `messageType` TEXT NOT NULL, `parent` INTEGER, `reactions` TEXT, `reactionsSelf` TEXT, `referenceId` TEXT, `sendStatus` TEXT, `silent` INTEGER NOT NULL, `systemMessage` TEXT NOT NULL, `timestamp` INTEGER NOT NULL, PRIMARY KEY(`internalId`), FOREIGN KEY(`internalConversationId`) REFERENCES `Conversations`(`internalId`) ON UPDATE CASCADE ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "internalId", + "columnName": "internalId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "accountId", + "columnName": "accountId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "token", + "columnName": "token", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "internalConversationId", + "columnName": "internalConversationId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "threadId", + "columnName": "threadId", + "affinity": "INTEGER" + }, + { + "fieldPath": "isThread", + "columnName": "isThread", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "actorDisplayName", + "columnName": "actorDisplayName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "message", + "columnName": "message", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "actorId", + "columnName": "actorId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "actorType", + "columnName": "actorType", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "deleted", + "columnName": "deleted", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "expirationTimestamp", + "columnName": "expirationTimestamp", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "replyable", + "columnName": "isReplyable", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isTemporary", + "columnName": "isTemporary", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastEditActorDisplayName", + "columnName": "lastEditActorDisplayName", + "affinity": "TEXT" + }, + { + "fieldPath": "lastEditActorId", + "columnName": "lastEditActorId", + "affinity": "TEXT" + }, + { + "fieldPath": "lastEditActorType", + "columnName": "lastEditActorType", + "affinity": "TEXT" + }, + { + "fieldPath": "lastEditTimestamp", + "columnName": "lastEditTimestamp", + "affinity": "INTEGER" + }, + { + "fieldPath": "renderMarkdown", + "columnName": "markdown", + "affinity": "INTEGER" + }, + { + "fieldPath": "messageParameters", + "columnName": "messageParameters", + "affinity": "TEXT" + }, + { + "fieldPath": "messageType", + "columnName": "messageType", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "parentMessageId", + "columnName": "parent", + "affinity": "INTEGER" + }, + { + "fieldPath": "reactions", + "columnName": "reactions", + "affinity": "TEXT" + }, + { + "fieldPath": "reactionsSelf", + "columnName": "reactionsSelf", + "affinity": "TEXT" + }, + { + "fieldPath": "referenceId", + "columnName": "referenceId", + "affinity": "TEXT" + }, + { + "fieldPath": "sendStatus", + "columnName": "sendStatus", + "affinity": "TEXT" + }, + { + "fieldPath": "silent", + "columnName": "silent", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "systemMessageType", + "columnName": "systemMessage", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "timestamp", + "columnName": "timestamp", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "internalId" + ] + }, + "indices": [ + { + "name": "index_ChatMessages_internalId", + "unique": true, + "columnNames": [ + "internalId" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_ChatMessages_internalId` ON `${TABLE_NAME}` (`internalId`)" + }, + { + "name": "index_ChatMessages_internalConversationId", + "unique": false, + "columnNames": [ + "internalConversationId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_ChatMessages_internalConversationId` ON `${TABLE_NAME}` (`internalConversationId`)" + } + ], + "foreignKeys": [ + { + "table": "Conversations", + "onDelete": "CASCADE", + "onUpdate": "CASCADE", + "columns": [ + "internalConversationId" + ], + "referencedColumns": [ + "internalId" + ] + } + ] + }, + { + "tableName": "ChatBlocks", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `internalConversationId` TEXT NOT NULL, `accountId` INTEGER, `token` TEXT, `threadId` INTEGER, `oldestMessageId` INTEGER NOT NULL, `newestMessageId` INTEGER NOT NULL, `hasHistory` INTEGER NOT NULL, FOREIGN KEY(`internalConversationId`) REFERENCES `Conversations`(`internalId`) ON UPDATE CASCADE ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "internalConversationId", + "columnName": "internalConversationId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "accountId", + "columnName": "accountId", + "affinity": "INTEGER" + }, + { + "fieldPath": "token", + "columnName": "token", + "affinity": "TEXT" + }, + { + "fieldPath": "threadId", + "columnName": "threadId", + "affinity": "INTEGER" + }, + { + "fieldPath": "oldestMessageId", + "columnName": "oldestMessageId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "newestMessageId", + "columnName": "newestMessageId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "hasHistory", + "columnName": "hasHistory", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_ChatBlocks_internalConversationId", + "unique": false, + "columnNames": [ + "internalConversationId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_ChatBlocks_internalConversationId` ON `${TABLE_NAME}` (`internalConversationId`)" + } + ], + "foreignKeys": [ + { + "table": "Conversations", + "onDelete": "CASCADE", + "onUpdate": "CASCADE", + "columns": [ + "internalConversationId" + ], + "referencedColumns": [ + "internalId" + ] + } + ] + } + ], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '7330dad871a0b42e36931ffe8c7d4bcf')" + ] + } +} \ No newline at end of file diff --git a/app/src/androidTest/java/com/nextcloud/talk/data/database/dao/ChatBlocksDaoTest.kt b/app/src/androidTest/java/com/nextcloud/talk/data/database/dao/ChatBlocksDaoTest.kt index 4f2f39971f..284b339124 100644 --- a/app/src/androidTest/java/com/nextcloud/talk/data/database/dao/ChatBlocksDaoTest.kt +++ b/app/src/androidTest/java/com/nextcloud/talk/data/database/dao/ChatBlocksDaoTest.kt @@ -57,6 +57,7 @@ class ChatBlocksDaoTest { val account1 = usersDao.getUserWithUserId("account1").blockingGet() conversationsDao.upsertConversations( + account1.id, listOf( createConversationEntity( accountId = account1.id, @@ -182,6 +183,7 @@ class ChatBlocksDaoTest { val account1 = usersDao.getUserWithUserId("account1").blockingGet() conversationsDao.upsertConversations( + account1.id, listOf( createConversationEntity( accountId = account1.id, diff --git a/app/src/androidTest/java/com/nextcloud/talk/data/database/dao/ChatMessagesDaoTest.kt b/app/src/androidTest/java/com/nextcloud/talk/data/database/dao/ChatMessagesDaoTest.kt index 424b932b02..6bcce26f8a 100644 --- a/app/src/androidTest/java/com/nextcloud/talk/data/database/dao/ChatMessagesDaoTest.kt +++ b/app/src/androidTest/java/com/nextcloud/talk/data/database/dao/ChatMessagesDaoTest.kt @@ -66,6 +66,7 @@ class ChatMessagesDaoTest { // Problem: lets say we want to update the conv list -> We don#t know the primary keys! // with account@token that would be easier! conversationsDao.upsertConversations( + account1.id, listOf( createConversationEntity( accountId = account1.id, diff --git a/app/src/main/java/com/nextcloud/talk/chat/MessageInputFragment.kt b/app/src/main/java/com/nextcloud/talk/chat/MessageInputFragment.kt index 0ecc553141..8591382927 100644 --- a/app/src/main/java/com/nextcloud/talk/chat/MessageInputFragment.kt +++ b/app/src/main/java/com/nextcloud/talk/chat/MessageInputFragment.kt @@ -35,7 +35,6 @@ import android.widget.LinearLayout import android.widget.PopupMenu import android.widget.RelativeLayout import android.widget.SeekBar -import androidx.appcompat.app.AppCompatActivity import androidx.appcompat.content.res.AppCompatResources import androidx.appcompat.view.ContextThemeWrapper import androidx.core.content.ContextCompat @@ -81,15 +80,17 @@ import com.nextcloud.talk.utils.database.user.CurrentUserProviderNew import com.nextcloud.talk.utils.message.MessageUtils import com.nextcloud.talk.utils.text.Spans import com.otaliastudios.autocomplete.Autocomplete -import com.stfalcon.chatkit.commons.models.IMessage import com.vanniktech.emoji.EmojiPopup +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.collect import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext import java.util.Objects import javax.inject.Inject -@Suppress("LongParameterList", "TooManyFunctions") +@Suppress("LongParameterList", "TooManyFunctions", "LargeClass", "LongMethod") @AutoInjector(NextcloudTalkApplication::class) class MessageInputFragment : Fragment() { @@ -161,18 +162,12 @@ class MessageInputFragment : Fragment() { return binding.root } - override fun onPause() { - super.onPause() - saveState() - } - override fun onDestroyView() { super.onDestroyView() if (mentionAutocomplete != null && mentionAutocomplete!!.isPopupShowing) { mentionAutocomplete?.dismissPopup() } clearEditUI() - cancelReply() } override fun onViewCreated(view: View, savedInstanceState: Bundle?) { @@ -183,7 +178,13 @@ class MessageInputFragment : Fragment() { private fun initObservers() { Log.d(TAG, "LifeCyclerOwner is: ${viewLifecycleOwner.lifecycle}") chatActivity.messageInputViewModel.getReplyChatMessage.observe(viewLifecycleOwner) { message -> - message?.let { replyToMessage(message) } + (message as ChatMessage?)?.let { + chatActivity.chatViewModel.messageDraft.quotedMessageText = message.text + chatActivity.chatViewModel.messageDraft.quotedDisplayName = message.actorDisplayName + chatActivity.chatViewModel.messageDraft.quotedImageUrl = message.imageUrl + chatActivity.chatViewModel.messageDraft.quotedJsonId = message.jsonMessageId + replyToMessage(message.text, message.actorDisplayName, message.imageUrl, message.jsonMessageId) + } } chatActivity.messageInputViewModel.getEditChatMessage.observe(viewLifecycleOwner) { message -> @@ -299,33 +300,25 @@ class MessageInputFragment : Fragment() { } private fun restoreState() { - if (binding.fragmentMessageInputView.inputEditText.text.isEmpty()) { - requireContext().getSharedPreferences(chatActivity.localClassName, AppCompatActivity.MODE_PRIVATE).apply { - val text = getString(chatActivity.roomToken, "") - val cursor = getInt(chatActivity.roomToken + CURSOR_KEY, 0) - binding.fragmentMessageInputView.messageInput.setText(text) - binding.fragmentMessageInputView.messageInput.setSelection(cursor) - } - } - } + CoroutineScope(Dispatchers.IO).launch { + chatActivity.chatViewModel.updateMessageDraft() + + withContext(Dispatchers.Main) { + val draft = chatActivity.chatViewModel.messageDraft + binding.fragmentMessageInputView.messageInput.setText(draft.messageText) + binding.fragmentMessageInputView.messageInput.setSelection(draft.messageCursor) + if (draft.messageText != "") { + binding.fragmentMessageInputView.messageInput.requestFocus() + } - private fun saveState() { - val text = binding.fragmentMessageInputView.messageInput.text.toString() - val cursor = binding.fragmentMessageInputView.messageInput.selectionStart - val previous = requireContext().getSharedPreferences( - chatActivity.localClassName, - AppCompatActivity - .MODE_PRIVATE - ).getString(chatActivity.roomToken, "null") - - if (text != previous) { - requireContext().getSharedPreferences( - chatActivity.localClassName, - AppCompatActivity.MODE_PRIVATE - ).edit().apply { - putString(chatActivity.roomToken, text) - putInt(chatActivity.roomToken + CURSOR_KEY, cursor) - apply() + if (isInReplyState()) { + replyToMessage( + chatActivity.chatViewModel.messageDraft.quotedMessageText, + chatActivity.chatViewModel.messageDraft.quotedDisplayName, + chatActivity.chatViewModel.messageDraft.quotedImageUrl, + chatActivity.chatViewModel.messageDraft.quotedJsonId ?: 0 + ) + } } } } @@ -388,7 +381,10 @@ class MessageInputFragment : Fragment() { } override fun afterTextChanged(s: Editable) { - // unused atm + val cursor = binding.fragmentMessageInputView.messageInput.selectionStart + val text = binding.fragmentMessageInputView.messageInput.text.toString() + chatActivity.chatViewModel.messageDraft.messageCursor = cursor + chatActivity.chatViewModel.messageDraft.messageText = text } }) @@ -615,7 +611,7 @@ class MessageInputFragment : Fragment() { } } } - v?.onTouchEvent(event) ?: true + v?.onTouchEvent(event) != false } } @@ -717,52 +713,54 @@ class MessageInputFragment : Fragment() { } } - private fun replyToMessage(message: IMessage?) { + private fun replyToMessage( + quotedMessageText: String?, + quotedActorDisplayName: String?, + quotedImageUrl: String?, + quotedJsonId: Int + ) { Log.d(TAG, "Reply") - val chatMessage = message as ChatMessage? - chatMessage?.let { - val view = binding.fragmentMessageInputView - view.findViewById(R.id.attachmentButton)?.visibility = - View.GONE - view.findViewById(R.id.cancelReplyButton)?.visibility = - View.VISIBLE - - val quotedMessage = view.findViewById(R.id.quotedMessage) - - quotedMessage?.maxLines = 2 - quotedMessage?.ellipsize = TextUtils.TruncateAt.END - quotedMessage?.text = it.text - view.findViewById(R.id.quotedMessageAuthor)?.text = - it.actorDisplayName ?: requireContext().getText(R.string.nc_nick_guest) - - chatActivity.conversationUser?.let { - val quotedMessageImage = view.findViewById(R.id.quotedMessageImage) - chatMessage.imageUrl?.let { previewImageUrl -> - quotedMessageImage?.visibility = View.VISIBLE - - val px = TypedValue.applyDimension( - TypedValue.COMPLEX_UNIT_DIP, - QUOTED_MESSAGE_IMAGE_MAX_HEIGHT, - resources.displayMetrics - ) - - quotedMessageImage?.maxHeight = px.toInt() - val layoutParams = quotedMessageImage?.layoutParams as FlexboxLayout.LayoutParams - layoutParams.flexGrow = 0f - quotedMessageImage.layoutParams = layoutParams - quotedMessageImage.load(previewImageUrl) { - addHeader("Authorization", chatActivity.credentials!!) - } - } ?: run { - view.findViewById(R.id.quotedMessageImage)?.visibility = View.GONE + val view = binding.fragmentMessageInputView + view.findViewById(R.id.attachmentButton)?.visibility = + View.GONE + view.findViewById(R.id.cancelReplyButton)?.visibility = + View.VISIBLE + + val quotedMessage = view.findViewById(R.id.quotedMessage) + + quotedMessage?.maxLines = 2 + quotedMessage?.ellipsize = TextUtils.TruncateAt.END + quotedMessage?.text = quotedMessageText + view.findViewById(R.id.quotedMessageAuthor)?.text = + quotedActorDisplayName ?: requireContext().getText(R.string.nc_nick_guest) + + chatActivity.conversationUser?.let { + val quotedMessageImage = view.findViewById(R.id.quotedMessageImage) + quotedImageUrl?.let { previewImageUrl -> + quotedMessageImage?.visibility = View.VISIBLE + + val px = TypedValue.applyDimension( + TypedValue.COMPLEX_UNIT_DIP, + QUOTED_MESSAGE_IMAGE_MAX_HEIGHT, + resources.displayMetrics + ) + + quotedMessageImage?.maxHeight = px.toInt() + val layoutParams = quotedMessageImage?.layoutParams as FlexboxLayout.LayoutParams + layoutParams.flexGrow = 0f + quotedMessageImage.layoutParams = layoutParams + quotedMessageImage.load(previewImageUrl) { + addHeader("Authorization", chatActivity.credentials!!) } + } ?: run { + view.findViewById(R.id.quotedMessageImage)?.visibility = View.GONE } - - val quotedChatMessageView = - view.findViewById(R.id.quotedChatMessageView) - quotedChatMessageView?.tag = message?.jsonMessageId - quotedChatMessageView?.visibility = View.VISIBLE } + + val quotedChatMessageView = + view.findViewById(R.id.quotedChatMessageView) + quotedChatMessageView?.tag = quotedJsonId + quotedChatMessageView?.visibility = View.VISIBLE } fun updateOwnTypingStatus(typedText: CharSequence) { @@ -1051,5 +1049,15 @@ class MessageInputFragment : Fragment() { quote.tag = null binding.fragmentMessageInputView.findViewById(R.id.attachmentButton)?.visibility = View.VISIBLE chatActivity.messageInputViewModel.reply(null) + + chatActivity.chatViewModel.messageDraft.quotedMessageText = null + chatActivity.chatViewModel.messageDraft.quotedDisplayName = null + chatActivity.chatViewModel.messageDraft.quotedImageUrl = null + chatActivity.chatViewModel.messageDraft.quotedJsonId = null + } + + private fun isInReplyState(): Boolean { + val jsonId = chatActivity.chatViewModel.messageDraft.quotedJsonId + return jsonId != null } } diff --git a/app/src/main/java/com/nextcloud/talk/chat/viewmodels/ChatViewModel.kt b/app/src/main/java/com/nextcloud/talk/chat/viewmodels/ChatViewModel.kt index 2b1371d7b2..e0c97381b7 100644 --- a/app/src/main/java/com/nextcloud/talk/chat/viewmodels/ChatViewModel.kt +++ b/app/src/main/java/com/nextcloud/talk/chat/viewmodels/ChatViewModel.kt @@ -27,6 +27,7 @@ import com.nextcloud.talk.conversationlist.data.OfflineConversationsRepository import com.nextcloud.talk.data.user.model.User import com.nextcloud.talk.extensions.toIntOrZero import com.nextcloud.talk.jobs.UploadAndShareFilesWorker +import com.nextcloud.talk.models.MessageDraft import com.nextcloud.talk.models.domain.ConversationModel import com.nextcloud.talk.models.domain.ReactionAddedModel import com.nextcloud.talk.models.domain.ReactionDeletedModel @@ -89,6 +90,8 @@ class ChatViewModel @Inject constructor( val disposableSet = mutableSetOf() var mediaPlayerDuration = mediaPlayerManager.mediaPlayerDuration val mediaPlayerPosition = mediaPlayerManager.mediaPlayerPosition + var chatRoomToken: String = "" + var messageDraft: MessageDraft = MessageDraft() fun getChatRepository(): ChatMessageRepository = chatRepository @@ -108,6 +111,14 @@ class ChatViewModel @Inject constructor( mediaRecorderManager.handleOnPause() chatRepository.handleOnPause() mediaPlayerManager.handleOnPause() + + CoroutineScope(Dispatchers.IO).launch { + val model = conversationRepository.getLocallyStoredConversation(chatRoomToken) + model?.let { + it.messageDraft = messageDraft + conversationRepository.updateConversation(it) + } + } } override fun onStop(owner: LifecycleOwner) { @@ -283,6 +294,7 @@ class ChatViewModel @Inject constructor( fun initData(credentials: String, urlForChatting: String, roomToken: String, threadId: Long?) { chatRepository.initData(credentials, urlForChatting, roomToken, threadId) + chatRoomToken = roomToken } fun updateConversation(currentConversation: ConversationModel) { @@ -889,6 +901,13 @@ class ChatViewModel @Inject constructor( } } + suspend fun updateMessageDraft() { + val model = conversationRepository.getLocallyStoredConversation(chatRoomToken) + model?.messageDraft?.let { + messageDraft = it + } + } + companion object { private val TAG = ChatViewModel::class.simpleName const val JOIN_ROOM_RETRY_COUNT: Long = 3 diff --git a/app/src/main/java/com/nextcloud/talk/conversationlist/ConversationsListActivity.kt b/app/src/main/java/com/nextcloud/talk/conversationlist/ConversationsListActivity.kt index be0fb7fe77..4cb7e19516 100644 --- a/app/src/main/java/com/nextcloud/talk/conversationlist/ConversationsListActivity.kt +++ b/app/src/main/java/com/nextcloud/talk/conversationlist/ConversationsListActivity.kt @@ -2216,6 +2216,7 @@ class ConversationsListActivity : const val UNREAD_BUBBLE_DELAY = 2500 const val BOTTOM_SHEET_DELAY: Long = 2500 private const val KEY_SEARCH_QUERY = "ConversationsListActivity.searchQuery" + private const val CHAT_ACTIVITY_LOCAL_NAME = "com.nextcloud.talk.chat.ChatActivity" const val SEARCH_DEBOUNCE_INTERVAL_MS = 300 const val SEARCH_MIN_CHARS = 1 const val HTTP_UNAUTHORIZED = 401 diff --git a/app/src/main/java/com/nextcloud/talk/conversationlist/data/OfflineConversationsRepository.kt b/app/src/main/java/com/nextcloud/talk/conversationlist/data/OfflineConversationsRepository.kt index 92591fe4e1..a115ac008a 100644 --- a/app/src/main/java/com/nextcloud/talk/conversationlist/data/OfflineConversationsRepository.kt +++ b/app/src/main/java/com/nextcloud/talk/conversationlist/data/OfflineConversationsRepository.kt @@ -36,4 +36,8 @@ interface OfflineConversationsRepository { * to be handled asynchronously. */ fun getRoom(roomToken: String): Job + + suspend fun updateConversation(conversationModel: ConversationModel) + + suspend fun getLocallyStoredConversation(roomToken: String): ConversationModel? } diff --git a/app/src/main/java/com/nextcloud/talk/conversationlist/data/network/OfflineFirstConversationsRepository.kt b/app/src/main/java/com/nextcloud/talk/conversationlist/data/network/OfflineFirstConversationsRepository.kt index c4b9419624..2e3687ac96 100644 --- a/app/src/main/java/com/nextcloud/talk/conversationlist/data/network/OfflineFirstConversationsRepository.kt +++ b/app/src/main/java/com/nextcloud/talk/conversationlist/data/network/OfflineFirstConversationsRepository.kt @@ -98,12 +98,22 @@ class OfflineFirstConversationsRepository @Inject constructor( runBlocking { _conversationFlow.emit(model) val entityList = listOf(model.asEntity()) - dao.upsertConversations(entityList) + dao.upsertConversations(user.id!!, entityList) } } }) } + override suspend fun updateConversation(conversationModel: ConversationModel) { + val entity = conversationModel.asEntity() + dao.updateConversation(entity) + } + + override suspend fun getLocallyStoredConversation(roomToken: String): ConversationModel? { + val id = user.id!! + return getConversation(id, roomToken) + } + @Suppress("Detekt.TooGenericExceptionCaught") private suspend fun getRoomsFromServer(): List? { var conversationsFromSync: List? = null @@ -126,7 +136,7 @@ class OfflineFirstConversationsRepository @Inject constructor( } deleteLeftConversations(conversationsFromSync) - dao.upsertConversations(conversationsFromSync) + dao.upsertConversations(user.id!!, conversationsFromSync) } catch (e: Exception) { Log.e(TAG, "Something went wrong when fetching conversations", e) } diff --git a/app/src/main/java/com/nextcloud/talk/data/database/dao/ConversationsDao.kt b/app/src/main/java/com/nextcloud/talk/data/database/dao/ConversationsDao.kt index b6b7ae14fe..621207cccb 100644 --- a/app/src/main/java/com/nextcloud/talk/data/database/dao/ConversationsDao.kt +++ b/app/src/main/java/com/nextcloud/talk/data/database/dao/ConversationsDao.kt @@ -8,11 +8,14 @@ package com.nextcloud.talk.data.database.dao import androidx.room.Dao +import androidx.room.Insert +import androidx.room.OnConflictStrategy.Companion.REPLACE import androidx.room.Query +import androidx.room.Transaction import androidx.room.Update -import androidx.room.Upsert import com.nextcloud.talk.data.database.model.ConversationEntity import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.first @Dao interface ConversationsDao { @@ -22,8 +25,19 @@ interface ConversationsDao { @Query("SELECT * FROM Conversations where accountId = :accountId AND token = :token") fun getConversationForUser(accountId: Long, token: String): Flow - @Upsert - fun upsertConversations(conversationEntities: List) + @Transaction + suspend fun upsertConversations(accountId: Long, serverItems: List) { + serverItems.forEach { serverItem -> + val existingItem = getConversationForUser(accountId, serverItem.token).first() + if (existingItem != null) { + val mergedItem = serverItem.copy() + mergedItem.messageDraft = existingItem.messageDraft + updateConversation(mergedItem) + } else { + insertConversation(serverItem) + } + } + } /** * Deletes rows in the db matching the specified [conversationIds] @@ -36,9 +50,12 @@ interface ConversationsDao { ) fun deleteConversations(conversationIds: List) - @Update + @Update(onConflict = REPLACE) fun updateConversation(conversationEntity: ConversationEntity) + @Insert(onConflict = REPLACE) + fun insertConversation(conversation: ConversationEntity) + @Query( """ DELETE FROM Conversations diff --git a/app/src/main/java/com/nextcloud/talk/data/database/mappers/ConversationMapUtils.kt b/app/src/main/java/com/nextcloud/talk/data/database/mappers/ConversationMapUtils.kt index dd28633e3f..0953376f72 100644 --- a/app/src/main/java/com/nextcloud/talk/data/database/mappers/ConversationMapUtils.kt +++ b/app/src/main/java/com/nextcloud/talk/data/database/mappers/ConversationMapUtils.kt @@ -1,7 +1,7 @@ /* * Nextcloud Talk - Android Client * - * SPDX-FileCopyrightText: 2024 Your Name + * SPDX-FileCopyrightText: 2024 Julius Linus * SPDX-License-Identifier: GPL-3.0-or-later */ @@ -63,7 +63,8 @@ fun ConversationModel.asEntity() = remoteToken = remoteToken, hasArchived = hasArchived, hasSensitive = hasSensitive, - hasImportant = hasImportant + hasImportant = hasImportant, + messageDraft = messageDraft ) fun ConversationEntity.asModel() = @@ -117,7 +118,8 @@ fun ConversationEntity.asModel() = remoteToken = remoteToken, hasArchived = hasArchived, hasSensitive = hasSensitive, - hasImportant = hasImportant + hasImportant = hasImportant, + messageDraft = messageDraft ) fun Conversation.asEntity(accountId: Long) = diff --git a/app/src/main/java/com/nextcloud/talk/data/database/model/ConversationEntity.kt b/app/src/main/java/com/nextcloud/talk/data/database/model/ConversationEntity.kt index 8cdd4db585..8301b8c17f 100644 --- a/app/src/main/java/com/nextcloud/talk/data/database/model/ConversationEntity.kt +++ b/app/src/main/java/com/nextcloud/talk/data/database/model/ConversationEntity.kt @@ -13,6 +13,7 @@ import androidx.room.ForeignKey import androidx.room.Index import androidx.room.PrimaryKey import com.nextcloud.talk.data.user.model.UserEntity +import com.nextcloud.talk.models.MessageDraft import com.nextcloud.talk.models.json.conversations.ConversationEnums import com.nextcloud.talk.models.json.participants.Participant @@ -96,7 +97,8 @@ data class ConversationEntity( @ColumnInfo(name = "unreadMessages") var unreadMessages: Int = 0, @ColumnInfo(name = "hasArchived") var hasArchived: Boolean = false, @ColumnInfo(name = "hasSensitive") var hasSensitive: Boolean = false, - @ColumnInfo(name = "hasImportant") var hasImportant: Boolean = false + @ColumnInfo(name = "hasImportant") var hasImportant: Boolean = false, + @ColumnInfo(name = "messageDraft") var messageDraft: MessageDraft? = MessageDraft() // missing/not needed: attendeeId // missing/not needed: attendeePin // missing/not needed: attendeePermissions diff --git a/app/src/main/java/com/nextcloud/talk/data/source/local/TalkDatabase.kt b/app/src/main/java/com/nextcloud/talk/data/source/local/TalkDatabase.kt index ede508c1d6..c67c465557 100644 --- a/app/src/main/java/com/nextcloud/talk/data/source/local/TalkDatabase.kt +++ b/app/src/main/java/com/nextcloud/talk/data/source/local/TalkDatabase.kt @@ -36,6 +36,7 @@ import com.nextcloud.talk.data.storage.ArbitraryStoragesDao import com.nextcloud.talk.data.storage.model.ArbitraryStorageEntity import com.nextcloud.talk.data.user.UsersDao import com.nextcloud.talk.data.user.model.UserEntity +import com.nextcloud.talk.models.MessageDraftConverter import net.zetetic.database.sqlcipher.SupportOpenHelperFactory import java.util.Locale @@ -47,10 +48,11 @@ import java.util.Locale ChatMessageEntity::class, ChatBlockEntity::class ], - version = 18, + version = 19, autoMigrations = [ AutoMigration(from = 9, to = 10), - AutoMigration(from = 16, to = 17, spec = AutoMigration16To17::class) + AutoMigration(from = 16, to = 17, spec = AutoMigration16To17::class), + AutoMigration(from = 18, to = 19) ], exportSchema = true ) @@ -63,7 +65,8 @@ import java.util.Locale HashMapHashMapConverter::class, LinkedHashMapConverter::class, ArrayListConverter::class, - SendStatusConverter::class + SendStatusConverter::class, + MessageDraftConverter::class ) abstract class TalkDatabase : RoomDatabase() { abstract fun usersDao(): UsersDao diff --git a/app/src/main/java/com/nextcloud/talk/models/MessageDraft.kt b/app/src/main/java/com/nextcloud/talk/models/MessageDraft.kt new file mode 100644 index 0000000000..a41dacf753 --- /dev/null +++ b/app/src/main/java/com/nextcloud/talk/models/MessageDraft.kt @@ -0,0 +1,55 @@ +/* + * Nextcloud Talk - Android Client + * + * SPDX-FileCopyrightText: 2025 Julius Linus + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +package com.nextcloud.talk.models + +import android.os.Parcelable +import androidx.room.TypeConverter +import com.bluelinelabs.logansquare.LoganSquare +import com.bluelinelabs.logansquare.annotation.JsonField +import com.bluelinelabs.logansquare.annotation.JsonObject +import kotlinx.parcelize.Parcelize +import kotlinx.serialization.Serializable + +@Parcelize +@JsonObject +@Serializable +data class MessageDraft( + @JsonField(name = ["messageText"]) + var messageText: String = "", + @JsonField(name = ["messageCursor"]) + var messageCursor: Int = 0, + @JsonField(name = ["quotedJsonId"]) + var quotedJsonId: Int? = null, + @JsonField(name = ["quotedDisplayName"]) + var quotedDisplayName: String? = null, + @JsonField(name = ["quotedMessageText"]) + var quotedMessageText: String? = null, + @JsonField(name = ["quoteImageUrl"]) + var quotedImageUrl: String? = null +) : Parcelable { + constructor() : this("", 0, null, null, null, null) +} + +class MessageDraftConverter { + + @TypeConverter + fun fromMessageDraftToString(messageDraft: MessageDraft?): String = + if (messageDraft == null) { + "" + } else { + LoganSquare.serialize(messageDraft) + } + + @TypeConverter + fun fromStringToMessageDraft(value: String): MessageDraft? = + if (value.isBlank()) { + null + } else { + LoganSquare.parse(value, MessageDraft::class.java) + } +} diff --git a/app/src/main/java/com/nextcloud/talk/models/domain/ConversationModel.kt b/app/src/main/java/com/nextcloud/talk/models/domain/ConversationModel.kt index 59ed023c50..c5ea387f01 100644 --- a/app/src/main/java/com/nextcloud/talk/models/domain/ConversationModel.kt +++ b/app/src/main/java/com/nextcloud/talk/models/domain/ConversationModel.kt @@ -8,6 +8,7 @@ package com.nextcloud.talk.models.domain import com.nextcloud.talk.data.user.model.User +import com.nextcloud.talk.models.MessageDraft import com.nextcloud.talk.models.json.chat.ChatMessageJson import com.nextcloud.talk.models.json.conversations.Conversation import com.nextcloud.talk.models.json.conversations.ConversationEnums @@ -65,7 +66,8 @@ class ConversationModel( var hasImportant: Boolean = false, // attributes that don't come from API. This should be changed?! - var password: String? = null + var password: String? = null, + var messageDraft: MessageDraft? = MessageDraft() ) { companion object { diff --git a/app/src/main/java/com/nextcloud/talk/utils/preview/ComposePreviewUtilsDaos.kt b/app/src/main/java/com/nextcloud/talk/utils/preview/ComposePreviewUtilsDaos.kt index 4354457742..2ee67c08dd 100644 --- a/app/src/main/java/com/nextcloud/talk/utils/preview/ComposePreviewUtilsDaos.kt +++ b/app/src/main/java/com/nextcloud/talk/utils/preview/ComposePreviewUtilsDaos.kt @@ -192,12 +192,14 @@ class DummyConversationDaoImpl : ConversationsDao { override fun getConversationForUser(accountId: Long, token: String): Flow = flowOf() - override fun upsertConversations(conversationEntities: List) { /* */ } + override suspend fun upsertConversations(accountId: Long, serverItems: List) { /* */ } override fun deleteConversations(conversationIds: List) { /* */ } override fun updateConversation(conversationEntity: ConversationEntity) { /* */ } + override fun insertConversation(conversation: ConversationEntity) { /* */ } + override fun clearAllConversationsForUser(accountId: Long) { /* */ } }