Merge pull request #4422 from nextcloud/feature/4378/addTemporaryMessagesWhileSending

Feature/4378/add temporary messages while sending
This commit is contained in:
Marcel Hibbe 2025-01-10 13:43:10 +01:00 committed by GitHub
commit 88bb5d506a
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
40 changed files with 2045 additions and 960 deletions

View File

@ -0,0 +1,749 @@
{
"formatVersion": 1,
"database": {
"version": 13,
"identityHash": "a521f027909f69f4c7d1855f84a2e67f",
"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",
"notNull": false
},
{
"fieldPath": "username",
"columnName": "username",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "baseUrl",
"columnName": "baseUrl",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "token",
"columnName": "token",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "displayName",
"columnName": "displayName",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "pushConfigurationState",
"columnName": "pushConfigurationState",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "capabilities",
"columnName": "capabilities",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "serverVersion",
"columnName": "serverVersion",
"affinity": "TEXT",
"notNull": false,
"defaultValue": "''"
},
{
"fieldPath": "clientCertificate",
"columnName": "clientCertificate",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "externalSignalingServer",
"columnName": "externalSignalingServer",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "current",
"columnName": "current",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "scheduledForDeletion",
"columnName": "scheduledForDeletion",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": true,
"columnNames": [
"id"
]
},
"indices": [],
"foreignKeys": []
},
{
"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",
"notNull": false
},
{
"fieldPath": "value",
"columnName": "value",
"affinity": "TEXT",
"notNull": false
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"accountIdentifier",
"key"
]
},
"indices": [],
"foreignKeys": []
},
{
"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, `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, 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",
"notNull": false
},
{
"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": "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",
"notNull": false
},
{
"fieldPath": "remoteToken",
"columnName": "remoteToken",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "sessionId",
"columnName": "sessionId",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "status",
"columnName": "status",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "statusClearAt",
"columnName": "statusClearAt",
"affinity": "INTEGER",
"notNull": false
},
{
"fieldPath": "statusIcon",
"columnName": "statusIcon",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "statusMessage",
"columnName": "statusMessage",
"affinity": "TEXT",
"notNull": false
},
{
"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
}
],
"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, `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, `sendingFailed` INTEGER NOT NULL, `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": "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",
"notNull": false
},
{
"fieldPath": "lastEditActorId",
"columnName": "lastEditActorId",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "lastEditActorType",
"columnName": "lastEditActorType",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "lastEditTimestamp",
"columnName": "lastEditTimestamp",
"affinity": "INTEGER",
"notNull": false
},
{
"fieldPath": "renderMarkdown",
"columnName": "markdown",
"affinity": "INTEGER",
"notNull": false
},
{
"fieldPath": "messageParameters",
"columnName": "messageParameters",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "messageType",
"columnName": "messageType",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "parentMessageId",
"columnName": "parent",
"affinity": "INTEGER",
"notNull": false
},
{
"fieldPath": "reactions",
"columnName": "reactions",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "reactionsSelf",
"columnName": "reactionsSelf",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "referenceId",
"columnName": "referenceId",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "sendingFailed",
"columnName": "sendingFailed",
"affinity": "INTEGER",
"notNull": true
},
{
"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, `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",
"notNull": false
},
{
"fieldPath": "token",
"columnName": "token",
"affinity": "TEXT",
"notNull": false
},
{
"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"
]
}
]
}
],
"views": [],
"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, 'a521f027909f69f4c7d1855f84a2e67f')"
]
}
}

View File

@ -218,7 +218,6 @@ class IncomingTextMessageViewHolder(itemView: View, payload: Any) :
)
binding.messageQuote.quotedChatMessageView.setOnClickListener {
val chatActivity = commonMessageInterface as ChatActivity
chatActivity.jumpToQuotedMessage(parentChatMessage)
}
} catch (e: Exception) {

View File

@ -23,6 +23,7 @@ import com.nextcloud.talk.application.NextcloudTalkApplication
import com.nextcloud.talk.application.NextcloudTalkApplication.Companion.sharedApplication
import com.nextcloud.talk.chat.ChatActivity
import com.nextcloud.talk.chat.data.model.ChatMessage
import com.nextcloud.talk.data.network.NetworkMonitor
import com.nextcloud.talk.databinding.ItemCustomOutcomingTextMessageBinding
import com.nextcloud.talk.models.json.chat.ReadStatus
import com.nextcloud.talk.ui.theme.ViewThemeUtils
@ -58,8 +59,12 @@ class OutcomingTextMessageViewHolder(itemView: View) :
@Inject
lateinit var dateUtils: DateUtils
@Inject
lateinit var networkMonitor: NetworkMonitor
lateinit var commonMessageInterface: CommonMessageInterface
@Suppress("Detekt.LongMethod")
override fun onBind(message: ChatMessage) {
super.onBind(message)
sharedApplication!!.componentApplication.inject(this)
@ -68,6 +73,7 @@ class OutcomingTextMessageViewHolder(itemView: View) :
layoutParams.isWrapBefore = false
var textSize = context.resources.getDimension(R.dimen.chat_text_size)
viewThemeUtils.platform.colorTextView(binding.messageTime, ColorRole.ON_SURFACE_VARIANT)
var processedMessageText = messageUtils.enrichChatMessageText(
binding.messageText.context,
message,
@ -114,7 +120,27 @@ class OutcomingTextMessageViewHolder(itemView: View) :
binding.messageQuote.quotedChatMessageView.visibility = View.GONE
}
setReadStatus(message.readStatus)
binding.checkMark.visibility = View.INVISIBLE
binding.sendingProgress.visibility = View.GONE
if (message.sendingFailed) {
updateStatus(R.drawable.baseline_error_outline_24, context.resources?.getString(R.string.nc_message_failed))
} else if (message.isTemporary) {
updateStatus(R.drawable.baseline_schedule_24, context.resources?.getString(R.string.nc_message_sending))
} else if (message.readStatus == ReadStatus.READ) {
updateStatus(R.drawable.ic_check_all, context.resources?.getString(R.string.nc_message_read))
} else if (message.readStatus == ReadStatus.SENT) {
updateStatus(R.drawable.ic_check, context.resources?.getString(R.string.nc_message_sent))
}
CoroutineScope(Dispatchers.Main).launch {
if (message.isTemporary && !networkMonitor.isOnline.first()) {
updateStatus(
R.drawable.ic_signal_wifi_off_white_24dp,
context.resources?.getString(R.string.nc_message_offline)
)
}
}
itemView.setTag(R.string.replyable_message_view_tag, message.replyable)
@ -129,27 +155,16 @@ class OutcomingTextMessageViewHolder(itemView: View) :
)
}
private fun setReadStatus(readStatus: Enum<ReadStatus>) {
val readStatusDrawableInt = when (readStatus) {
ReadStatus.READ -> R.drawable.ic_check_all
ReadStatus.SENT -> R.drawable.ic_check
else -> null
}
val readStatusContentDescriptionString = when (readStatus) {
ReadStatus.READ -> context.resources?.getString(R.string.nc_message_read)
ReadStatus.SENT -> context.resources?.getString(R.string.nc_message_sent)
else -> null
}
readStatusDrawableInt?.let { drawableInt ->
private fun updateStatus(readStatusDrawableInt: Int, description: String?) {
binding.sendingProgress.visibility = View.GONE
binding.checkMark.visibility = View.VISIBLE
readStatusDrawableInt.let { drawableInt ->
ResourcesCompat.getDrawable(context.resources, drawableInt, null)?.let {
binding.checkMark.setImageDrawable(it)
viewThemeUtils.talk.themeMessageCheckMark(binding.checkMark)
}
}
binding.checkMark.contentDescription = readStatusContentDescriptionString
binding.checkMark.contentDescription = description
}
private fun longClickOnReaction(chatMessage: ChatMessage) {
@ -180,7 +195,7 @@ class OutcomingTextMessageViewHolder(itemView: View) :
).first()
}
parentChatMessage!!.activeUser = message.activeUser
parentChatMessage.activeUser = message.activeUser
parentChatMessage.imageUrl?.let {
binding.messageQuote.quotedMessageImage.visibility = View.VISIBLE
binding.messageQuote.quotedMessageImage.load(it) {
@ -207,7 +222,6 @@ class OutcomingTextMessageViewHolder(itemView: View) :
viewThemeUtils.talk.colorOutgoingQuoteBackground(binding.messageQuote.quoteColoredView)
binding.messageQuote.quotedChatMessageView.setOnClickListener {
val chatActivity = commonMessageInterface as ChatActivity
chatActivity.jumpToQuotedMessage(parentChatMessage)
}
} catch (e: Exception) {

View File

@ -68,9 +68,6 @@ public class TalkMessagesListAdapter<M extends IMessage> extends MessagesListAda
} else if (holder instanceof SystemMessageViewHolder holderInstance) {
holderInstance.assignSystemMessageInterface(chatActivity);
} else if (holder instanceof TemporaryMessageViewHolder holderInstance) {
holderInstance.assignTemporaryMessageInterface(chatActivity);
} else if (holder instanceof IncomingDeckCardViewHolder holderInstance) {
holderInstance.assignCommonMessageInterface(chatActivity);
} else if (holder instanceof OutcomingDeckCardViewHolder holderInstance) {

View File

@ -1,13 +0,0 @@
/*
* Nextcloud Talk - Android Client
*
* SPDX-FileCopyrightText: 2024 Julius Linus <juliuslinus1@gmail.com>
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package com.nextcloud.talk.adapters.messages
interface TemporaryMessageInterface {
fun editTemporaryMessage(id: Int, newMessage: String)
fun deleteTemporaryMessage(id: Int)
}

View File

@ -1,197 +0,0 @@
/*
* Nextcloud Talk - Android Client
*
* SPDX-FileCopyrightText: 2024 Julius Linus <juliuslinus1@gmail.com>
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package com.nextcloud.talk.adapters.messages
import android.content.Context
import android.util.Log
import android.view.View
import androidx.core.content.res.ResourcesCompat
import androidx.core.view.ViewCompat
import androidx.core.view.WindowInsetsCompat
import autodagger.AutoInjector
import coil.load
import com.nextcloud.android.common.ui.theme.utils.ColorRole
import com.nextcloud.talk.R
import com.nextcloud.talk.application.NextcloudTalkApplication
import com.nextcloud.talk.chat.ChatActivity
import com.nextcloud.talk.chat.data.model.ChatMessage
import com.nextcloud.talk.databinding.ItemTemporaryMessageBinding
import com.nextcloud.talk.ui.theme.ViewThemeUtils
import com.nextcloud.talk.utils.ApiUtils
import com.nextcloud.talk.utils.DisplayUtils
import com.nextcloud.talk.utils.message.MessageUtils
import com.stfalcon.chatkit.messages.MessagesListAdapter
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import javax.inject.Inject
@AutoInjector(NextcloudTalkApplication::class)
class TemporaryMessageViewHolder(outgoingView: View, payload: Any) :
MessagesListAdapter.OutcomingMessageViewHolder<ChatMessage>(outgoingView) {
private val binding: ItemTemporaryMessageBinding = ItemTemporaryMessageBinding.bind(outgoingView)
@Inject
lateinit var viewThemeUtils: ViewThemeUtils
@Inject
lateinit var context: Context
@Inject
lateinit var messageUtils: MessageUtils
lateinit var temporaryMessageInterface: TemporaryMessageInterface
var isEditing = false
override fun onBind(message: ChatMessage) {
super.onBind(message)
NextcloudTalkApplication.sharedApplication!!.componentApplication.inject(this)
viewThemeUtils.platform.colorImageView(binding.tempMsgEdit, ColorRole.PRIMARY)
viewThemeUtils.platform.colorImageView(binding.tempMsgDelete, ColorRole.PRIMARY)
binding.tempMsgEdit.setOnClickListener {
isEditing = !isEditing
if (isEditing) {
binding.tempMsgEdit.setImageDrawable(
ResourcesCompat.getDrawable(
context.resources,
R.drawable.ic_check,
null
)
)
binding.messageEdit.visibility = View.VISIBLE
binding.messageEdit.requestFocus()
ViewCompat.getWindowInsetsController(binding.root)?.show(WindowInsetsCompat.Type.ime())
binding.messageEdit.setText(binding.messageText.text)
binding.messageText.visibility = View.GONE
} else {
binding.tempMsgEdit.setImageDrawable(
ResourcesCompat.getDrawable(
context.resources,
R.drawable.ic_edit,
null
)
)
binding.messageEdit.visibility = View.GONE
binding.messageText.visibility = View.VISIBLE
val newMessage = binding.messageEdit.text.toString()
message.message = newMessage
temporaryMessageInterface.editTemporaryMessage(message.tempMessageId, newMessage)
}
}
binding.tempMsgDelete.setOnClickListener {
temporaryMessageInterface.deleteTemporaryMessage(message.tempMessageId)
}
// parent message handling
if (message.parentMessageId != null && message.parentMessageId!! > 0) {
processParentMessage(message)
binding.messageQuote.quotedChatMessageView.visibility = View.VISIBLE
} else {
binding.messageQuote.quotedChatMessageView.visibility = View.GONE
}
val bgBubbleColor = bubble.resources.getColor(R.color.bg_message_list_incoming_bubble, null)
val layout = R.drawable.shape_outcoming_message
val bubbleDrawable = DisplayUtils.getMessageSelector(
bgBubbleColor,
ResourcesCompat.getColor(bubble.resources, R.color.transparent, null),
bgBubbleColor,
layout
)
ViewCompat.setBackground(bubble, bubbleDrawable)
}
@Suppress("Detekt.TooGenericExceptionCaught")
private fun processParentMessage(message: ChatMessage) {
if (message.parentMessageId != null && !message.isDeleted) {
CoroutineScope(Dispatchers.Main).launch {
try {
val chatActivity = temporaryMessageInterface as ChatActivity
val urlForChatting = ApiUtils.getUrlForChat(
chatActivity.chatApiVersion,
chatActivity.conversationUser?.baseUrl,
chatActivity.roomToken
)
val parentChatMessage = withContext(Dispatchers.IO) {
chatActivity.chatViewModel.getMessageById(
urlForChatting,
chatActivity.currentConversation!!,
message.parentMessageId!!
).first()
}
parentChatMessage.activeUser = message.activeUser
parentChatMessage.imageUrl?.let {
binding.messageQuote.quotedMessageImage.visibility = View.VISIBLE
val placeholder = ResourcesCompat.getDrawable(
context.resources,
R.drawable.ic_mimetype_image,
null
)
binding.messageQuote.quotedMessageImage.setImageDrawable(placeholder)
binding.messageQuote.quotedMessageImage.load(it) {
addHeader(
"Authorization",
ApiUtils.getCredentials(message.activeUser!!.username, message.activeUser!!.token)!!
)
}
} ?: run {
binding.messageQuote.quotedMessageImage.visibility = View.GONE
}
binding.messageQuote.quotedMessageAuthor.text = parentChatMessage.actorDisplayName
?: context.getText(R.string.nc_nick_guest)
binding.messageQuote.quotedMessage.text = messageUtils
.enrichChatReplyMessageText(
binding.messageQuote.quotedMessage.context,
parentChatMessage,
false,
viewThemeUtils
)
viewThemeUtils.talk.colorOutgoingQuoteText(binding.messageQuote.quotedMessage)
viewThemeUtils.talk.colorOutgoingQuoteAuthorText(binding.messageQuote.quotedMessageAuthor)
viewThemeUtils.talk.colorOutgoingQuoteBackground(binding.messageQuote.quoteColoredView)
binding.messageQuote.quotedChatMessageView.setOnClickListener {
val chatActivity = temporaryMessageInterface as ChatActivity
chatActivity.jumpToQuotedMessage(parentChatMessage)
}
} catch (e: Exception) {
Log.d(TAG, "Error when processing parent message in view holder", e)
}
}
}
}
fun assignTemporaryMessageInterface(temporaryMessageInterface: TemporaryMessageInterface) {
this.temporaryMessageInterface = temporaryMessageInterface
}
override fun viewDetached() {
// unused atm
}
override fun viewAttached() {
// unused atm
}
override fun viewRecycled() {
// unused atm
}
companion object {
private val TAG = TemporaryMessageViewHolder::class.java.simpleName
}
}

View File

@ -344,18 +344,14 @@ public interface NcApi {
@FormUrlEncoded
@POST
Observable<GenericOverall> sendChatMessage(@Header("Authorization") String authorization,
Observable<ChatOverallSingleMessage> sendChatMessage(@Header("Authorization") String authorization,
@Url String url,
@Field("message") CharSequence message,
@Field("actorDisplayName") String actorDisplayName,
@Field("replyTo") Integer replyTo,
@Field("silent") Boolean sendWithoutNotification);
@FormUrlEncoded
@PUT
Observable<ChatOverallSingleMessage> editChatMessage(@Header("Authorization") String authorization,
@Url String url,
@Field("message") String message);
@Field("silent") Boolean sendWithoutNotification,
@Field("referenceId") String referenceId
);
@GET
Observable<Response<ChatShareOverall>> getSharedItems(

View File

@ -8,6 +8,7 @@
package com.nextcloud.talk.api
import com.nextcloud.talk.models.json.autocomplete.AutocompleteOverall
import com.nextcloud.talk.models.json.chat.ChatOverallSingleMessage
import com.nextcloud.talk.models.json.conversations.RoomOverall
import com.nextcloud.talk.models.json.generic.GenericOverall
import com.nextcloud.talk.models.json.participants.AddParticipantOverall
@ -30,6 +31,7 @@ import retrofit2.http.Query
import retrofit2.http.QueryMap
import retrofit2.http.Url
@Suppress("TooManyFunctions")
interface NcApiCoroutines {
@GET
@JvmSuppressWildcards
@ -122,6 +124,27 @@ interface NcApiCoroutines {
@DELETE
suspend fun unarchiveConversation(@Header("Authorization") authorization: String, @Url url: String): GenericOverall
@Suppress("LongParameterList")
@FormUrlEncoded
@POST
suspend fun sendChatMessage(
@Header("Authorization") authorization: String,
@Url url: String,
@Field("message") message: String,
@Field("actorDisplayName") actorDisplayName: String,
@Field("replyTo") replyTo: Int,
@Field("silent") sendWithoutNotification: Boolean,
@Field("referenceId") referenceId: String
): ChatOverallSingleMessage
@FormUrlEncoded
@PUT
suspend fun editChatMessage(
@Header("Authorization") authorization: String,
@Url url: String,
@Field("message") message: String
): ChatOverallSingleMessage
@FormUrlEncoded
@POST
suspend fun banActor(

View File

@ -111,8 +111,6 @@ import com.nextcloud.talk.adapters.messages.PreviewMessageViewHolder
import com.nextcloud.talk.adapters.messages.SystemMessageInterface
import com.nextcloud.talk.adapters.messages.SystemMessageViewHolder
import com.nextcloud.talk.adapters.messages.TalkMessagesListAdapter
import com.nextcloud.talk.adapters.messages.TemporaryMessageInterface
import com.nextcloud.talk.adapters.messages.TemporaryMessageViewHolder
import com.nextcloud.talk.adapters.messages.UnreadNoticeMessageViewHolder
import com.nextcloud.talk.adapters.messages.VoiceMessageInterface
import com.nextcloud.talk.api.NcApi
@ -154,6 +152,7 @@ import com.nextcloud.talk.ui.dialog.FileAttachmentPreviewFragment
import com.nextcloud.talk.ui.dialog.MessageActionsDialog
import com.nextcloud.talk.ui.dialog.SaveToStorageDialogFragment
import com.nextcloud.talk.ui.dialog.ShowReactionsDialog
import com.nextcloud.talk.ui.dialog.TempMessageActionsDialog
import com.nextcloud.talk.ui.recyclerview.MessageSwipeActions
import com.nextcloud.talk.ui.recyclerview.MessageSwipeCallback
import com.nextcloud.talk.utils.ApiUtils
@ -208,7 +207,6 @@ import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import org.greenrobot.eventbus.Subscribe
import org.greenrobot.eventbus.ThreadMode
import retrofit2.HttpException
import java.io.File
import java.io.IOException
import java.net.HttpURLConnection
@ -231,8 +229,7 @@ class ChatActivity :
CommonMessageInterface,
PreviewMessageInterface,
SystemMessageInterface,
CallStartedMessageInterface,
TemporaryMessageInterface {
CallStartedMessageInterface {
var active = false
@ -319,7 +316,6 @@ class ChatActivity :
var startCallFromNotification: Boolean = false
var startCallFromRoomSwitch: Boolean = false
// lateinit var roomId: String
var voiceOnly: Boolean = true
private lateinit var path: String
@ -452,6 +448,7 @@ class ChatActivity :
chatViewModel = ViewModelProvider(this, viewModelFactory)[ChatViewModel::class.java]
messageInputViewModel = ViewModelProvider(this, viewModelFactory)[MessageInputViewModel::class.java]
messageInputViewModel.setData(chatViewModel.getChatRepository())
this.lifecycleScope.launch {
delay(DELAY_TO_SHOW_PROGRESS_BAR)
@ -524,7 +521,6 @@ class ChatActivity :
private fun handleIntent(intent: Intent) {
val extras: Bundle? = intent.extras
// roomId = extras?.getString(KEY_ROOM_ID).orEmpty()
roomToken = extras?.getString(KEY_ROOM_TOKEN).orEmpty()
sharedText = extras?.getString(BundleKeys.KEY_SHARED_TEXT).orEmpty()
@ -583,35 +579,6 @@ class ChatActivity :
private fun initObservers() {
Log.d(TAG, "initObservers Called")
messageInputViewModel.messageQueueFlow.observe(this) { list ->
list.forEachIndexed { _, qMsg ->
val temporaryChatMessage = ChatMessage()
temporaryChatMessage.jsonMessageId = TEMPORARY_MESSAGE_ID_INT
temporaryChatMessage.actorId = "-3"
temporaryChatMessage.timestamp = System.currentTimeMillis() / ONE_SECOND_IN_MILLIS
temporaryChatMessage.message = qMsg.message.toString()
temporaryChatMessage.tempMessageId = qMsg.id
temporaryChatMessage.isTempMessage = true
temporaryChatMessage.parentMessageId = qMsg.replyTo!!.toLong()
val pos = adapter?.getMessagePositionById(qMsg.replyTo.toString())
adapter?.addToStart(temporaryChatMessage, true)
adapter?.notifyDataSetChanged()
}
}
messageInputViewModel.messageQueueSizeFlow.observe(this) { size ->
if (size == 0) {
var i = 0
var pos = adapter?.getMessagePositionById(TEMPORARY_MESSAGE_ID_STRING)
while (pos != null && pos > -1) {
adapter?.items?.removeAt(pos)
i++
pos = adapter?.getMessagePositionById(TEMPORARY_MESSAGE_ID_STRING)
}
adapter?.notifyDataSetChanged()
}
}
this.lifecycleScope.launch {
chatViewModel.getConversationFlow
.onEach { conversationModel ->
@ -719,7 +686,6 @@ class ChatActivity :
withCredentials = credentials!!,
withUrl = urlForChatting
)
messageInputViewModel.getTempMessagesFromMessageQueue(currentConversation!!.internalId)
}
} else {
Log.w(
@ -744,7 +710,6 @@ class ChatActivity :
sessionIdAfterRoomJoined = currentConversation!!.sessionId
ApplicationWideCurrentRoomHolder.getInstance().session = currentConversation!!.sessionId
// ApplicationWideCurrentRoomHolder.getInstance().currentRoomId = currentConversation!!.roomId
ApplicationWideCurrentRoomHolder.getInstance().currentRoomToken = currentConversation!!.token
ApplicationWideCurrentRoomHolder.getInstance().userInRoom = conversationUser
@ -813,18 +778,7 @@ class ChatActivity :
}
is MessageInputViewModel.SendChatMessageErrorState -> {
if (state.e is HttpException) {
val code = state.e.code()
if (code.toString().startsWith("2")) {
myFirstMessage = state.message
if (binding.unreadMessagesPopup.isShown) {
binding.unreadMessagesPopup.visibility = View.GONE
}
binding.messagesListView.smoothScrollToPosition(0)
}
}
binding.messagesListView.smoothScrollToPosition(0)
}
else -> {}
@ -861,7 +815,6 @@ class ChatActivity :
is ChatViewModel.CreateRoomSuccessState -> {
val bundle = Bundle()
bundle.putString(KEY_ROOM_TOKEN, state.roomOverall.ocs!!.data!!.token)
// bundle.putString(KEY_ROOM_ID, state.roomOverall.ocs!!.data!!.roomId)
leaveRoom {
val chatIntent = Intent(context, ChatActivity::class.java)
@ -937,6 +890,14 @@ class ChatActivity :
.collect()
}
this.lifecycleScope.launch {
chatViewModel.getRemoveMessageFlow
.onEach {
removeMessageById(it.id)
}
.collect()
}
this.lifecycleScope.launch {
chatViewModel.getUpdateMessageFlow
.onEach {
@ -1081,8 +1042,10 @@ class ChatActivity :
is ChatViewModel.OutOfOfficeUIState.Error -> {
Log.e(TAG, "Error fetching/ no user absence data", uiState.exception)
}
ChatViewModel.OutOfOfficeUIState.None -> {
}
is ChatViewModel.OutOfOfficeUIState.Success -> {
binding.outOfOfficeContainer.visibility = View.VISIBLE
@ -1171,9 +1134,25 @@ class ChatActivity :
}
private fun removeUnreadMessagesMarker() {
val index = adapter?.getMessagePositionById(UNREAD_MESSAGES_MARKER_ID.toString())
if (index != null && index != -1) {
adapter?.items?.removeAt(index)
removeMessageById(UNREAD_MESSAGES_MARKER_ID.toString())
}
// do not use adapter.deleteById() as it seems to contain a bug! Use this method instead!
@Suppress("MagicNumber")
private fun removeMessageById(idToDelete: String) {
val indexToDelete = adapter?.getMessagePositionById(idToDelete)
if (indexToDelete != null && indexToDelete != UNREAD_MESSAGES_MARKER_ID) {
// If user sent a message as a first message in todays chat, the temp message will be deleted when
// messages are retrieved from server, but also the date has to be deleted as it will be added again
// when the chat messages are added from server. Otherwise date "Today" would be shown twice.
if (indexToDelete == 0 && (adapter?.items?.get(1))?.item is Date) {
adapter?.items?.removeAt(0)
adapter?.items?.removeAt(0)
adapter?.notifyItemRangeRemoved(indexToDelete, 1)
} else {
adapter?.items?.removeAt(indexToDelete)
adapter?.notifyItemRemoved(indexToDelete)
}
}
}
@ -1190,7 +1169,7 @@ class ChatActivity :
cancelNotificationsForCurrentConversation()
chatViewModel.getRoom(conversationUser!!, roomToken)
chatViewModel.getRoom(roomToken)
actionBar?.show()
@ -1238,18 +1217,18 @@ class ChatActivity :
viewThemeUtils.material.colorToolbarOverflowIcon(binding.chatToolbar)
}
private fun getLastAdapterId(): Int {
var lastId = 0
if (adapter?.items?.size != 0) {
val item = adapter?.items?.get(0)?.item
if (item != null) {
lastId = (item as ChatMessage).jsonMessageId
} else {
lastId = 0
}
}
return lastId
}
// private fun getLastAdapterId(): Int {
// var lastId = 0
// if (adapter?.items?.size != 0) {
// val item = adapter?.items?.get(0)?.item
// if (item != null) {
// lastId = (item as ChatMessage).jsonMessageId
// } else {
// lastId = 0
// }
// }
// return lastId
// }
private fun setupActionBar() {
setSupportActionBar(binding.chatToolbar)
@ -1369,17 +1348,6 @@ class ChatActivity :
R.layout.item_custom_outcoming_preview_message
)
messageHolders.registerContentType(
CONTENT_TYPE_TEMP,
TemporaryMessageViewHolder::class.java,
payload,
R.layout.item_temporary_message,
TemporaryMessageViewHolder::class.java,
payload,
R.layout.item_temporary_message,
this
)
messageHolders.registerContentType(
CONTENT_TYPE_SYSTEM_MESSAGE,
SystemMessageViewHolder::class.java,
@ -1658,7 +1626,7 @@ class ChatActivity :
}
getRoomInfoTimerHandler?.postDelayed(
{
chatViewModel.getRoom(conversationUser!!, roomToken)
chatViewModel.getRoom(roomToken)
},
delayForRecursiveCall
)
@ -2727,7 +2695,6 @@ class ChatActivity :
) {
sessionIdAfterRoomJoined = ApplicationWideCurrentRoomHolder.getInstance().session
// ApplicationWideCurrentRoomHolder.getInstance().currentRoomId = roomId
ApplicationWideCurrentRoomHolder.getInstance().currentRoomToken = roomToken
ApplicationWideCurrentRoomHolder.getInstance().userInRoom = conversationUser
}
@ -2918,7 +2885,6 @@ class ChatActivity :
) {
if (message.item is ChatMessage) {
val chatMessage = message.item as ChatMessage
if (chatMessage.jsonMessageId <= xChatLastCommonRead) {
chatMessage.readStatus = ReadStatus.READ
} else {
@ -2968,7 +2934,19 @@ class ChatActivity :
}
}
private fun isScrolledToBottom() = layoutManager?.findFirstVisibleItemPosition() == 0
private fun isScrolledToBottom(): Boolean {
val position = layoutManager?.findFirstVisibleItemPosition()
if (position == -1) {
Log.w(
TAG,
"FirstVisibleItemPosition was -1 but true is returned for isScrolledToBottom(). This can " +
"happen when the UI is not yet ready"
)
return true
}
return layoutManager?.findFirstVisibleItemPosition() == 0
}
private fun setUnreadMessageMarker(chatMessageList: List<ChatMessage>) {
if (chatMessageList.isNotEmpty()) {
@ -3354,7 +3332,6 @@ class ChatActivity :
currentConversation?.let {
val bundle = Bundle()
bundle.putString(KEY_ROOM_TOKEN, roomToken)
// bundle.putString(KEY_ROOM_ID, roomId)
bundle.putString(BundleKeys.KEY_CONVERSATION_PASSWORD, roomPassword)
bundle.putString(BundleKeys.KEY_MODIFIED_BASE_URL, conversationUser?.baseUrl!!)
bundle.putString(KEY_CONVERSATION_NAME, it.displayName)
@ -3423,9 +3400,14 @@ class ChatActivity :
private fun openMessageActionsDialog(iMessage: IMessage?) {
val message = iMessage as ChatMessage
if (hasVisibleItems(message) &&
!isSystemMessage(message) &&
message.id != "-3"
if (message.isTemporary) {
TempMessageActionsDialog(
this,
message
).show()
} else if (hasVisibleItems(message) &&
!isSystemMessage(message)
) {
MessageActionsDialog(
this,
@ -3849,7 +3831,6 @@ class ChatActivity :
CONTENT_TYPE_SYSTEM_MESSAGE -> !TextUtils.isEmpty(message.systemMessage)
CONTENT_TYPE_UNREAD_NOTICE_MESSAGE -> message.id == UNREAD_MESSAGES_MARKER_ID.toString()
CONTENT_TYPE_CALL_STARTED -> message.id == "-2"
CONTENT_TYPE_TEMP -> message.id == "-3"
CONTENT_TYPE_DECK_CARD -> message.isDeckCard()
else -> false
@ -3996,30 +3977,6 @@ class ChatActivity :
startACall(false, false)
}
override fun editTemporaryMessage(id: Int, newMessage: String) {
messageInputViewModel.editQueuedMessage(currentConversation!!.internalId, id, newMessage)
adapter?.notifyDataSetChanged() // TODO optimize this
}
override fun deleteTemporaryMessage(id: Int) {
messageInputViewModel.removeFromQueue(currentConversation!!.internalId, id)
var i = 0
val max = messageInputViewModel.messageQueueSizeFlow.value?.plus(1)
for (item in adapter?.items!!) {
if (i > max!! && max < 1) break
if (item.item is ChatMessage &&
(item.item as ChatMessage).isTempMessage &&
(item.item as ChatMessage).tempMessageId == id
) {
val index = adapter?.items!!.indexOf(item)
adapter?.items!!.removeAt(index)
adapter?.notifyItemRemoved(index)
break
}
i++
}
}
private fun logConversationInfos(methodName: String) {
Log.d(TAG, " |-----------------------------------------------")
Log.d(TAG, " | method: $methodName")
@ -4068,9 +4025,7 @@ class ChatActivity :
private const val CONTENT_TYPE_POLL: Byte = 6
private const val CONTENT_TYPE_LINK_PREVIEW: Byte = 7
private const val CONTENT_TYPE_DECK_CARD: Byte = 8
private const val CONTENT_TYPE_TEMP: Byte = 9
private const val UNREAD_MESSAGES_MARKER_ID = -1
private const val CALL_STARTED_ID = -2
private const val GET_ROOM_INFO_DELAY_NORMAL: Long = 30000
private const val GET_ROOM_INFO_DELAY_LOBBY: Long = 5000
private const val AGE_THRESHOLD_FOR_DELETE_MESSAGE: Int = 21600000 // (6 hours in millis = 6 * 3600 * 1000)
@ -4108,8 +4063,6 @@ class ChatActivity :
private const val RESUME_AUDIO_TAG = "RESUME_AUDIO_TAG"
private const val DELAY_TO_SHOW_PROGRESS_BAR = 1000L
private const val FIVE_MINUTES_IN_SECONDS: Long = 300
private const val TEMPORARY_MESSAGE_ID_INT: Int = -3
private const val TEMPORARY_MESSAGE_ID_STRING: String = "-3"
private const val ROOM_TYPE_ONE_TO_ONE = "1"
private const val ACTOR_TYPE = "users"
const val CONVERSATION_INTERNAL_ID = "CONVERSATION_INTERNAL_ID"

View File

@ -158,7 +158,6 @@ class MessageInputFragment : Fragment() {
override fun onResume() {
super.onResume()
chatActivity.messageInputViewModel.restoreMessageQueue(conversationInternalId)
}
override fun onDestroyView() {
@ -199,19 +198,20 @@ class MessageInputFragment : Fragment() {
wasOnline = !binding.fragmentConnectionLost.isShown
val connectionGained = (!wasOnline && isOnline)
Log.d(TAG, "isOnline: $isOnline\nwasOnline: $wasOnline\nconnectionGained: $connectionGained")
handleMessageQueue(isOnline)
if (connectionGained) {
chatActivity.messageInputViewModel.sendTempMessages(
chatActivity.conversationUser!!.getCredentials(),
ApiUtils.getUrlForChat(
chatActivity.chatApiVersion,
chatActivity.conversationUser!!.baseUrl!!,
chatActivity.roomToken
)
)
}
handleUI(isOnline, connectionGained)
}.collect()
}
chatActivity.messageInputViewModel.messageQueueSizeFlow.observe(viewLifecycleOwner) { size ->
if (size > 0) {
binding.fragmentConnectionLost.text = getString(R.string.connection_lost_queued, size)
} else {
binding.fragmentConnectionLost.text = getString(R.string.connection_lost_sent_messages_are_queued)
}
}
chatActivity.messageInputViewModel.callStartedFlow.observe(viewLifecycleOwner) {
val (message, show) = it
if (show) {
@ -292,23 +292,6 @@ class MessageInputFragment : Fragment() {
}
}
private fun handleMessageQueue(isOnline: Boolean) {
if (isOnline) {
chatActivity.messageInputViewModel.switchToMessageQueue(false)
chatActivity.messageInputViewModel.sendAndEmptyMessageQueue(
conversationInternalId,
chatActivity.conversationUser!!.getCredentials(),
ApiUtils.getUrlForChat(
chatActivity.chatApiVersion,
chatActivity.conversationUser!!.baseUrl!!,
chatActivity.roomToken
)
)
} else {
chatActivity.messageInputViewModel.switchToMessageQueue(true)
}
}
private fun restoreState() {
if (binding.fragmentMessageInputView.inputEditText.text.isEmpty()) {
requireContext().getSharedPreferences(chatActivity.localClassName, AppCompatActivity.MODE_PRIVATE).apply {
@ -868,7 +851,7 @@ class MessageInputFragment : Fragment() {
.findViewById<RelativeLayout>(R.id.quotedChatMessageView)?.tag as Int? ?: 0
sendMessage(
editable,
editable.toString(),
replyMessageId,
sendWithoutNotification
)
@ -876,9 +859,8 @@ class MessageInputFragment : Fragment() {
}
}
private fun sendMessage(message: CharSequence, replyTo: Int?, sendWithoutNotification: Boolean) {
private fun sendMessage(message: String, replyTo: Int?, sendWithoutNotification: Boolean) {
chatActivity.messageInputViewModel.sendChatMessage(
conversationInternalId,
chatActivity.conversationUser!!.getCredentials(),
ApiUtils.getUrlForChat(
chatActivity.chatApiVersion,
@ -917,16 +899,23 @@ class MessageInputFragment : Fragment() {
// FIXME Fix API checking with guests?
val apiVersion: Int = ApiUtils.getChatApiVersion(chatActivity.spreedCapabilities, intArrayOf(1))
chatActivity.messageInputViewModel.editChatMessage(
chatActivity.credentials!!,
ApiUtils.getUrlForChatMessage(
apiVersion,
chatActivity.conversationUser!!.baseUrl!!,
chatActivity.roomToken,
message.id
),
editedMessageText
)
if (message.isTemporary) {
chatActivity.messageInputViewModel.editTempChatMessage(
message,
editedMessageText
)
} else {
chatActivity.messageInputViewModel.editChatMessage(
chatActivity.credentials!!,
ApiUtils.getUrlForChatMessage(
apiVersion,
chatActivity.conversationUser!!.baseUrl!!,
chatActivity.roomToken,
message.id
),
editedMessageText
)
}
}
private fun setEditUI(message: ChatMessage) {

View File

@ -11,6 +11,7 @@ import android.os.Bundle
import com.nextcloud.talk.chat.data.io.LifecycleAwareManager
import com.nextcloud.talk.chat.data.model.ChatMessage
import com.nextcloud.talk.models.domain.ConversationModel
import com.nextcloud.talk.models.json.chat.ChatOverallSingleMessage
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.Flow
@ -41,6 +42,8 @@ interface ChatMessageRepository : LifecycleAwareManager {
*/
val generalUIFlow: Flow<String>
val removeMessageFlow: Flow<ChatMessage>
fun setData(conversationModel: ConversationModel, credentials: String, urlForChatting: String)
fun loadInitialMessages(withNetworkParams: Bundle): Job
@ -75,4 +78,42 @@ interface ChatMessageRepository : LifecycleAwareManager {
* Destroys unused resources.
*/
fun handleChatOnBackPress()
@Suppress("LongParameterList")
suspend fun sendChatMessage(
credentials: String,
url: String,
message: String,
displayName: String,
replyTo: Int,
sendWithoutNotification: Boolean,
referenceId: String
): Flow<Result<ChatMessage?>>
@Suppress("LongParameterList")
suspend fun resendChatMessage(
credentials: String,
url: String,
message: String,
displayName: String,
replyTo: Int,
sendWithoutNotification: Boolean,
referenceId: String
): Flow<Result<ChatMessage?>>
suspend fun addTemporaryMessage(
message: CharSequence,
displayName: String,
replyTo: Int,
sendWithoutNotification: Boolean,
referenceId: String
): Flow<Result<ChatMessage?>>
suspend fun editChatMessage(credentials: String, url: String, text: String): Flow<Result<ChatOverallSingleMessage>>
suspend fun editTempChatMessage(message: ChatMessage, editedMessageText: String): Flow<Boolean>
suspend fun sendTempChatMessages(credentials: String, url: String)
suspend fun deleteTempMessage(chatMessage: ChatMessage)
}

View File

@ -115,11 +115,16 @@ data class ChatMessage(
var openWhenDownloaded: Boolean = true,
var isTempMessage: Boolean = false,
var isTemporary: Boolean = false,
var tempMessageId: Int = -1
var referenceId: String? = null,
) : MessageContentType, MessageContentType.Image {
var sendingFailed: Boolean = true,
var silent: Boolean = false
) : MessageContentType,
MessageContentType.Image {
var extractedUrlToPreview: String? = null
@ -240,8 +245,8 @@ data class ChatMessage(
}
}
fun getCalculateMessageType(): MessageType {
return if (!TextUtils.isEmpty(systemMessage)) {
fun getCalculateMessageType(): MessageType =
if (!TextUtils.isEmpty(systemMessage)) {
MessageType.SYSTEM_MESSAGE
} else if (isVoiceMessage) {
MessageType.VOICE_MESSAGE
@ -256,19 +261,15 @@ data class ChatMessage(
} else {
MessageType.REGULAR_TEXT_MESSAGE
}
}
override fun getId(): String {
return jsonMessageId.toString()
}
override fun getId(): String = jsonMessageId.toString()
override fun getText(): String {
return if (message != null) {
override fun getText(): String =
if (message != null) {
getParsedMessage(message, messageParameters)!!
} else {
""
}
}
fun getNullsafeActorDisplayName() =
if (!TextUtils.isEmpty(actorDisplayName)) {
@ -277,22 +278,19 @@ data class ChatMessage(
sharedApplication!!.getString(R.string.nc_guest)
}
override fun getUser(): IUser {
return object : IUser {
override fun getId(): String {
return "$actorType/$actorId"
}
override fun getUser(): IUser =
object : IUser {
override fun getId(): String = "$actorType/$actorId"
override fun getName(): String {
return if (!TextUtils.isEmpty(actorDisplayName)) {
override fun getName(): String =
if (!TextUtils.isEmpty(actorDisplayName)) {
actorDisplayName!!
} else {
sharedApplication!!.getString(R.string.nc_guest)
}
}
override fun getAvatar(): String? {
return when {
override fun getAvatar(): String? =
when {
activeUser == null -> {
null
}
@ -317,21 +315,14 @@ data class ChatMessage(
ApiUtils.getUrlForGuestAvatar(activeUser!!.baseUrl!!, apiId, true)
}
}
}
}
}
override fun getCreatedAt(): Date {
return Date(timestamp * MILLIES)
}
override fun getCreatedAt(): Date = Date(timestamp * MILLIES)
override fun getSystemMessage(): String {
return EnumSystemMessageTypeConverter().convertToString(systemMessageType)
}
override fun getSystemMessage(): String = EnumSystemMessageTypeConverter().convertToString(systemMessageType)
private fun isHashMapEntryEqualTo(map: HashMap<String?, String?>, key: String, searchTerm: String): Boolean {
return map != null && MessageDigest.isEqual(map[key]!!.toByteArray(), searchTerm.toByteArray())
}
private fun isHashMapEntryEqualTo(map: HashMap<String?, String?>, key: String, searchTerm: String): Boolean =
map != null && MessageDigest.isEqual(map[key]!!.toByteArray(), searchTerm.toByteArray())
// needed a equals and hashcode function to fix detekt errors
override fun equals(other: Any?): Boolean {
@ -340,9 +331,7 @@ data class ChatMessage(
return false
}
override fun hashCode(): Int {
return 0
}
override fun hashCode(): Int = 0
val isVoiceMessage: Boolean
get() = "voice-message" == messageType

View File

@ -38,7 +38,7 @@ interface ChatNetworkDataSource {
url: String,
message: String,
displayName: String
): Observable<GenericOverall> // last two fields are false
): Observable<ChatOverallSingleMessage>
fun checkForNoteToSelf(credentials: String, url: String, includeStatus: Boolean): Observable<RoomsOverall>
fun shareLocationToNotes(
@ -50,19 +50,20 @@ interface ChatNetworkDataSource {
): Observable<GenericOverall>
fun leaveRoom(credentials: String, url: String): Observable<GenericOverall>
fun sendChatMessage(
suspend fun sendChatMessage(
credentials: String,
url: String,
message: CharSequence,
message: String,
displayName: String,
replyTo: Int,
sendWithoutNotification: Boolean
): Observable<GenericOverall>
sendWithoutNotification: Boolean,
referenceId: String
): ChatOverallSingleMessage
fun pullChatMessages(credentials: String, url: String, fieldMap: HashMap<String, Int>): Observable<Response<*>>
fun deleteChatMessage(credentials: String, url: String): Observable<ChatOverallSingleMessage>
fun createRoom(credentials: String, url: String, map: Map<String, String>): Observable<RoomOverall>
fun setChatReadMarker(credentials: String, url: String, previousMessageId: Int): Observable<GenericOverall>
fun editChatMessage(credentials: String, url: String, text: String): Observable<ChatOverallSingleMessage>
suspend fun editChatMessage(credentials: String, url: String, text: String): ChatOverallSingleMessage
suspend fun getOutOfOfficeStatusForUser(credentials: String, baseUrl: String, userId: String): UserAbsenceOverall
}

View File

@ -21,12 +21,16 @@ import com.nextcloud.talk.data.database.model.ChatBlockEntity
import com.nextcloud.talk.data.database.model.ChatMessageEntity
import com.nextcloud.talk.data.network.NetworkMonitor
import com.nextcloud.talk.data.user.model.User
import com.nextcloud.talk.extensions.toIntOrZero
import com.nextcloud.talk.models.domain.ConversationModel
import com.nextcloud.talk.models.json.chat.ChatMessageJson
import com.nextcloud.talk.models.json.chat.ChatOverall
import com.nextcloud.talk.models.json.chat.ChatOverallSingleMessage
import com.nextcloud.talk.models.json.converters.EnumActorTypeConverter
import com.nextcloud.talk.models.json.participants.Participant
import com.nextcloud.talk.utils.bundle.BundleKeys
import com.nextcloud.talk.utils.database.user.CurrentUserProviderNew
import com.nextcloud.talk.utils.preferences.AppPreferences
import com.nextcloud.talk.utils.message.SendMessageUtils
import io.reactivex.android.schedulers.AndroidSchedulers
import io.reactivex.schedulers.Schedulers
import kotlinx.coroutines.CoroutineScope
@ -36,18 +40,22 @@ import kotlinx.coroutines.cancel
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.retryWhen
import kotlinx.coroutines.launch
import java.io.IOException
import javax.inject.Inject
@Suppress("LargeClass", "TooManyFunctions")
class OfflineFirstChatRepository @Inject constructor(
private val chatDao: ChatMessagesDao,
private val chatBlocksDao: ChatBlocksDao,
private val network: ChatNetworkDataSource,
private val datastore: AppPreferences,
private val monitor: NetworkMonitor,
private val userProvider: CurrentUserProviderNew
userProvider: CurrentUserProviderNew
) : ChatMessageRepository {
val currentUser: User = userProvider.currentUser.blockingGet()
@ -71,8 +79,7 @@ class OfflineFirstChatRepository @Inject constructor(
>
> = MutableSharedFlow()
override val updateMessageFlow:
Flow<ChatMessage>
override val updateMessageFlow: Flow<ChatMessage>
get() = _updateMessageFlow
private val _updateMessageFlow:
@ -85,8 +92,7 @@ class OfflineFirstChatRepository @Inject constructor(
private val _lastCommonReadFlow:
MutableSharedFlow<Int> = MutableSharedFlow()
override val lastReadMessageFlow:
Flow<Int>
override val lastReadMessageFlow: Flow<Int>
get() = _lastReadMessageFlow
private val _lastReadMessageFlow:
@ -97,6 +103,12 @@ class OfflineFirstChatRepository @Inject constructor(
private val _generalUIFlow: MutableSharedFlow<String> = MutableSharedFlow()
override val removeMessageFlow: Flow<ChatMessage>
get() = _removeMessageFlow
private val _removeMessageFlow:
MutableSharedFlow<ChatMessage> = MutableSharedFlow()
private var newXChatLastCommonRead: Int? = null
private var itIsPaused = false
private val scope = CoroutineScope(Dispatchers.IO)
@ -169,26 +181,39 @@ class OfflineFirstChatRepository @Inject constructor(
Log.d(TAG, "newestMessageIdFromDb after sync: $newestMessageIdFromDb")
}
if (newestMessageIdFromDb.toInt() != 0) {
val limit = getCappedMessagesAmountOfChatBlock(newestMessageIdFromDb)
showMessagesBeforeAndEqual(
internalConversationId,
newestMessageIdFromDb,
limit
)
// delay is a dirty workaround to make sure messages are added to adapter on initial load before dealing
// with them (otherwise there is a race condition).
delay(DELAY_TO_ENSURE_MESSAGES_ARE_ADDED)
updateUiForLastCommonRead()
updateUiForLastReadMessage(newestMessageIdFromDb)
}
handleMessagesFromDb(newestMessageIdFromDb)
initMessagePolling(newestMessageIdFromDb)
}
private suspend fun handleMessagesFromDb(newestMessageIdFromDb: Long) {
if (newestMessageIdFromDb.toInt() != 0) {
val limit = getCappedMessagesAmountOfChatBlock(newestMessageIdFromDb)
val list = getMessagesBeforeAndEqual(
newestMessageIdFromDb,
internalConversationId,
limit
)
if (list.isNotEmpty()) {
handleNewAndTempMessages(
receivedChatMessages = list,
lookIntoFuture = false,
showUnreadMessagesMarker = false
)
}
sendTempChatMessages(credentials, urlForChatting)
// delay is a dirty workaround to make sure messages are added to adapter on initial load before dealing
// with them (otherwise there is a race condition).
delay(DELAY_TO_ENSURE_MESSAGES_ARE_ADDED)
updateUiForLastCommonRead()
updateUiForLastReadMessage(newestMessageIdFromDb)
}
}
private suspend fun getCappedMessagesAmountOfChatBlock(messageId: Long): Int {
val chatBlock = getBlockOfMessage(messageId.toInt())
@ -293,8 +318,11 @@ class OfflineFirstChatRepository @Inject constructor(
val weHaveMessagesFromOurself = chatMessages.any { it.actorId == currentUser.userId }
showUnreadMessagesMarker = showUnreadMessagesMarker && !weHaveMessagesFromOurself
val triple = Triple(true, showUnreadMessagesMarker, chatMessages)
_messageFlow.emit(triple)
handleNewAndTempMessages(
receivedChatMessages = chatMessages,
lookIntoFuture = true,
showUnreadMessagesMarker = showUnreadMessagesMarker
)
} else {
Log.d(TAG, "resultsFromSync are null or empty")
}
@ -317,6 +345,39 @@ class OfflineFirstChatRepository @Inject constructor(
}
}
private suspend fun handleNewAndTempMessages(
receivedChatMessages: List<ChatMessage>,
lookIntoFuture: Boolean,
showUnreadMessagesMarker: Boolean
) {
// remove all temp messages from UI
val oldTempMessages = chatDao.getTempMessagesForConversation(internalConversationId)
.first()
.map(ChatMessageEntity::asModel)
oldTempMessages.forEach { _removeMessageFlow.emit(it) }
// add new messages to UI
val tripleChatMessages = Triple(lookIntoFuture, showUnreadMessagesMarker, receivedChatMessages)
_messageFlow.emit(tripleChatMessages)
// remove temp messages from DB that are now found in the new messages
val chatMessagesReferenceIds = receivedChatMessages.mapTo(HashSet(receivedChatMessages.size)) { it.referenceId }
val tempChatMessagesThatCanBeReplaced = oldTempMessages.filter { it.referenceId in chatMessagesReferenceIds }
chatDao.deleteTempChatMessages(
internalConversationId,
tempChatMessagesThatCanBeReplaced.map { it.referenceId!! }
)
// add the remaining temp messages to UI again
val remainingTempMessages = chatDao.getTempMessagesForConversation(internalConversationId)
.first()
.sortedBy { it.internalId }
.map(ChatMessageEntity::asModel)
val triple = Triple(true, false, remainingTempMessages)
_messageFlow.emit(triple)
}
private suspend fun hasToLoadPreviousMessagesFromServer(beforeMessageId: Long): Boolean {
val loadFromServer: Boolean
@ -684,31 +745,18 @@ class OfflineFirstChatRepository @Inject constructor(
}
}
private suspend fun showMessagesBeforeAndEqual(internalConversationId: String, messageId: Long, limit: Int) {
suspend fun getMessagesBeforeAndEqual(
messageId: Long,
internalConversationId: String,
messageLimit: Int
): List<ChatMessage> =
chatDao.getMessagesForConversationBeforeAndEqual(
internalConversationId,
messageId,
messageLimit
).map {
it.map(ChatMessageEntity::asModel)
}.first()
val list = getMessagesBeforeAndEqual(
messageId,
suspend fun getMessagesBeforeAndEqual(
messageId: Long,
internalConversationId: String,
messageLimit: Int
): List<ChatMessage> =
chatDao.getMessagesForConversationBeforeAndEqual(
internalConversationId,
limit
)
if (list.isNotEmpty()) {
val triple = Triple(false, false, list)
_messageFlow.emit(triple)
}
}
messageId,
messageLimit
).map {
it.map(ChatMessageEntity::asModel)
}.first()
private suspend fun showMessagesBefore(internalConversationId: String, messageId: Long, limit: Int) {
suspend fun getMessagesBefore(
@ -752,6 +800,227 @@ class OfflineFirstChatRepository @Inject constructor(
scope.cancel()
}
@Suppress("LongParameterList")
override suspend fun sendChatMessage(
credentials: String,
url: String,
message: String,
displayName: String,
replyTo: Int,
sendWithoutNotification: Boolean,
referenceId: String
): Flow<Result<ChatMessage?>> {
if (!monitor.isOnline.first()) {
return flow {
emit(Result.failure(IOException("Skipped to send message as device is offline")))
}
}
return flow {
val response = network.sendChatMessage(
credentials,
url,
message,
displayName,
replyTo,
sendWithoutNotification,
referenceId
)
val chatMessageModel = response.ocs?.data?.asModel()
emit(Result.success(chatMessageModel))
}
.retryWhen { cause, attempt ->
if (cause is IOException && attempt < SEND_MESSAGE_RETRY_ATTEMPTS) {
delay(SEND_MESSAGE_RETRY_DELAY)
return@retryWhen true
} else {
return@retryWhen false
}
}
.catch { e ->
Log.e(TAG, "Error when sending message", e)
val failedMessage = chatDao.getTempMessageForConversation(internalConversationId, referenceId).first()
failedMessage.sendingFailed = true
chatDao.updateChatMessage(failedMessage)
val failedMessageModel = failedMessage.asModel()
_updateMessageFlow.emit(failedMessageModel)
emit(Result.failure(e))
}
}
@Suppress("LongParameterList")
override suspend fun resendChatMessage(
credentials: String,
url: String,
message: String,
displayName: String,
replyTo: Int,
sendWithoutNotification: Boolean,
referenceId: String
): Flow<Result<ChatMessage?>> {
val messageToResend = chatDao.getTempMessageForConversation(internalConversationId, referenceId).first()
messageToResend.sendingFailed = false
chatDao.updateChatMessage(messageToResend)
val messageToResendModel = messageToResend.asModel()
_updateMessageFlow.emit(messageToResendModel)
return sendChatMessage(
credentials,
url,
message,
displayName,
replyTo,
sendWithoutNotification,
referenceId
)
}
@Suppress("Detekt.TooGenericExceptionCaught")
override suspend fun editChatMessage(
credentials: String,
url: String,
text: String
): Flow<Result<ChatOverallSingleMessage>> =
flow {
try {
val response = network.editChatMessage(
credentials,
url,
text
)
emit(Result.success(response))
} catch (e: Exception) {
emit(Result.failure(e))
}
}
@Suppress("Detekt.TooGenericExceptionCaught")
override suspend fun editTempChatMessage(message: ChatMessage, editedMessageText: String): Flow<Boolean> =
flow {
try {
val messageToEdit = chatDao.getChatMessageForConversation(
internalConversationId,
message.jsonMessageId
.toLong()
).first()
messageToEdit.message = editedMessageText
chatDao.upsertChatMessage(messageToEdit)
val editedMessageModel = messageToEdit.asModel()
_updateMessageFlow.emit(editedMessageModel)
emit(true)
} catch (e: Exception) {
emit(false)
}
}
override suspend fun sendTempChatMessages(credentials: String, url: String) {
val tempMessages = chatDao.getTempMessagesForConversation(internalConversationId).first()
tempMessages.sortedBy { it.internalId }.onEach {
sendChatMessage(
credentials,
url,
it.message,
it.actorDisplayName,
it.parentMessageId?.toIntOrZero() ?: 0,
it.silent,
it.referenceId.orEmpty()
).collect { result ->
if (result.isSuccess) {
Log.d(TAG, "Sent temp message")
} else {
Log.e(TAG, "Failed to send temp message")
}
}
}
}
override suspend fun deleteTempMessage(chatMessage: ChatMessage) {
chatDao.deleteTempChatMessages(internalConversationId, listOf(chatMessage.referenceId.orEmpty()))
_removeMessageFlow.emit(chatMessage)
}
@Suppress("Detekt.TooGenericExceptionCaught")
override suspend fun addTemporaryMessage(
message: CharSequence,
displayName: String,
replyTo: Int,
sendWithoutNotification: Boolean,
referenceId: String
): Flow<Result<ChatMessage?>> =
flow {
try {
val tempChatMessageEntity = createChatMessageEntity(
internalConversationId,
message.toString(),
replyTo,
sendWithoutNotification,
referenceId
)
chatDao.upsertChatMessage(tempChatMessageEntity)
val tempChatMessageModel = tempChatMessageEntity.asModel()
emit(Result.success(tempChatMessageModel))
val triple = Triple(true, false, listOf(tempChatMessageModel))
_messageFlow.emit(triple)
} catch (e: Exception) {
Log.e(TAG, "Something went wrong when adding temporary message", e)
emit(Result.failure(e))
}
}
private fun createChatMessageEntity(
internalConversationId: String,
message: String,
replyTo: Int,
sendWithoutNotification: Boolean,
referenceId: String
): ChatMessageEntity {
val currentTimeMillies = System.currentTimeMillis()
val currentTimeWithoutYear = SendMessageUtils().removeYearFromTimestamp(currentTimeMillies)
val parentMessageId = if (replyTo != 0) {
replyTo.toLong()
} else {
null
}
val entity = ChatMessageEntity(
internalId = "$internalConversationId@_temp_$currentTimeMillies",
internalConversationId = internalConversationId,
id = currentTimeWithoutYear.toLong(),
message = message,
deleted = false,
token = conversationModel.token,
actorId = currentUser.userId!!,
actorType = EnumActorTypeConverter().convertToString(Participant.ActorType.USERS),
accountId = currentUser.id!!,
messageParameters = null,
messageType = "comment",
parentMessageId = parentMessageId,
systemMessageType = ChatMessage.SystemMessageType.DUMMY,
replyable = false,
timestamp = currentTimeMillies / MILLIES,
expirationTimestamp = 0,
actorDisplayName = currentUser.displayName!!,
referenceId = referenceId,
isTemporary = true,
sendingFailed = false,
silent = sendWithoutNotification
)
return entity
}
companion object {
val TAG = OfflineFirstChatRepository::class.simpleName
private const val HTTP_CODE_OK: Int = 200
@ -760,5 +1029,8 @@ class OfflineFirstChatRepository @Inject constructor(
private const val HALF_SECOND = 500L
private const val DELAY_TO_ENSURE_MESSAGES_ARE_ADDED: Long = 100
private const val DEFAULT_MESSAGES_LIMIT = 100
private const val MILLIES = 1000
private const val SEND_MESSAGE_RETRY_ATTEMPTS = 3
private const val SEND_MESSAGE_RETRY_DELAY: Long = 2000
}
}

View File

@ -18,6 +18,7 @@ import com.nextcloud.talk.models.json.generic.GenericOverall
import com.nextcloud.talk.models.json.reminder.Reminder
import com.nextcloud.talk.models.json.userAbsence.UserAbsenceOverall
import com.nextcloud.talk.utils.ApiUtils
import com.nextcloud.talk.utils.message.SendMessageUtils
import io.reactivex.Observable
import retrofit2.Response
@ -108,26 +109,24 @@ class RetrofitChatNetwork(
url: String,
message: String,
displayName: String
): Observable<GenericOverall> {
return ncApi.sendChatMessage(
): Observable<ChatOverallSingleMessage> =
ncApi.sendChatMessage(
credentials,
url,
message,
displayName,
null,
false
false,
SendMessageUtils().generateReferenceId()
).map {
it
}
}
override fun checkForNoteToSelf(
credentials: String,
url: String,
includeStatus: Boolean
): Observable<RoomsOverall> {
return ncApi.getRooms(credentials, url, includeStatus).map { it }
}
): Observable<RoomsOverall> = ncApi.getRooms(credentials, url, includeStatus).map { it }
override fun shareLocationToNotes(
credentials: String,
@ -135,54 +134,56 @@ class RetrofitChatNetwork(
objectType: String,
objectId: String,
metadata: String
): Observable<GenericOverall> {
return ncApi.sendLocation(credentials, url, objectType, objectId, metadata).map { it }
}
): Observable<GenericOverall> = ncApi.sendLocation(credentials, url, objectType, objectId, metadata).map { it }
override fun leaveRoom(credentials: String, url: String): Observable<GenericOverall> {
return ncApi.leaveRoom(credentials, url).map { it }
}
override fun sendChatMessage(
credentials: String,
url: String,
message: CharSequence,
displayName: String,
replyTo: Int,
sendWithoutNotification: Boolean
): Observable<GenericOverall> {
return ncApi.sendChatMessage(credentials, url, message, displayName, replyTo, sendWithoutNotification).map {
override fun leaveRoom(credentials: String, url: String): Observable<GenericOverall> =
ncApi.leaveRoom(credentials, url).map {
it
}
}
override suspend fun sendChatMessage(
credentials: String,
url: String,
message: String,
displayName: String,
replyTo: Int,
sendWithoutNotification: Boolean,
referenceId: String
): ChatOverallSingleMessage =
ncApiCoroutines.sendChatMessage(
credentials,
url,
message,
displayName,
replyTo,
sendWithoutNotification,
referenceId
)
override fun pullChatMessages(
credentials: String,
url: String,
fieldMap: HashMap<String, Int>
): Observable<Response<*>> {
return ncApi.pullChatMessages(credentials, url, fieldMap).map { it }
}
): Observable<Response<*>> = ncApi.pullChatMessages(credentials, url, fieldMap).map { it }
override fun deleteChatMessage(credentials: String, url: String): Observable<ChatOverallSingleMessage> {
return ncApi.deleteChatMessage(credentials, url).map { it }
}
override fun deleteChatMessage(credentials: String, url: String): Observable<ChatOverallSingleMessage> =
ncApi.deleteChatMessage(credentials, url).map {
it
}
override fun createRoom(credentials: String, url: String, map: Map<String, String>): Observable<RoomOverall> {
return ncApi.createRoom(credentials, url, map).map { it }
}
override fun createRoom(credentials: String, url: String, map: Map<String, String>): Observable<RoomOverall> =
ncApi.createRoom(credentials, url, map).map {
it
}
override fun setChatReadMarker(
credentials: String,
url: String,
previousMessageId: Int
): Observable<GenericOverall> {
return ncApi.setChatReadMarker(credentials, url, previousMessageId).map { it }
}
): Observable<GenericOverall> = ncApi.setChatReadMarker(credentials, url, previousMessageId).map { it }
override fun editChatMessage(credentials: String, url: String, text: String): Observable<ChatOverallSingleMessage> {
return ncApi.editChatMessage(credentials, url, text).map { it }
}
override suspend fun editChatMessage(credentials: String, url: String, text: String): ChatOverallSingleMessage =
ncApiCoroutines.editChatMessage(credentials, url, text)
override suspend fun getOutOfOfficeStatusForUser(
credentials: String,

View File

@ -24,6 +24,7 @@ import com.nextcloud.talk.chat.data.model.ChatMessage
import com.nextcloud.talk.chat.data.network.ChatNetworkDataSource
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.domain.ConversationModel
import com.nextcloud.talk.models.domain.ReactionAddedModel
@ -63,7 +64,8 @@ class ChatViewModel @Inject constructor(
private val mediaRecorderManager: MediaRecorderManager,
private val audioFocusRequestManager: AudioFocusRequestManager,
private val userProvider: CurrentUserProviderNew
) : ViewModel(), DefaultLifecycleObserver {
) : ViewModel(),
DefaultLifecycleObserver {
enum class LifeCycleFlag {
PAUSED,
@ -74,6 +76,10 @@ class ChatViewModel @Inject constructor(
lateinit var currentLifeCycleFlag: LifeCycleFlag
val disposableSet = mutableSetOf<Disposable>()
fun getChatRepository(): ChatMessageRepository {
return chatRepository
}
override fun onResume(owner: LifecycleOwner) {
super.onResume(owner)
currentLifeCycleFlag = LifeCycleFlag.RESUMED
@ -131,6 +137,8 @@ class ChatViewModel @Inject constructor(
_chatMessageViewState.value = ChatMessageErrorState
}
val getRemoveMessageFlow = chatRepository.removeMessageFlow
val getUpdateMessageFlow = chatRepository.updateMessageFlow
val getLastCommonReadFlow = chatRepository.lastCommonReadFlow
@ -239,14 +247,9 @@ class ChatViewModel @Inject constructor(
chatRepository.setData(conversationModel, credentials, urlForChatting)
}
fun getRoom(user: User, token: String) {
fun getRoom(token: String) {
_getRoomViewState.value = GetRoomStartState
conversationRepository.getRoom(token)
// chatNetworkDataSource.getRoom(user, token)
// .subscribeOn(Schedulers.io())
// ?.observeOn(AndroidSchedulers.mainThread())
// ?.subscribe(GetRoomObserver())
}
fun getCapabilities(user: User, token: String, conversationModel: ConversationModel) {
@ -478,12 +481,12 @@ class ChatViewModel @Inject constructor(
chatNetworkDataSource.shareToNotes(credentials, url, message, displayName)
.subscribeOn(Schedulers.io())
?.observeOn(AndroidSchedulers.mainThread())
?.subscribe(object : Observer<GenericOverall> {
?.subscribe(object : Observer<ChatOverallSingleMessage> {
override fun onSubscribe(d: Disposable) {
disposableSet.add(d)
}
override fun onNext(genericOverall: GenericOverall) {
override fun onNext(genericOverall: ChatOverallSingleMessage) {
// unused atm
}
@ -609,9 +612,7 @@ class ChatViewModel @Inject constructor(
cachedFile.delete()
}
fun getCurrentVoiceRecordFile(): String {
return mediaRecorderManager.currentVoiceRecordFile
}
fun getCurrentVoiceRecordFile(): String = mediaRecorderManager.currentVoiceRecordFile
fun uploadFile(fileUri: String, room: String, displayName: String, metaData: String) {
try {
@ -650,7 +651,7 @@ class ChatViewModel @Inject constructor(
chatRepository.handleChatOnBackPress()
}
suspend fun getMessageById(url: String, conversationModel: ConversationModel, messageId: Long): Flow<ChatMessage> =
fun getMessageById(url: String, conversationModel: ConversationModel, messageId: Long): Flow<ChatMessage> =
flow {
val bundle = Bundle()
bundle.putString(BundleKeys.KEY_CHAT_URL, url)
@ -671,25 +672,6 @@ class ChatViewModel @Inject constructor(
fun getPlaybackSpeedPreference(message: ChatMessage) =
_voiceMessagePlaybackSpeedPreferences.value?.get(message.user.id) ?: PlaybackSpeed.NORMAL
// inner class GetRoomObserver : Observer<ConversationModel> {
// override fun onSubscribe(d: Disposable) {
// // unused atm
// }
//
// override fun onNext(conversationModel: ConversationModel) {
// _getRoomViewState.value = GetRoomSuccessState(conversationModel)
// }
//
// override fun onError(e: Throwable) {
// Log.e(TAG, "Error when fetching room")
// _getRoomViewState.value = GetRoomErrorState
// }
//
// override fun onComplete() {
// // unused atm
// }
// }
inner class JoinRoomObserver : Observer<ConversationModel> {
override fun onSubscribe(d: Disposable) {
disposableSet.add(d)
@ -788,6 +770,32 @@ class ChatViewModel @Inject constructor(
}
}
fun deleteTempMessage(chatMessage: ChatMessage) {
viewModelScope.launch {
chatRepository.deleteTempMessage(chatMessage)
}
}
fun resendMessage(credentials: String, urlForChat: String, message: ChatMessage) {
viewModelScope.launch {
chatRepository.resendChatMessage(
credentials,
urlForChat,
message.message.orEmpty(),
message.actorDisplayName.orEmpty(),
message.parentMessageId?.toIntOrZero() ?: 0,
false,
message.referenceId.orEmpty()
).collect { result ->
if (result.isSuccess) {
Log.d(TAG, "resend successful")
} else {
Log.e(TAG, "resend failed")
}
}
}
}
companion object {
private val TAG = ChatViewModel::class.simpleName
const val JOIN_ROOM_RETRY_COUNT: Long = 3

View File

@ -14,50 +14,40 @@ import androidx.lifecycle.LifecycleOwner
import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel
import androidx.lifecycle.asLiveData
import androidx.lifecycle.viewModelScope
import com.nextcloud.talk.chat.data.ChatMessageRepository
import com.nextcloud.talk.chat.data.io.AudioFocusRequestManager
import com.nextcloud.talk.chat.data.io.AudioRecorderManager
import com.nextcloud.talk.chat.data.io.MediaPlayerManager
import com.nextcloud.talk.chat.data.model.ChatMessage
import com.nextcloud.talk.chat.data.network.ChatNetworkDataSource
import com.nextcloud.talk.models.json.chat.ChatOverallSingleMessage
import com.nextcloud.talk.models.json.generic.GenericOverall
import com.nextcloud.talk.utils.preferences.AppPreferences
import com.nextcloud.talk.utils.message.SendMessageUtils
import com.stfalcon.chatkit.commons.models.IMessage
import io.reactivex.Observer
import io.reactivex.android.schedulers.AndroidSchedulers
import io.reactivex.disposables.Disposable
import io.reactivex.schedulers.Schedulers
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.update
import java.lang.Thread.sleep
import kotlinx.coroutines.launch
import javax.inject.Inject
@Suppress("Detekt.TooManyFunctions")
class MessageInputViewModel @Inject constructor(
private val chatNetworkDataSource: ChatNetworkDataSource,
private val audioRecorderManager: AudioRecorderManager,
private val mediaPlayerManager: MediaPlayerManager,
private val audioFocusRequestManager: AudioFocusRequestManager,
private val appPreferences: AppPreferences
) : ViewModel(), DefaultLifecycleObserver {
private val audioFocusRequestManager: AudioFocusRequestManager
) : ViewModel(),
DefaultLifecycleObserver {
enum class LifeCycleFlag {
PAUSED,
RESUMED,
STOPPED
}
lateinit var chatRepository: ChatMessageRepository
lateinit var currentLifeCycleFlag: LifeCycleFlag
val disposableSet = mutableSetOf<Disposable>()
data class QueuedMessage(
val id: Int,
var message: CharSequence? = null,
val displayName: String? = null,
val replyTo: Int? = null,
val sendWithoutNotification: Boolean? = null
)
private var isQueueing: Boolean = false
private var messageQueue: MutableList<QueuedMessage> = mutableListOf()
fun setData(chatMessageRepository: ChatMessageRepository) {
chatRepository = chatMessageRepository
}
override fun onResume(owner: LifecycleOwner) {
super.onResume(owner)
@ -106,11 +96,12 @@ class MessageInputViewModel @Inject constructor(
sealed interface ViewState
object SendChatMessageStartState : ViewState
class SendChatMessageSuccessState(val message: CharSequence) : ViewState
class SendChatMessageErrorState(val e: Throwable, val message: CharSequence) : ViewState
class SendChatMessageErrorState(val message: CharSequence) : ViewState
private val _sendChatMessageViewState: MutableLiveData<ViewState> = MutableLiveData(SendChatMessageStartState)
val sendChatMessageViewState: LiveData<ViewState>
get() = _sendChatMessageViewState
object EditMessageStartState : ViewState
object EditMessageErrorState : ViewState
class EditMessageSuccessState(val messageEdited: ChatOverallSingleMessage) : ViewState
@ -122,89 +113,93 @@ class MessageInputViewModel @Inject constructor(
val isVoicePreviewPlaying: LiveData<Boolean>
get() = _isVoicePreviewPlaying
private val _messageQueueSizeFlow = MutableStateFlow(messageQueue.size)
val messageQueueSizeFlow: LiveData<Int>
get() = _messageQueueSizeFlow.asLiveData()
private val _messageQueueFlow: MutableLiveData<List<QueuedMessage>> = MutableLiveData()
val messageQueueFlow: LiveData<List<QueuedMessage>>
get() = _messageQueueFlow
private val _callStartedFlow: MutableLiveData<Pair<ChatMessage, Boolean>> = MutableLiveData()
val callStartedFlow: LiveData<Pair<ChatMessage, Boolean>>
get() = _callStartedFlow
@Suppress("LongParameterList")
fun sendChatMessage(
internalId: String,
credentials: String,
url: String,
message: CharSequence,
message: String,
displayName: String,
replyTo: Int,
sendWithoutNotification: Boolean
) {
if (isQueueing) {
val tempID = System.currentTimeMillis().toInt()
val qMsg = QueuedMessage(tempID, message, displayName, replyTo, sendWithoutNotification)
messageQueue = appPreferences.getMessageQueue(internalId)
messageQueue.add(qMsg)
appPreferences.saveMessageQueue(internalId, messageQueue)
_messageQueueSizeFlow.update { messageQueue.size }
_messageQueueFlow.postValue(listOf(qMsg))
return
val referenceId = SendMessageUtils().generateReferenceId()
Log.d(TAG, "Random SHA-256 Hash: $referenceId")
viewModelScope.launch {
chatRepository.addTemporaryMessage(
message,
displayName,
replyTo,
sendWithoutNotification,
referenceId
).collect { result ->
if (result.isSuccess) {
Log.d(TAG, "temp message ref id: " + (result.getOrNull()?.referenceId ?: "none"))
_sendChatMessageViewState.value = SendChatMessageSuccessState(message)
} else {
_sendChatMessageViewState.value = SendChatMessageErrorState(message)
}
}
}
chatNetworkDataSource.sendChatMessage(
credentials,
url,
message,
displayName,
replyTo,
sendWithoutNotification
).subscribeOn(Schedulers.io())
?.observeOn(AndroidSchedulers.mainThread())
?.subscribe(object : Observer<GenericOverall> {
override fun onSubscribe(d: Disposable) {
disposableSet.add(d)
}
viewModelScope.launch {
chatRepository.sendChatMessage(
credentials,
url,
message,
displayName,
replyTo,
sendWithoutNotification,
referenceId
).collect { result ->
if (result.isSuccess) {
Log.d(TAG, "received ref id: " + (result.getOrNull()?.referenceId ?: "none"))
override fun onError(e: Throwable) {
_sendChatMessageViewState.value = SendChatMessageErrorState(e, message)
}
override fun onComplete() {
// unused atm
}
override fun onNext(t: GenericOverall) {
_sendChatMessageViewState.value = SendChatMessageSuccessState(message)
} else {
_sendChatMessageViewState.value = SendChatMessageErrorState(message)
}
})
}
}
}
fun sendTempMessages(credentials: String, url: String) {
viewModelScope.launch {
chatRepository.sendTempChatMessages(
credentials,
url
)
}
}
fun editChatMessage(credentials: String, url: String, text: String) {
chatNetworkDataSource.editChatMessage(credentials, url, text)
.subscribeOn(Schedulers.io())
?.observeOn(AndroidSchedulers.mainThread())
?.subscribe(object : Observer<ChatOverallSingleMessage> {
override fun onSubscribe(d: Disposable) {
disposableSet.add(d)
}
override fun onError(e: Throwable) {
Log.e(TAG, "failed to edit message", e)
viewModelScope.launch {
chatRepository.editChatMessage(
credentials,
url,
text
).collect { result ->
if (result.isSuccess) {
_editMessageViewState.value = EditMessageSuccessState(result.getOrNull()!!)
} else {
_editMessageViewState.value = EditMessageErrorState
}
}
}
}
override fun onComplete() {
// unused atm
}
override fun onNext(messageEdited: ChatOverallSingleMessage) {
_editMessageViewState.value = EditMessageSuccessState(messageEdited)
}
})
fun editTempChatMessage(message: ChatMessage, editedMessageText: String) {
viewModelScope.launch {
chatRepository.editTempChatMessage(
message,
editedMessageText
).collect {}
}
}
fun reply(message: IMessage?) {
@ -256,75 +251,11 @@ class MessageInputViewModel @Inject constructor(
_getRecordingTime.postValue(time)
}
fun sendAndEmptyMessageQueue(internalId: String, credentials: String, url: String) {
if (isQueueing) return
messageQueue.clear()
val queue = appPreferences.getMessageQueue(internalId)
appPreferences.saveMessageQueue(internalId, null) // empties the queue
while (queue.size > 0) {
val msg = queue.removeAt(0)
sendChatMessage(
internalId,
credentials,
url,
msg.message!!,
msg.displayName!!,
msg.replyTo!!,
msg.sendWithoutNotification!!
)
sleep(DELAY_BETWEEN_QUEUED_MESSAGES)
}
_messageQueueSizeFlow.tryEmit(0)
}
fun getTempMessagesFromMessageQueue(internalId: String) {
val queue = appPreferences.getMessageQueue(internalId)
val list = mutableListOf<QueuedMessage>()
for (msg in queue) {
list.add(msg)
}
_messageQueueFlow.postValue(list)
}
fun switchToMessageQueue(shouldQueue: Boolean) {
isQueueing = shouldQueue
}
fun restoreMessageQueue(internalId: String) {
messageQueue = appPreferences.getMessageQueue(internalId)
_messageQueueSizeFlow.tryEmit(messageQueue.size)
}
fun removeFromQueue(internalId: String, id: Int) {
val queue = appPreferences.getMessageQueue(internalId)
for (qMsg in queue) {
if (qMsg.id == id) {
queue.remove(qMsg)
break
}
}
appPreferences.saveMessageQueue(internalId, queue)
_messageQueueSizeFlow.tryEmit(queue.size)
}
fun editQueuedMessage(internalId: String, id: Int, newMessage: String) {
val queue = appPreferences.getMessageQueue(internalId)
for (qMsg in queue) {
if (qMsg.id == id) {
qMsg.message = newMessage
break
}
}
appPreferences.saveMessageQueue(internalId, queue)
}
fun showCallStartedIndicator(recent: ChatMessage, show: Boolean) {
_callStartedFlow.postValue(Pair(recent, show))
}
companion object {
private val TAG = MessageInputViewModel::class.java.simpleName
private const val DELAY_BETWEEN_QUEUED_MESSAGES: Long = 1000
}
}

View File

@ -10,7 +10,6 @@ package com.nextcloud.talk.conversationlist.data.network
import android.util.Log
import com.nextcloud.talk.chat.data.network.ChatNetworkDataSource
import com.nextcloud.talk.chat.data.network.OfflineFirstChatRepository
import com.nextcloud.talk.conversationlist.data.OfflineConversationsRepository
import com.nextcloud.talk.data.database.dao.ConversationsDao
import com.nextcloud.talk.data.database.mappers.asEntity
@ -107,7 +106,7 @@ class OfflineFirstConversationsRepository @Inject constructor(
var conversationsFromSync: List<ConversationEntity>? = null
if (!monitor.isOnline.first()) {
Log.d(OfflineFirstChatRepository.TAG, "Device is offline, can't load conversations from server")
Log.d(TAG, "Device is offline, can't load conversations from server")
return null
}

View File

@ -61,7 +61,6 @@ import com.nextcloud.talk.translate.repositories.TranslateRepositoryImpl
import com.nextcloud.talk.users.UserManager
import com.nextcloud.talk.utils.DateUtils
import com.nextcloud.talk.utils.database.user.CurrentUserProviderNew
import com.nextcloud.talk.utils.preferences.AppPreferences
import dagger.Module
import dagger.Provides
import okhttp3.OkHttpClient
@ -74,87 +73,66 @@ class RepositoryModule {
ncApi: NcApi,
ncApiCoroutines: NcApiCoroutines,
userProvider: CurrentUserProviderNew
): ConversationsRepository {
return ConversationsRepositoryImpl(ncApi, ncApiCoroutines, userProvider)
}
): ConversationsRepository = ConversationsRepositoryImpl(ncApi, ncApiCoroutines, userProvider)
@Provides
fun provideSharedItemsRepository(ncApi: NcApi, dateUtils: DateUtils): SharedItemsRepository {
return SharedItemsRepositoryImpl(ncApi, dateUtils)
}
fun provideSharedItemsRepository(ncApi: NcApi, dateUtils: DateUtils): SharedItemsRepository =
SharedItemsRepositoryImpl(ncApi, dateUtils)
@Provides
fun provideUnifiedSearchRepository(ncApi: NcApi, userProvider: CurrentUserProviderNew): UnifiedSearchRepository {
return UnifiedSearchRepositoryImpl(ncApi, userProvider)
}
fun provideUnifiedSearchRepository(ncApi: NcApi, userProvider: CurrentUserProviderNew): UnifiedSearchRepository =
UnifiedSearchRepositoryImpl(ncApi, userProvider)
@Provides
fun provideDialogPollRepository(ncApi: NcApi, userProvider: CurrentUserProviderNew): PollRepository {
return PollRepositoryImpl(ncApi, userProvider)
}
fun provideDialogPollRepository(ncApi: NcApi, userProvider: CurrentUserProviderNew): PollRepository =
PollRepositoryImpl(ncApi, userProvider)
@Provides
fun provideRemoteFileBrowserItemsRepository(
okHttpClient: OkHttpClient,
userProvider: CurrentUserProviderNew
): RemoteFileBrowserItemsRepository {
return RemoteFileBrowserItemsRepositoryImpl(okHttpClient, userProvider)
}
): RemoteFileBrowserItemsRepository = RemoteFileBrowserItemsRepositoryImpl(okHttpClient, userProvider)
@Provides
fun provideUsersRepository(database: TalkDatabase): UsersRepository {
return UsersRepositoryImpl(database.usersDao())
}
fun provideUsersRepository(database: TalkDatabase): UsersRepository = UsersRepositoryImpl(database.usersDao())
@Provides
fun provideArbitraryStoragesRepository(database: TalkDatabase): ArbitraryStoragesRepository {
return ArbitraryStoragesRepositoryImpl(database.arbitraryStoragesDao())
}
fun provideArbitraryStoragesRepository(database: TalkDatabase): ArbitraryStoragesRepository =
ArbitraryStoragesRepositoryImpl(database.arbitraryStoragesDao())
@Provides
fun provideReactionsRepository(
ncApi: NcApi,
userProvider: CurrentUserProviderNew,
dao: ChatMessagesDao
): ReactionsRepository {
return ReactionsRepositoryImpl(ncApi, userProvider, dao)
}
): ReactionsRepository = ReactionsRepositoryImpl(ncApi, userProvider, dao)
@Provides
fun provideCallRecordingRepository(ncApi: NcApi, userProvider: CurrentUserProviderNew): CallRecordingRepository {
return CallRecordingRepositoryImpl(ncApi, userProvider)
}
fun provideCallRecordingRepository(ncApi: NcApi, userProvider: CurrentUserProviderNew): CallRecordingRepository =
CallRecordingRepositoryImpl(ncApi, userProvider)
@Provides
fun provideRequestAssistanceRepository(
ncApi: NcApi,
userProvider: CurrentUserProviderNew
): RequestAssistanceRepository {
return RequestAssistanceRepositoryImpl(ncApi, userProvider)
}
): RequestAssistanceRepository = RequestAssistanceRepositoryImpl(ncApi, userProvider)
@Provides
fun provideOpenConversationsRepository(
ncApi: NcApi,
userProvider: CurrentUserProviderNew
): OpenConversationsRepository {
return OpenConversationsRepositoryImpl(ncApi, userProvider)
}
): OpenConversationsRepository = OpenConversationsRepositoryImpl(ncApi, userProvider)
@Provides
fun translateRepository(ncApi: NcApi): TranslateRepository {
return TranslateRepositoryImpl(ncApi)
}
fun translateRepository(ncApi: NcApi): TranslateRepository = TranslateRepositoryImpl(ncApi)
@Provides
fun provideChatNetworkDataSource(ncApi: NcApi, ncApiCoroutines: NcApiCoroutines): ChatNetworkDataSource {
return RetrofitChatNetwork(ncApi, ncApiCoroutines)
}
fun provideChatNetworkDataSource(ncApi: NcApi, ncApiCoroutines: NcApiCoroutines): ChatNetworkDataSource =
RetrofitChatNetwork(ncApi, ncApiCoroutines)
@Provides
fun provideConversationsNetworkDataSource(ncApi: NcApi): ConversationsNetworkDataSource {
return RetrofitConversationsNetwork(ncApi)
}
fun provideConversationsNetworkDataSource(ncApi: NcApi): ConversationsNetworkDataSource =
RetrofitConversationsNetwork(ncApi)
@Provides
fun provideConversationInfoEditRepository(
@ -166,33 +144,27 @@ class RepositoryModule {
}
@Provides
fun provideConversationRepository(ncApi: NcApi, userProvider: CurrentUserProviderNew): ConversationRepository {
return ConversationRepositoryImpl(ncApi, userProvider)
}
fun provideConversationRepository(ncApi: NcApi, userProvider: CurrentUserProviderNew): ConversationRepository =
ConversationRepositoryImpl(ncApi, userProvider)
@Provides
fun provideInvitationsRepository(ncApi: NcApi): InvitationsRepository {
return InvitationsRepositoryImpl(ncApi)
}
fun provideInvitationsRepository(ncApi: NcApi): InvitationsRepository = InvitationsRepositoryImpl(ncApi)
@Provides
fun provideOfflineFirstChatRepository(
chatMessagesDao: ChatMessagesDao,
chatBlocksDao: ChatBlocksDao,
dataSource: ChatNetworkDataSource,
appPreferences: AppPreferences,
networkMonitor: NetworkMonitor,
userProvider: CurrentUserProviderNew
): ChatMessageRepository {
return OfflineFirstChatRepository(
): ChatMessageRepository =
OfflineFirstChatRepository(
chatMessagesDao,
chatBlocksDao,
dataSource,
appPreferences,
networkMonitor,
userProvider
)
}
@Provides
fun provideOfflineFirstConversationsRepository(
@ -201,26 +173,22 @@ class RepositoryModule {
chatNetworkDataSource: ChatNetworkDataSource,
networkMonitor: NetworkMonitor,
currentUserProviderNew: CurrentUserProviderNew
): OfflineConversationsRepository {
return OfflineFirstConversationsRepository(
): OfflineConversationsRepository =
OfflineFirstConversationsRepository(
dao,
dataSource,
chatNetworkDataSource,
networkMonitor,
currentUserProviderNew
)
}
@Provides
fun provideContactsRepository(ncApiCoroutines: NcApiCoroutines, userManager: UserManager): ContactsRepository {
return ContactsRepositoryImpl(ncApiCoroutines, userManager)
}
fun provideContactsRepository(ncApiCoroutines: NcApiCoroutines, userManager: UserManager): ContactsRepository =
ContactsRepositoryImpl(ncApiCoroutines, userManager)
@Provides
fun provideConversationCreationRepository(
ncApiCoroutines: NcApiCoroutines,
userManager: UserManager
): ConversationCreationRepository {
return ConversationCreationRepositoryImpl(ncApiCoroutines, userManager)
}
): ConversationCreationRepository = ConversationCreationRepositoryImpl(ncApiCoroutines, userManager)
}

View File

@ -16,12 +16,14 @@ import com.nextcloud.talk.data.database.model.ChatMessageEntity
import kotlinx.coroutines.flow.Flow
@Dao
@Suppress("Detekt.TooManyFunctions")
interface ChatMessagesDao {
@Query(
"""
SELECT MAX(id) as max_items
FROM ChatMessages
WHERE internalConversationId = :internalConversationId
AND isTemporary = 0
"""
)
fun getNewestMessageId(internalConversationId: String): Long
@ -31,11 +33,35 @@ interface ChatMessagesDao {
SELECT *
FROM ChatMessages
WHERE internalConversationId = :internalConversationId
AND isTemporary = 0
ORDER BY timestamp DESC, id DESC
"""
)
fun getMessagesForConversation(internalConversationId: String): Flow<List<ChatMessageEntity>>
@Query(
"""
SELECT *
FROM ChatMessages
WHERE internalConversationId = :internalConversationId
AND isTemporary = 1
ORDER BY timestamp DESC, id DESC
"""
)
fun getTempMessagesForConversation(internalConversationId: String): Flow<List<ChatMessageEntity>>
@Query(
"""
SELECT *
FROM ChatMessages
WHERE internalConversationId = :internalConversationId
AND referenceId = :referenceId
AND isTemporary = 1
ORDER BY timestamp DESC, id DESC
"""
)
fun getTempMessageForConversation(internalConversationId: String, referenceId: String): Flow<ChatMessageEntity>
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun upsertChatMessages(chatMessages: List<ChatMessageEntity>)
@ -54,10 +80,20 @@ interface ChatMessagesDao {
@Query(
value = """
DELETE FROM ChatMessages
WHERE id in (:messageIds)
WHERE internalId in (:internalIds)
"""
)
fun deleteChatMessages(messageIds: List<Int>)
fun deleteChatMessages(internalIds: List<String>)
@Query(
value = """
DELETE FROM ChatMessages
WHERE internalConversationId = :internalConversationId
AND referenceId in (:referenceIds)
AND isTemporary = 1
"""
)
fun deleteTempChatMessages(internalConversationId: String, referenceIds: List<String>)
@Update
fun updateChatMessage(message: ChatMessageEntity)
@ -77,6 +113,7 @@ interface ChatMessagesDao {
SELECT *
FROM ChatMessages
WHERE internalConversationId = :internalConversationId AND id >= :messageId
AND isTemporary = 0
ORDER BY timestamp ASC, id ASC
"""
)
@ -87,6 +124,7 @@ interface ChatMessagesDao {
SELECT *
FROM ChatMessages
WHERE internalConversationId = :internalConversationId
AND isTemporary = 0
AND id < :messageId
ORDER BY timestamp DESC, id DESC
LIMIT :limit
@ -103,6 +141,7 @@ interface ChatMessagesDao {
SELECT *
FROM ChatMessages
WHERE internalConversationId = :internalConversationId
AND isTemporary = 0
AND id <= :messageId
ORDER BY timestamp DESC, id DESC
LIMIT :limit
@ -119,6 +158,7 @@ interface ChatMessagesDao {
SELECT COUNT(*)
FROM ChatMessages
WHERE internalConversationId = :internalConversationId
AND isTemporary = 0
AND id BETWEEN :newestMessageId AND :oldestMessageId
"""
)

View File

@ -10,6 +10,7 @@ package com.nextcloud.talk.data.database.mappers
import com.nextcloud.talk.models.json.chat.ChatMessageJson
import com.nextcloud.talk.data.database.model.ChatMessageEntity
import com.nextcloud.talk.chat.data.model.ChatMessage
import com.nextcloud.talk.models.json.chat.ReadStatus
fun ChatMessageJson.asEntity(accountId: Long) =
ChatMessageEntity(
@ -37,7 +38,9 @@ fun ChatMessageJson.asEntity(accountId: Long) =
lastEditActorId = lastEditActorId,
lastEditActorType = lastEditActorType,
lastEditTimestamp = lastEditTimestamp,
deleted = deleted
deleted = deleted,
referenceId = referenceId,
silent = silent
)
fun ChatMessageEntity.asModel() =
@ -62,7 +65,12 @@ fun ChatMessageEntity.asModel() =
lastEditActorId = lastEditActorId,
lastEditActorType = lastEditActorType,
lastEditTimestamp = lastEditTimestamp,
isDeleted = deleted
isDeleted = deleted,
referenceId = referenceId,
isTemporary = isTemporary,
sendingFailed = sendingFailed,
readStatus = ReadStatus.NONE,
silent = silent
)
fun ChatMessageJson.asModel() =
@ -87,5 +95,7 @@ fun ChatMessageJson.asModel() =
lastEditActorId = lastEditActorId,
lastEditActorType = lastEditActorType,
lastEditTimestamp = lastEditTimestamp,
isDeleted = deleted
isDeleted = deleted,
referenceId = referenceId,
silent = silent
)

View File

@ -52,6 +52,7 @@ data class ChatMessageEntity(
@ColumnInfo(name = "deleted") var deleted: Boolean = false,
@ColumnInfo(name = "expirationTimestamp") var expirationTimestamp: Int = 0,
@ColumnInfo(name = "isReplyable") var replyable: Boolean = false,
@ColumnInfo(name = "isTemporary") var isTemporary: Boolean = false,
@ColumnInfo(name = "lastEditActorDisplayName") var lastEditActorDisplayName: String? = null,
@ColumnInfo(name = "lastEditActorId") var lastEditActorId: String? = null,
@ColumnInfo(name = "lastEditActorType") var lastEditActorType: String? = null,
@ -62,8 +63,9 @@ data class ChatMessageEntity(
@ColumnInfo(name = "parent") var parentMessageId: Long? = null,
@ColumnInfo(name = "reactions") var reactions: LinkedHashMap<String, Int>? = null,
@ColumnInfo(name = "reactionsSelf") var reactionsSelf: ArrayList<String>? = null,
@ColumnInfo(name = "referenceId") var referenceId: String? = null,
@ColumnInfo(name = "sendingFailed") var sendingFailed: Boolean = false,
@ColumnInfo(name = "silent") var silent: Boolean = false,
@ColumnInfo(name = "systemMessage") var systemMessageType: ChatMessage.SystemMessageType,
@ColumnInfo(name = "timestamp") var timestamp: Long = 0
// missing/not needed: referenceId
// missing/not needed: silent
)

View File

@ -48,6 +48,13 @@ object Migrations {
}
}
val MIGRATION_12_13 = object : Migration(12, 13) {
override fun migrate(db: SupportSQLiteDatabase) {
Log.i("Migrations", "Migrating 12 to 13")
addTempMessagesSupport(db)
}
}
fun migrateToRoom(db: SupportSQLiteDatabase) {
db.execSQL(
"CREATE TABLE User_new (" +
@ -257,4 +264,42 @@ object Migrations {
Log.i("Migrations", "hasArchived already exists")
}
}
fun addTempMessagesSupport(db: SupportSQLiteDatabase) {
try {
db.execSQL(
"ALTER TABLE ChatMessages " +
"ADD COLUMN referenceId TEXT;"
)
} catch (e: SQLException) {
Log.i("Migrations", "Something went wrong when adding column referenceId to table ChatMessages")
}
try {
db.execSQL(
"ALTER TABLE ChatMessages " +
"ADD COLUMN isTemporary INTEGER NOT NULL DEFAULT 0;"
)
} catch (e: SQLException) {
Log.i("Migrations", "Something went wrong when adding column isTemporary to table ChatMessages")
}
try {
db.execSQL(
"ALTER TABLE ChatMessages " +
"ADD COLUMN sendingFailed INTEGER NOT NULL DEFAULT 0;"
)
} catch (e: SQLException) {
Log.i("Migrations", "Something went wrong when adding column sendingFailed to table ChatMessages")
}
try {
db.execSQL(
"ALTER TABLE ChatMessages " +
"ADD COLUMN silent INTEGER NOT NULL DEFAULT 0;"
)
} catch (e: SQLException) {
Log.i("Migrations", "Something went wrong when adding column silent to table ChatMessages")
}
}
}

View File

@ -49,7 +49,7 @@ import java.util.Locale
ChatMessageEntity::class,
ChatBlockEntity::class
],
version = 12,
version = 13,
autoMigrations = [
AutoMigration(from = 9, to = 11)
],
@ -114,7 +114,8 @@ abstract class TalkDatabase : RoomDatabase() {
Migrations.MIGRATION_7_8,
Migrations.MIGRATION_8_9,
Migrations.MIGRATION_10_11,
Migrations.MIGRATION_11_12
Migrations.MIGRATION_11_12,
Migrations.MIGRATION_12_13
)
.allowMainThreadQueries()
.addCallback(
@ -128,8 +129,8 @@ abstract class TalkDatabase : RoomDatabase() {
.build()
}
private fun getCipherMigrationHook(): SQLiteDatabaseHook {
return object : SQLiteDatabaseHook {
private fun getCipherMigrationHook(): SQLiteDatabaseHook =
object : SQLiteDatabaseHook {
override fun preKey(database: SQLiteDatabase) {
// unused atm
}
@ -140,6 +141,5 @@ abstract class TalkDatabase : RoomDatabase() {
Log.i(TAG, "DB cipher_migrate END")
}
}
}
}
}

View File

@ -0,0 +1,15 @@
/*
* Nextcloud Talk - Android Client
*
* SPDX-FileCopyrightText: 2024 Marcel Hibbe <dev@mhibbe.de>
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package com.nextcloud.talk.extensions
fun Long.toIntOrZero(): Int {
return if (this >= Int.MIN_VALUE && this <= Int.MAX_VALUE) {
toInt()
} else {
0
}
}

View File

@ -192,7 +192,6 @@ public class AccountRemovalWorker extends Worker {
if (user.getId() != null) {
String username = user.getUsername();
try {
appPreferences.deleteAllMessageQueuesFor(user.getUserId());
userManager.deleteUser(user.getId());
Log.d(TAG, "deleted user: " + username);
} catch (Throwable e) {

View File

@ -42,5 +42,7 @@ data class ChatMessageJson(
@JsonField(name = ["lastEditActorId"]) var lastEditActorId: String? = null,
@JsonField(name = ["lastEditActorType"]) var lastEditActorType: String? = null,
@JsonField(name = ["lastEditTimestamp"]) var lastEditTimestamp: Long? = 0,
@JsonField(name = ["deleted"]) var deleted: Boolean = false
@JsonField(name = ["deleted"]) var deleted: Boolean = false,
@JsonField(name = ["referenceId"]) var referenceId: String? = null,
@JsonField(name = ["silent"]) var silent: Boolean = false
) : Parcelable

View File

@ -23,13 +23,14 @@ import com.nextcloud.talk.R
import com.nextcloud.talk.api.NcApi
import com.nextcloud.talk.application.NextcloudTalkApplication
import com.nextcloud.talk.data.user.model.User
import com.nextcloud.talk.models.json.generic.GenericOverall
import com.nextcloud.talk.models.json.chat.ChatOverallSingleMessage
import com.nextcloud.talk.users.UserManager
import com.nextcloud.talk.utils.ApiUtils
import com.nextcloud.talk.utils.NotificationUtils
import com.nextcloud.talk.utils.bundle.BundleKeys.KEY_INTERNAL_USER_ID
import com.nextcloud.talk.utils.bundle.BundleKeys.KEY_ROOM_TOKEN
import com.nextcloud.talk.utils.bundle.BundleKeys.KEY_SYSTEM_NOTIFICATION_ID
import com.nextcloud.talk.utils.message.SendMessageUtils
import io.reactivex.Observer
import io.reactivex.Single
import io.reactivex.android.schedulers.AndroidSchedulers
@ -71,24 +72,31 @@ class DirectReplyReceiver : BroadcastReceiver() {
sendDirectReply()
}
private fun getMessageText(intent: Intent): CharSequence? {
return RemoteInput.getResultsFromIntent(intent)?.getCharSequence(NotificationUtils.KEY_DIRECT_REPLY)
}
private fun getMessageText(intent: Intent): CharSequence? =
RemoteInput.getResultsFromIntent(intent)?.getCharSequence(NotificationUtils.KEY_DIRECT_REPLY)
private fun sendDirectReply() {
val credentials = ApiUtils.getCredentials(currentUser.username, currentUser.token)
val apiVersion = ApiUtils.getChatApiVersion(currentUser.capabilities!!.spreedCapability!!, intArrayOf(1))
val url = ApiUtils.getUrlForChat(apiVersion, currentUser.baseUrl!!, roomToken!!)
ncApi.sendChatMessage(credentials, url, replyMessage, currentUser.displayName, null, false)
ncApi.sendChatMessage(
credentials,
url,
replyMessage,
currentUser.displayName,
null,
false,
SendMessageUtils().generateReferenceId()
)
?.subscribeOn(Schedulers.io())
?.observeOn(AndroidSchedulers.mainThread())
?.subscribe(object : Observer<GenericOverall> {
?.subscribe(object : Observer<ChatOverallSingleMessage> {
override fun onSubscribe(d: Disposable) {
// unused atm
}
override fun onNext(genericOverall: GenericOverall) {
override fun onNext(message: ChatOverallSingleMessage) {
confirmReplySent()
}

View File

@ -0,0 +1,133 @@
/*
* Nextcloud Talk - Android Client
*
* SPDX-FileCopyrightText: 2022 Marcel Hibbe <dev@mhibbe.de>
* SPDX-FileCopyrightText: 2022 Andy Scherzinger <info@andy-scherzinger.de>
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package com.nextcloud.talk.ui.dialog
import android.os.Bundle
import android.view.View
import android.view.ViewGroup
import androidx.lifecycle.lifecycleScope
import autodagger.AutoInjector
import com.google.android.material.bottomsheet.BottomSheetBehavior
import com.google.android.material.bottomsheet.BottomSheetDialog
import com.nextcloud.talk.R
import com.nextcloud.talk.application.NextcloudTalkApplication
import com.nextcloud.talk.chat.ChatActivity
import com.nextcloud.talk.chat.data.model.ChatMessage
import com.nextcloud.talk.data.network.NetworkMonitor
import com.nextcloud.talk.databinding.DialogTempMessageActionsBinding
import com.nextcloud.talk.ui.theme.ViewThemeUtils
import com.nextcloud.talk.utils.ApiUtils
import com.nextcloud.talk.utils.DateUtils
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch
import javax.inject.Inject
@AutoInjector(NextcloudTalkApplication::class)
class TempMessageActionsDialog(
private val chatActivity: ChatActivity,
private val message: ChatMessage
) : BottomSheetDialog(chatActivity) {
@Inject
lateinit var viewThemeUtils: ViewThemeUtils
@Inject
lateinit var dateUtils: DateUtils
@Inject
lateinit var networkMonitor: NetworkMonitor
private lateinit var binding: DialogTempMessageActionsBinding
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
NextcloudTalkApplication.sharedApplication?.componentApplication?.inject(this)
binding = DialogTempMessageActionsBinding.inflate(layoutInflater)
setContentView(binding.root)
window?.setLayout(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT)
viewThemeUtils.material.colorBottomSheetBackground(binding.root)
viewThemeUtils.material.colorBottomSheetDragHandle(binding.bottomSheetDragHandle)
initMenuItems()
}
private fun initMenuItems() {
this.lifecycleScope.launch {
val isOnline = networkMonitor.isOnline.first()
initResendMessage(message.sendingFailed && isOnline)
initMenuEditMessage(message.sendingFailed || !isOnline)
initMenuDeleteMessage(message.sendingFailed || !isOnline)
initMenuItemCopy()
}
}
override fun onStart() {
super.onStart()
val bottomSheet = findViewById<View>(R.id.design_bottom_sheet)
val behavior = BottomSheetBehavior.from(bottomSheet as View)
behavior.state = BottomSheetBehavior.STATE_COLLAPSED
}
private fun initResendMessage(visible: Boolean) {
if (visible) {
binding.menuResendMessage.setOnClickListener {
chatActivity.chatViewModel.resendMessage(
chatActivity.conversationUser!!.getCredentials(),
ApiUtils.getUrlForChat(
chatActivity.chatApiVersion,
chatActivity.conversationUser!!.baseUrl!!,
chatActivity.roomToken
),
message
)
dismiss()
}
}
binding.menuResendMessage.visibility = getVisibility(visible)
}
private fun initMenuDeleteMessage(visible: Boolean) {
if (visible) {
binding.menuDeleteMessage.setOnClickListener {
chatActivity.chatViewModel.deleteTempMessage(message)
dismiss()
}
}
binding.menuDeleteMessage.visibility = getVisibility(visible)
}
private fun initMenuEditMessage(visible: Boolean) {
if (visible) {
binding.menuEditMessage.setOnClickListener {
chatActivity.messageInputViewModel.edit(message)
dismiss()
}
}
binding.menuEditMessage.visibility = getVisibility(visible)
}
private fun initMenuItemCopy() {
binding.menuCopyMessage.setOnClickListener {
chatActivity.copyMessage(message)
dismiss()
}
}
private fun getVisibility(visible: Boolean): Int {
return if (visible) {
View.VISIBLE
} else {
View.GONE
}
}
companion object {
private val TAG = TempMessageActionsDialog::class.java.simpleName
}
}

View File

@ -0,0 +1,32 @@
/*
* Nextcloud Talk - Android Client
*
* SPDX-FileCopyrightText: 2024 Marcel Hibbe <dev@mhibbe.de>
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package com.nextcloud.talk.utils.message
import java.security.MessageDigest
import java.util.Calendar
import java.util.UUID
class SendMessageUtils {
fun generateReferenceId(): String {
val randomString = UUID.randomUUID().toString()
val digest = MessageDigest.getInstance("SHA-256")
val hashBytes = digest.digest(randomString.toByteArray(Charsets.UTF_8))
return hashBytes.joinToString("") { "%02x".format(it) }
}
@Suppress("MagicNumber")
fun removeYearFromTimestamp(timestampMillis: Long): Int {
val calendar = Calendar.getInstance().apply { timeInMillis = timestampMillis }
val month = calendar.get(Calendar.MONTH)
val day = calendar.get(Calendar.DAY_OF_MONTH)
val hour = calendar.get(Calendar.HOUR_OF_DAY)
val minute = calendar.get(Calendar.MINUTE)
val second = calendar.get(Calendar.SECOND)
return (month * 1000000) + (day * 10000) + (hour * 100) + (minute * 10) + second
}
}

View File

@ -175,12 +175,6 @@ public interface AppPreferences {
int getLastKnownId(String internalConversationId, int defaultValue);
void saveMessageQueue(String internalConversationId, List<MessageInputViewModel.QueuedMessage> queue);
List<MessageInputViewModel.QueuedMessage> getMessageQueue(String internalConversationId);
void deleteAllMessageQueuesFor(String userId);
void saveVoiceMessagePlaybackSpeedPreferences(Map<String, PlaybackSpeed> speeds);
Map<String, PlaybackSpeed> readVoiceMessagePlaybackSpeedPreferences();

View File

@ -17,7 +17,6 @@ import androidx.datastore.preferences.core.longPreferencesKey
import androidx.datastore.preferences.core.stringPreferencesKey
import androidx.datastore.preferences.preferencesDataStore
import com.nextcloud.talk.R
import com.nextcloud.talk.chat.viewmodels.MessageInputViewModel
import com.nextcloud.talk.ui.PlaybackSpeed
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.async
@ -501,76 +500,6 @@ class AppPreferencesImpl(val context: Context) : AppPreferences {
return if (lastReadId.isNotEmpty()) lastReadId.toInt() else defaultValue
}
override fun saveMessageQueue(
internalConversationId: String,
queue: MutableList<MessageInputViewModel.QueuedMessage>?
) {
runBlocking<Unit> {
async {
var queueStr = ""
queue?.let {
for (msg in queue) {
val msgStr = "${msg.id},${msg.message},${msg.replyTo},${msg.displayName},${
msg
.sendWithoutNotification
}^"
queueStr += msgStr
}
}
writeString(internalConversationId + MESSAGE_QUEUE, queueStr)
}
}
}
@Suppress("Detekt.TooGenericExceptionCaught")
override fun getMessageQueue(internalConversationId: String): MutableList<MessageInputViewModel.QueuedMessage> {
val queueStr =
runBlocking { async { readString(internalConversationId + MESSAGE_QUEUE).first() } }.getCompleted()
val queue: MutableList<MessageInputViewModel.QueuedMessage> = mutableListOf()
if (queueStr.isEmpty()) return queue
for (msgStr in queueStr.split("^")) {
try {
if (msgStr.isNotEmpty()) {
val msgArray = msgStr.split(",")
val id = msgArray[ID].toInt()
val message = msgArray[MESSAGE_INDEX]
val replyTo = msgArray[REPLY_TO_INDEX].toInt()
val displayName = msgArray[DISPLAY_NAME_INDEX]
val silent = msgArray[SILENT_INDEX].toBoolean()
val qMsg = MessageInputViewModel.QueuedMessage(id, message, displayName, replyTo, silent)
queue.add(qMsg)
}
} catch (e: IndexOutOfBoundsException) {
Log.e(TAG, "Message string: $msgStr\n Queue String: $queueStr \n$e")
}
}
return queue
}
override fun deleteAllMessageQueuesFor(userId: String) {
runBlocking {
async {
val keyList = mutableListOf<Preferences.Key<*>>()
val preferencesMap = context.dataStore.data.first().asMap()
for (preference in preferencesMap) {
if (preference.key.name.contains("$userId@")) {
keyList.add(preference.key)
}
}
for (key in keyList) {
context.dataStore.edit {
it.remove(key)
}
}
}
}
}
override fun saveVoiceMessagePlaybackSpeedPreferences(speeds: Map<String, PlaybackSpeed>) {
Json.encodeToString(speeds).let {
runBlocking<Unit> { async { writeString(VOICE_MESSAGE_PLAYBACK_SPEEDS, it) } }
@ -655,13 +584,7 @@ class AppPreferencesImpl(val context: Context) : AppPreferences {
@Suppress("UnusedPrivateProperty")
private val TAG = AppPreferencesImpl::class.simpleName
private val Context.dataStore: DataStore<Preferences> by preferencesDataStore(name = "settings")
private const val ID: Int = 0
private const val MESSAGE_INDEX: Int = 1
private const val REPLY_TO_INDEX: Int = 2
private const val DISPLAY_NAME_INDEX: Int = 3
private const val SILENT_INDEX: Int = 4
const val PROXY_TYPE = "proxy_type"
const val PROXY_SERVER = "proxy_server"
const val PROXY_HOST = "proxy_host"
const val PROXY_PORT = "proxy_port"
const val PROXY_CRED = "proxy_credentials"
@ -686,7 +609,6 @@ class AppPreferencesImpl(val context: Context) : AppPreferences {
const val DB_ROOM_MIGRATED = "db_room_migrated"
const val PHONE_BOOK_INTEGRATION_LAST_RUN = "phone_book_integration_last_run"
const val TYPING_STATUS = "typing_status"
const val MESSAGE_QUEUE = "@message_queue"
const val VOICE_MESSAGE_PLAYBACK_SPEEDS = "voice_message_playback_speeds"
const val SHOW_REGULAR_NOTIFICATION_WARNING = "show_regular_notification_warning"
const val LAST_NOTIFICATION_WARNING = "last_notification_warning"

View File

@ -0,0 +1,16 @@
<!--
~ Nextcloud Talk - Android Client
~
~ SPDX-FileCopyrightText: 2025 Google LLC
~ SPDX-License-Identifier: Apache-2.0
-->
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:tint="#000000"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="@android:color/white"
android:pathData="M11,15h2v2h-2zM11,7h2v6h-2zM11.99,2C6.47,2 2,6.48 2,12s4.47,10 9.99,10C17.52,22 22,17.52 22,12S17.52,2 11.99,2zM12,20c-4.42,0 -8,-3.58 -8,-8s3.58,-8 8,-8 8,3.58 8,8 -3.58,8 -8,8z"/>
</vector>

View File

@ -0,0 +1,16 @@
<!--
~ Nextcloud Talk - Android Client
~
~ SPDX-FileCopyrightText: 2021-2024 Google LLC
~ SPDX-License-Identifier: Apache-2.0
-->
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:tint="#000000"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="@android:color/white"
android:pathData="M1,21h22L12,2 1,21zM13,18h-2v-2h2v2zM13,14h-2v-4h2v4z"/>
</vector>

View File

@ -0,0 +1,19 @@
<!--
~ Nextcloud Talk - Android Client
~
~ SPDX-FileCopyrightText: 2025 Google LLC
~ SPDX-License-Identifier: Apache-2.0
-->
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:tint="#000000"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="@android:color/white"
android:pathData="M11.99,2C6.47,2 2,6.48 2,12s4.47,10 9.99,10C17.52,22 22,17.52 22,12S17.52,2 11.99,2zM12,20c-4.42,0 -8,-3.58 -8,-8s3.58,-8 8,-8 8,3.58 8,8 -3.58,8 -8,8z" />
<path
android:fillColor="@android:color/white"
android:pathData="M12.5,7H11v6l5.25,3.15 0.75,-1.23 -4.5,-2.67z" />
</vector>

View File

@ -0,0 +1,165 @@
<?xml version="1.0" encoding="utf-8"?><!--
~ Nextcloud Talk - Android Client
~
~ SPDX-FileCopyrightText: 2022 Andy Scherzinger <info@andy-scherzinger.de>
~ SPDX-License-Identifier: GPL-3.0-or-later
-->
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:paddingBottom="@dimen/standard_half_padding">
<com.google.android.material.bottomsheet.BottomSheetDragHandleView
android:id="@+id/bottom_sheet_drag_handle"
android:layout_width="match_parent"
android:layout_height="wrap_content" />
<androidx.core.widget.NestedScrollView
android:layout_width="match_parent"
android:layout_height="wrap_content">
<LinearLayout
android:id="@+id/message_actions"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical">
<LinearLayout
android:id="@+id/menu_resend_message"
android:layout_width="match_parent"
android:layout_height="@dimen/bottom_sheet_item_height"
android:background="?android:attr/selectableItemBackground"
android:gravity="center_vertical"
android:orientation="horizontal"
tools:ignore="UseCompoundDrawables">
<ImageView
android:id="@+id/menu_icon_resend_message"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:contentDescription="@null"
android:paddingStart="@dimen/standard_padding"
android:paddingEnd="@dimen/zero"
android:src="@drawable/ic_send"
app:tint="@color/high_emphasis_menu_icon" />
<androidx.appcompat.widget.AppCompatTextView
android:id="@+id/menu_text_resend_message"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_gravity="start|center_vertical"
android:paddingStart="@dimen/standard_double_padding"
android:paddingEnd="@dimen/standard_padding"
android:text="@string/resend_message"
android:textAlignment="viewStart"
android:textColor="@color/high_emphasis_text"
android:textSize="@dimen/bottom_sheet_text_size" />
</LinearLayout>
<LinearLayout
android:id="@+id/menu_copy_message"
android:layout_width="match_parent"
android:layout_height="@dimen/bottom_sheet_item_height"
android:background="?android:attr/selectableItemBackground"
android:gravity="center_vertical"
android:orientation="horizontal"
tools:ignore="UseCompoundDrawables">
<ImageView
android:id="@+id/menu_icon_copy_message"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:contentDescription="@null"
android:paddingStart="@dimen/standard_padding"
android:paddingEnd="@dimen/zero"
android:src="@drawable/ic_content_copy"
app:tint="@color/high_emphasis_menu_icon" />
<androidx.appcompat.widget.AppCompatTextView
android:id="@+id/menu_text_copy_message"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_gravity="start|center_vertical"
android:paddingStart="@dimen/standard_double_padding"
android:paddingEnd="@dimen/standard_padding"
android:text="@string/nc_copy_message"
android:textAlignment="viewStart"
android:textColor="@color/high_emphasis_text"
android:textSize="@dimen/bottom_sheet_text_size" />
</LinearLayout>
<LinearLayout
android:id="@+id/menu_edit_message"
android:layout_width="match_parent"
android:layout_height="@dimen/bottom_sheet_item_height"
android:background="?android:attr/selectableItemBackground"
android:gravity="center_vertical"
android:orientation="horizontal"
tools:ignore="UseCompoundDrawables">
<ImageView
android:id="@+id/menu_icon_edit_message"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:contentDescription="@string/edit_message_icon_description"
android:paddingStart="@dimen/standard_padding"
android:paddingEnd="@dimen/zero"
android:src="@drawable/ic_edit_24"
app:tint="@color/high_emphasis_menu_icon" />
<androidx.appcompat.widget.AppCompatTextView
android:id="@+id/menu_text_edit_message"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_gravity="start|center_vertical"
android:paddingStart="@dimen/standard_double_padding"
android:paddingEnd="@dimen/standard_padding"
android:text="@string/nc_edit_message"
android:textAlignment="viewStart"
android:textColor="@color/high_emphasis_text"
android:textSize="@dimen/bottom_sheet_text_size" />
</LinearLayout>
<LinearLayout
android:id="@+id/menu_delete_message"
android:layout_width="match_parent"
android:layout_height="@dimen/bottom_sheet_item_height"
android:background="?android:attr/selectableItemBackground"
android:gravity="center_vertical"
android:orientation="horizontal"
tools:ignore="UseCompoundDrawables">
<ImageView
android:id="@+id/menu_icon_delete_message"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:contentDescription="@null"
android:paddingStart="@dimen/standard_padding"
android:paddingEnd="@dimen/zero"
android:src="@drawable/ic_delete"
app:tint="@color/high_emphasis_menu_icon" />
<androidx.appcompat.widget.AppCompatTextView
android:id="@+id/menu_text_delete_message"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_gravity="start|center_vertical"
android:paddingStart="@dimen/standard_double_padding"
android:paddingEnd="@dimen/standard_padding"
android:text="@string/nc_delete_message"
android:textAlignment="viewStart"
android:textColor="@color/high_emphasis_text"
android:textSize="@dimen/bottom_sheet_text_size" />
</LinearLayout>
</LinearLayout>
</androidx.core.widget.NestedScrollView>
</LinearLayout>

View File

@ -43,7 +43,6 @@
android:textIsSelectable="false"
tools:text="Talk to you later!" />
<TextView
android:id="@id/messageTime"
android:layout_width="wrap_content"
@ -76,13 +75,36 @@
<ImageView
android:id="@+id/checkMark"
android:layout_width="25dp"
android:layout_height="@dimen/message_bubble_checkmark_height"
android:layout_below="@id/messageTime"
android:layout_marginStart="8dp"
android:contentDescription="@null"
app:layout_alignSelf="center"
app:tint="@color/high_emphasis_text"
tools:src="@drawable/ic_check_all" />
<ImageView
android:id="@+id/sending_failed"
android:layout_width="wrap_content"
android:layout_height="@dimen/message_bubble_checkmark_height"
android:layout_below="@id/messageTime"
android:layout_marginStart="8dp"
android:contentDescription="@null"
app:layout_alignSelf="center"
app:tint="@color/high_emphasis_text" />
app:tint="@color/high_emphasis_text"
tools:src="@drawable/ic_warning_white"/>
<ImageView
android:id="@+id/sending_progress"
android:layout_width="wrap_content"
android:layout_height="@dimen/message_bubble_checkmark_height"
android:layout_below="@id/messageTime"
android:layout_marginStart="8dp"
android:contentDescription="@null"
app:layout_alignSelf="center"
app:tint="@color/high_emphasis_text"
tools:src="@drawable/baseline_schedule_24"/>
<include
android:id="@+id/reactions"

View File

@ -1,78 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
~ Nextcloud Talk - Android Client
~
~ SPDX-FileCopyrightText: 2024 Julius Linus <juliuslinus1@gmail.com>
~ SPDX-License-Identifier: GPL-3.0-or-later
-->
<RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginLeft="16dp"
android:layout_marginTop="2dp"
android:layout_marginRight="16dp"
android:layout_marginBottom="2dp">
<LinearLayout
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:layout_centerVertical="true">
<ImageView
android:id="@+id/temp_msg_edit"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:contentDescription="@null"
android:src="@drawable/ic_edit"
android:paddingHorizontal="@dimen/standard_half_padding"
android:layout_marginEnd="@dimen/standard_quarter_margin" />
<ImageView
android:id="@+id/temp_msg_delete"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:contentDescription="@null"
android:src="@drawable/ic_delete"
android:paddingHorizontal="@dimen/standard_half_padding"
android:layout_marginStart="@dimen/standard_quarter_margin" />
</LinearLayout>
<com.google.android.flexbox.FlexboxLayout
android:id="@id/bubble"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_alignParentEnd="true"
android:layout_marginStart="@dimen/message_outcoming_bubble_margin_left"
app:alignContent="stretch"
app:alignItems="stretch"
app:flexWrap="wrap"
app:justifyContent="flex_end">
<include
android:id="@+id/message_quote"
layout="@layout/item_message_quote"
android:visibility="gone"
tools:visibility="visible"/>
<androidx.emoji2.widget.EmojiTextView
android:id="@id/messageText"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:lineSpacingMultiplier="1.2"
android:textAlignment="viewStart"
android:textColorHighlight="@color/nc_grey"
android:textIsSelectable="false"
tools:text="Talk to you later!" />
<com.google.android.material.textfield.TextInputEditText
android:id="@+id/message_edit"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:visibility="gone" />
</com.google.android.flexbox.FlexboxLayout>
</RelativeLayout>

View File

@ -433,6 +433,9 @@ How to translate with transifex:
<string name="nc_formatted_message_you">You: %1$s</string>
<string name="nc_message_read">Message read</string>
<string name="nc_message_sent">Message sent</string>
<string name="nc_message_offline">Offline</string>
<string name="nc_message_failed">Failed</string>
<string name="nc_message_sending">Sending</string>
<string name="nc_message_failed_to_send">Failed to send message:</string>
<string name="nc_remote_audio_off">Remote audio off</string>
<string name="nc_add_attachment">Add attachment</string>
@ -820,7 +823,6 @@ How to translate with transifex:
<string name="show_banned_participants">Show banned participants</string>
<string name="bans_list">Bans list</string>
<string name="connection_lost_sent_messages_are_queued">Connection lost - Sent messages are queued</string>
<string name="connection_lost_queued">Connection lost - %1$d are queued</string>
<string name="connection_established">Connection established</string>
<string name="message_deleted_by_you">Message deleted by you</string>
<string name="unban">Unban</string>
@ -843,4 +845,5 @@ How to translate with transifex:
<string name="user_absence">%1$s is out of office and might not respond</string>
<string name="user_absence_for_one_day">%1$s is out of office today</string>
<string name="user_absence_replacement">Replacement: </string>
<string name="resend_message">Resend</string>
</resources>

View File

@ -1,2 +1,2 @@
DO NOT TOUCH; GENERATED BY DRONE
<span class="mdl-layout-title">Lint Report: 36 errors and 106 warnings</span>
<span class="mdl-layout-title">Lint Report: 36 errors and 104 warnings</span>