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

View File

@ -68,9 +68,6 @@ public class TalkMessagesListAdapter<M extends IMessage> extends MessagesListAda
} else if (holder instanceof SystemMessageViewHolder holderInstance) { } else if (holder instanceof SystemMessageViewHolder holderInstance) {
holderInstance.assignSystemMessageInterface(chatActivity); holderInstance.assignSystemMessageInterface(chatActivity);
} else if (holder instanceof TemporaryMessageViewHolder holderInstance) {
holderInstance.assignTemporaryMessageInterface(chatActivity);
} else if (holder instanceof IncomingDeckCardViewHolder holderInstance) { } else if (holder instanceof IncomingDeckCardViewHolder holderInstance) {
holderInstance.assignCommonMessageInterface(chatActivity); holderInstance.assignCommonMessageInterface(chatActivity);
} else if (holder instanceof OutcomingDeckCardViewHolder holderInstance) { } 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 @FormUrlEncoded
@POST @POST
Observable<GenericOverall> sendChatMessage(@Header("Authorization") String authorization, Observable<ChatOverallSingleMessage> sendChatMessage(@Header("Authorization") String authorization,
@Url String url, @Url String url,
@Field("message") CharSequence message, @Field("message") CharSequence message,
@Field("actorDisplayName") String actorDisplayName, @Field("actorDisplayName") String actorDisplayName,
@Field("replyTo") Integer replyTo, @Field("replyTo") Integer replyTo,
@Field("silent") Boolean sendWithoutNotification); @Field("silent") Boolean sendWithoutNotification,
@Field("referenceId") String referenceId
@FormUrlEncoded );
@PUT
Observable<ChatOverallSingleMessage> editChatMessage(@Header("Authorization") String authorization,
@Url String url,
@Field("message") String message);
@GET @GET
Observable<Response<ChatShareOverall>> getSharedItems( Observable<Response<ChatShareOverall>> getSharedItems(

View File

@ -8,6 +8,7 @@
package com.nextcloud.talk.api package com.nextcloud.talk.api
import com.nextcloud.talk.models.json.autocomplete.AutocompleteOverall 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.conversations.RoomOverall
import com.nextcloud.talk.models.json.generic.GenericOverall import com.nextcloud.talk.models.json.generic.GenericOverall
import com.nextcloud.talk.models.json.participants.AddParticipantOverall import com.nextcloud.talk.models.json.participants.AddParticipantOverall
@ -30,6 +31,7 @@ import retrofit2.http.Query
import retrofit2.http.QueryMap import retrofit2.http.QueryMap
import retrofit2.http.Url import retrofit2.http.Url
@Suppress("TooManyFunctions")
interface NcApiCoroutines { interface NcApiCoroutines {
@GET @GET
@JvmSuppressWildcards @JvmSuppressWildcards
@ -122,6 +124,27 @@ interface NcApiCoroutines {
@DELETE @DELETE
suspend fun unarchiveConversation(@Header("Authorization") authorization: String, @Url url: String): GenericOverall 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 @FormUrlEncoded
@POST @POST
suspend fun banActor( 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.SystemMessageInterface
import com.nextcloud.talk.adapters.messages.SystemMessageViewHolder import com.nextcloud.talk.adapters.messages.SystemMessageViewHolder
import com.nextcloud.talk.adapters.messages.TalkMessagesListAdapter 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.UnreadNoticeMessageViewHolder
import com.nextcloud.talk.adapters.messages.VoiceMessageInterface import com.nextcloud.talk.adapters.messages.VoiceMessageInterface
import com.nextcloud.talk.api.NcApi 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.MessageActionsDialog
import com.nextcloud.talk.ui.dialog.SaveToStorageDialogFragment import com.nextcloud.talk.ui.dialog.SaveToStorageDialogFragment
import com.nextcloud.talk.ui.dialog.ShowReactionsDialog 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.MessageSwipeActions
import com.nextcloud.talk.ui.recyclerview.MessageSwipeCallback import com.nextcloud.talk.ui.recyclerview.MessageSwipeCallback
import com.nextcloud.talk.utils.ApiUtils import com.nextcloud.talk.utils.ApiUtils
@ -208,7 +207,6 @@ import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import org.greenrobot.eventbus.Subscribe import org.greenrobot.eventbus.Subscribe
import org.greenrobot.eventbus.ThreadMode import org.greenrobot.eventbus.ThreadMode
import retrofit2.HttpException
import java.io.File import java.io.File
import java.io.IOException import java.io.IOException
import java.net.HttpURLConnection import java.net.HttpURLConnection
@ -231,8 +229,7 @@ class ChatActivity :
CommonMessageInterface, CommonMessageInterface,
PreviewMessageInterface, PreviewMessageInterface,
SystemMessageInterface, SystemMessageInterface,
CallStartedMessageInterface, CallStartedMessageInterface {
TemporaryMessageInterface {
var active = false var active = false
@ -319,7 +316,6 @@ class ChatActivity :
var startCallFromNotification: Boolean = false var startCallFromNotification: Boolean = false
var startCallFromRoomSwitch: Boolean = false var startCallFromRoomSwitch: Boolean = false
// lateinit var roomId: String
var voiceOnly: Boolean = true var voiceOnly: Boolean = true
private lateinit var path: String private lateinit var path: String
@ -452,6 +448,7 @@ class ChatActivity :
chatViewModel = ViewModelProvider(this, viewModelFactory)[ChatViewModel::class.java] chatViewModel = ViewModelProvider(this, viewModelFactory)[ChatViewModel::class.java]
messageInputViewModel = ViewModelProvider(this, viewModelFactory)[MessageInputViewModel::class.java] messageInputViewModel = ViewModelProvider(this, viewModelFactory)[MessageInputViewModel::class.java]
messageInputViewModel.setData(chatViewModel.getChatRepository())
this.lifecycleScope.launch { this.lifecycleScope.launch {
delay(DELAY_TO_SHOW_PROGRESS_BAR) delay(DELAY_TO_SHOW_PROGRESS_BAR)
@ -524,7 +521,6 @@ class ChatActivity :
private fun handleIntent(intent: Intent) { private fun handleIntent(intent: Intent) {
val extras: Bundle? = intent.extras val extras: Bundle? = intent.extras
// roomId = extras?.getString(KEY_ROOM_ID).orEmpty()
roomToken = extras?.getString(KEY_ROOM_TOKEN).orEmpty() roomToken = extras?.getString(KEY_ROOM_TOKEN).orEmpty()
sharedText = extras?.getString(BundleKeys.KEY_SHARED_TEXT).orEmpty() sharedText = extras?.getString(BundleKeys.KEY_SHARED_TEXT).orEmpty()
@ -583,35 +579,6 @@ class ChatActivity :
private fun initObservers() { private fun initObservers() {
Log.d(TAG, "initObservers Called") 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 { this.lifecycleScope.launch {
chatViewModel.getConversationFlow chatViewModel.getConversationFlow
.onEach { conversationModel -> .onEach { conversationModel ->
@ -719,7 +686,6 @@ class ChatActivity :
withCredentials = credentials!!, withCredentials = credentials!!,
withUrl = urlForChatting withUrl = urlForChatting
) )
messageInputViewModel.getTempMessagesFromMessageQueue(currentConversation!!.internalId)
} }
} else { } else {
Log.w( Log.w(
@ -744,7 +710,6 @@ class ChatActivity :
sessionIdAfterRoomJoined = currentConversation!!.sessionId sessionIdAfterRoomJoined = currentConversation!!.sessionId
ApplicationWideCurrentRoomHolder.getInstance().session = currentConversation!!.sessionId ApplicationWideCurrentRoomHolder.getInstance().session = currentConversation!!.sessionId
// ApplicationWideCurrentRoomHolder.getInstance().currentRoomId = currentConversation!!.roomId
ApplicationWideCurrentRoomHolder.getInstance().currentRoomToken = currentConversation!!.token ApplicationWideCurrentRoomHolder.getInstance().currentRoomToken = currentConversation!!.token
ApplicationWideCurrentRoomHolder.getInstance().userInRoom = conversationUser ApplicationWideCurrentRoomHolder.getInstance().userInRoom = conversationUser
@ -813,18 +778,7 @@ class ChatActivity :
} }
is MessageInputViewModel.SendChatMessageErrorState -> { is MessageInputViewModel.SendChatMessageErrorState -> {
if (state.e is HttpException) { binding.messagesListView.smoothScrollToPosition(0)
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)
}
}
} }
else -> {} else -> {}
@ -861,7 +815,6 @@ class ChatActivity :
is ChatViewModel.CreateRoomSuccessState -> { is ChatViewModel.CreateRoomSuccessState -> {
val bundle = Bundle() val bundle = Bundle()
bundle.putString(KEY_ROOM_TOKEN, state.roomOverall.ocs!!.data!!.token) bundle.putString(KEY_ROOM_TOKEN, state.roomOverall.ocs!!.data!!.token)
// bundle.putString(KEY_ROOM_ID, state.roomOverall.ocs!!.data!!.roomId)
leaveRoom { leaveRoom {
val chatIntent = Intent(context, ChatActivity::class.java) val chatIntent = Intent(context, ChatActivity::class.java)
@ -937,6 +890,14 @@ class ChatActivity :
.collect() .collect()
} }
this.lifecycleScope.launch {
chatViewModel.getRemoveMessageFlow
.onEach {
removeMessageById(it.id)
}
.collect()
}
this.lifecycleScope.launch { this.lifecycleScope.launch {
chatViewModel.getUpdateMessageFlow chatViewModel.getUpdateMessageFlow
.onEach { .onEach {
@ -1081,8 +1042,10 @@ class ChatActivity :
is ChatViewModel.OutOfOfficeUIState.Error -> { is ChatViewModel.OutOfOfficeUIState.Error -> {
Log.e(TAG, "Error fetching/ no user absence data", uiState.exception) Log.e(TAG, "Error fetching/ no user absence data", uiState.exception)
} }
ChatViewModel.OutOfOfficeUIState.None -> { ChatViewModel.OutOfOfficeUIState.None -> {
} }
is ChatViewModel.OutOfOfficeUIState.Success -> { is ChatViewModel.OutOfOfficeUIState.Success -> {
binding.outOfOfficeContainer.visibility = View.VISIBLE binding.outOfOfficeContainer.visibility = View.VISIBLE
@ -1171,9 +1134,25 @@ class ChatActivity :
} }
private fun removeUnreadMessagesMarker() { private fun removeUnreadMessagesMarker() {
val index = adapter?.getMessagePositionById(UNREAD_MESSAGES_MARKER_ID.toString()) removeMessageById(UNREAD_MESSAGES_MARKER_ID.toString())
if (index != null && index != -1) { }
adapter?.items?.removeAt(index)
// 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() cancelNotificationsForCurrentConversation()
chatViewModel.getRoom(conversationUser!!, roomToken) chatViewModel.getRoom(roomToken)
actionBar?.show() actionBar?.show()
@ -1238,18 +1217,18 @@ class ChatActivity :
viewThemeUtils.material.colorToolbarOverflowIcon(binding.chatToolbar) viewThemeUtils.material.colorToolbarOverflowIcon(binding.chatToolbar)
} }
private fun getLastAdapterId(): Int { // private fun getLastAdapterId(): Int {
var lastId = 0 // var lastId = 0
if (adapter?.items?.size != 0) { // if (adapter?.items?.size != 0) {
val item = adapter?.items?.get(0)?.item // val item = adapter?.items?.get(0)?.item
if (item != null) { // if (item != null) {
lastId = (item as ChatMessage).jsonMessageId // lastId = (item as ChatMessage).jsonMessageId
} else { // } else {
lastId = 0 // lastId = 0
} // }
} // }
return lastId // return lastId
} // }
private fun setupActionBar() { private fun setupActionBar() {
setSupportActionBar(binding.chatToolbar) setSupportActionBar(binding.chatToolbar)
@ -1369,17 +1348,6 @@ class ChatActivity :
R.layout.item_custom_outcoming_preview_message 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( messageHolders.registerContentType(
CONTENT_TYPE_SYSTEM_MESSAGE, CONTENT_TYPE_SYSTEM_MESSAGE,
SystemMessageViewHolder::class.java, SystemMessageViewHolder::class.java,
@ -1658,7 +1626,7 @@ class ChatActivity :
} }
getRoomInfoTimerHandler?.postDelayed( getRoomInfoTimerHandler?.postDelayed(
{ {
chatViewModel.getRoom(conversationUser!!, roomToken) chatViewModel.getRoom(roomToken)
}, },
delayForRecursiveCall delayForRecursiveCall
) )
@ -2727,7 +2695,6 @@ class ChatActivity :
) { ) {
sessionIdAfterRoomJoined = ApplicationWideCurrentRoomHolder.getInstance().session sessionIdAfterRoomJoined = ApplicationWideCurrentRoomHolder.getInstance().session
// ApplicationWideCurrentRoomHolder.getInstance().currentRoomId = roomId
ApplicationWideCurrentRoomHolder.getInstance().currentRoomToken = roomToken ApplicationWideCurrentRoomHolder.getInstance().currentRoomToken = roomToken
ApplicationWideCurrentRoomHolder.getInstance().userInRoom = conversationUser ApplicationWideCurrentRoomHolder.getInstance().userInRoom = conversationUser
} }
@ -2918,7 +2885,6 @@ class ChatActivity :
) { ) {
if (message.item is ChatMessage) { if (message.item is ChatMessage) {
val chatMessage = message.item as ChatMessage val chatMessage = message.item as ChatMessage
if (chatMessage.jsonMessageId <= xChatLastCommonRead) { if (chatMessage.jsonMessageId <= xChatLastCommonRead) {
chatMessage.readStatus = ReadStatus.READ chatMessage.readStatus = ReadStatus.READ
} else { } 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>) { private fun setUnreadMessageMarker(chatMessageList: List<ChatMessage>) {
if (chatMessageList.isNotEmpty()) { if (chatMessageList.isNotEmpty()) {
@ -3354,7 +3332,6 @@ class ChatActivity :
currentConversation?.let { currentConversation?.let {
val bundle = Bundle() val bundle = Bundle()
bundle.putString(KEY_ROOM_TOKEN, roomToken) bundle.putString(KEY_ROOM_TOKEN, roomToken)
// bundle.putString(KEY_ROOM_ID, roomId)
bundle.putString(BundleKeys.KEY_CONVERSATION_PASSWORD, roomPassword) bundle.putString(BundleKeys.KEY_CONVERSATION_PASSWORD, roomPassword)
bundle.putString(BundleKeys.KEY_MODIFIED_BASE_URL, conversationUser?.baseUrl!!) bundle.putString(BundleKeys.KEY_MODIFIED_BASE_URL, conversationUser?.baseUrl!!)
bundle.putString(KEY_CONVERSATION_NAME, it.displayName) bundle.putString(KEY_CONVERSATION_NAME, it.displayName)
@ -3423,9 +3400,14 @@ class ChatActivity :
private fun openMessageActionsDialog(iMessage: IMessage?) { private fun openMessageActionsDialog(iMessage: IMessage?) {
val message = iMessage as ChatMessage val message = iMessage as ChatMessage
if (hasVisibleItems(message) &&
!isSystemMessage(message) && if (message.isTemporary) {
message.id != "-3" TempMessageActionsDialog(
this,
message
).show()
} else if (hasVisibleItems(message) &&
!isSystemMessage(message)
) { ) {
MessageActionsDialog( MessageActionsDialog(
this, this,
@ -3849,7 +3831,6 @@ class ChatActivity :
CONTENT_TYPE_SYSTEM_MESSAGE -> !TextUtils.isEmpty(message.systemMessage) CONTENT_TYPE_SYSTEM_MESSAGE -> !TextUtils.isEmpty(message.systemMessage)
CONTENT_TYPE_UNREAD_NOTICE_MESSAGE -> message.id == UNREAD_MESSAGES_MARKER_ID.toString() CONTENT_TYPE_UNREAD_NOTICE_MESSAGE -> message.id == UNREAD_MESSAGES_MARKER_ID.toString()
CONTENT_TYPE_CALL_STARTED -> message.id == "-2" CONTENT_TYPE_CALL_STARTED -> message.id == "-2"
CONTENT_TYPE_TEMP -> message.id == "-3"
CONTENT_TYPE_DECK_CARD -> message.isDeckCard() CONTENT_TYPE_DECK_CARD -> message.isDeckCard()
else -> false else -> false
@ -3996,30 +3977,6 @@ class ChatActivity :
startACall(false, false) 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) { private fun logConversationInfos(methodName: String) {
Log.d(TAG, " |-----------------------------------------------") Log.d(TAG, " |-----------------------------------------------")
Log.d(TAG, " | method: $methodName") Log.d(TAG, " | method: $methodName")
@ -4068,9 +4025,7 @@ class ChatActivity :
private const val CONTENT_TYPE_POLL: Byte = 6 private const val CONTENT_TYPE_POLL: Byte = 6
private const val CONTENT_TYPE_LINK_PREVIEW: Byte = 7 private const val CONTENT_TYPE_LINK_PREVIEW: Byte = 7
private const val CONTENT_TYPE_DECK_CARD: Byte = 8 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 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_NORMAL: Long = 30000
private const val GET_ROOM_INFO_DELAY_LOBBY: Long = 5000 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) 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 RESUME_AUDIO_TAG = "RESUME_AUDIO_TAG"
private const val DELAY_TO_SHOW_PROGRESS_BAR = 1000L private const val DELAY_TO_SHOW_PROGRESS_BAR = 1000L
private const val FIVE_MINUTES_IN_SECONDS: Long = 300 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 ROOM_TYPE_ONE_TO_ONE = "1"
private const val ACTOR_TYPE = "users" private const val ACTOR_TYPE = "users"
const val CONVERSATION_INTERNAL_ID = "CONVERSATION_INTERNAL_ID" const val CONVERSATION_INTERNAL_ID = "CONVERSATION_INTERNAL_ID"

View File

@ -158,7 +158,6 @@ class MessageInputFragment : Fragment() {
override fun onResume() { override fun onResume() {
super.onResume() super.onResume()
chatActivity.messageInputViewModel.restoreMessageQueue(conversationInternalId)
} }
override fun onDestroyView() { override fun onDestroyView() {
@ -199,19 +198,20 @@ class MessageInputFragment : Fragment() {
wasOnline = !binding.fragmentConnectionLost.isShown wasOnline = !binding.fragmentConnectionLost.isShown
val connectionGained = (!wasOnline && isOnline) val connectionGained = (!wasOnline && isOnline)
Log.d(TAG, "isOnline: $isOnline\nwasOnline: $wasOnline\nconnectionGained: $connectionGained") 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) handleUI(isOnline, connectionGained)
}.collect() }.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) { chatActivity.messageInputViewModel.callStartedFlow.observe(viewLifecycleOwner) {
val (message, show) = it val (message, show) = it
if (show) { 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() { private fun restoreState() {
if (binding.fragmentMessageInputView.inputEditText.text.isEmpty()) { if (binding.fragmentMessageInputView.inputEditText.text.isEmpty()) {
requireContext().getSharedPreferences(chatActivity.localClassName, AppCompatActivity.MODE_PRIVATE).apply { requireContext().getSharedPreferences(chatActivity.localClassName, AppCompatActivity.MODE_PRIVATE).apply {
@ -868,7 +851,7 @@ class MessageInputFragment : Fragment() {
.findViewById<RelativeLayout>(R.id.quotedChatMessageView)?.tag as Int? ?: 0 .findViewById<RelativeLayout>(R.id.quotedChatMessageView)?.tag as Int? ?: 0
sendMessage( sendMessage(
editable, editable.toString(),
replyMessageId, replyMessageId,
sendWithoutNotification 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( chatActivity.messageInputViewModel.sendChatMessage(
conversationInternalId,
chatActivity.conversationUser!!.getCredentials(), chatActivity.conversationUser!!.getCredentials(),
ApiUtils.getUrlForChat( ApiUtils.getUrlForChat(
chatActivity.chatApiVersion, chatActivity.chatApiVersion,
@ -917,16 +899,23 @@ class MessageInputFragment : Fragment() {
// FIXME Fix API checking with guests? // FIXME Fix API checking with guests?
val apiVersion: Int = ApiUtils.getChatApiVersion(chatActivity.spreedCapabilities, intArrayOf(1)) val apiVersion: Int = ApiUtils.getChatApiVersion(chatActivity.spreedCapabilities, intArrayOf(1))
chatActivity.messageInputViewModel.editChatMessage( if (message.isTemporary) {
chatActivity.credentials!!, chatActivity.messageInputViewModel.editTempChatMessage(
ApiUtils.getUrlForChatMessage( message,
apiVersion, editedMessageText
chatActivity.conversationUser!!.baseUrl!!, )
chatActivity.roomToken, } else {
message.id chatActivity.messageInputViewModel.editChatMessage(
), chatActivity.credentials!!,
editedMessageText ApiUtils.getUrlForChatMessage(
) apiVersion,
chatActivity.conversationUser!!.baseUrl!!,
chatActivity.roomToken,
message.id
),
editedMessageText
)
}
} }
private fun setEditUI(message: ChatMessage) { 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.io.LifecycleAwareManager
import com.nextcloud.talk.chat.data.model.ChatMessage import com.nextcloud.talk.chat.data.model.ChatMessage
import com.nextcloud.talk.models.domain.ConversationModel import com.nextcloud.talk.models.domain.ConversationModel
import com.nextcloud.talk.models.json.chat.ChatOverallSingleMessage
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
@ -41,6 +42,8 @@ interface ChatMessageRepository : LifecycleAwareManager {
*/ */
val generalUIFlow: Flow<String> val generalUIFlow: Flow<String>
val removeMessageFlow: Flow<ChatMessage>
fun setData(conversationModel: ConversationModel, credentials: String, urlForChatting: String) fun setData(conversationModel: ConversationModel, credentials: String, urlForChatting: String)
fun loadInitialMessages(withNetworkParams: Bundle): Job fun loadInitialMessages(withNetworkParams: Bundle): Job
@ -75,4 +78,42 @@ interface ChatMessageRepository : LifecycleAwareManager {
* Destroys unused resources. * Destroys unused resources.
*/ */
fun handleChatOnBackPress() 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 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 var extractedUrlToPreview: String? = null
@ -240,8 +245,8 @@ data class ChatMessage(
} }
} }
fun getCalculateMessageType(): MessageType { fun getCalculateMessageType(): MessageType =
return if (!TextUtils.isEmpty(systemMessage)) { if (!TextUtils.isEmpty(systemMessage)) {
MessageType.SYSTEM_MESSAGE MessageType.SYSTEM_MESSAGE
} else if (isVoiceMessage) { } else if (isVoiceMessage) {
MessageType.VOICE_MESSAGE MessageType.VOICE_MESSAGE
@ -256,19 +261,15 @@ data class ChatMessage(
} else { } else {
MessageType.REGULAR_TEXT_MESSAGE MessageType.REGULAR_TEXT_MESSAGE
} }
}
override fun getId(): String { override fun getId(): String = jsonMessageId.toString()
return jsonMessageId.toString()
}
override fun getText(): String { override fun getText(): String =
return if (message != null) { if (message != null) {
getParsedMessage(message, messageParameters)!! getParsedMessage(message, messageParameters)!!
} else { } else {
"" ""
} }
}
fun getNullsafeActorDisplayName() = fun getNullsafeActorDisplayName() =
if (!TextUtils.isEmpty(actorDisplayName)) { if (!TextUtils.isEmpty(actorDisplayName)) {
@ -277,22 +278,19 @@ data class ChatMessage(
sharedApplication!!.getString(R.string.nc_guest) sharedApplication!!.getString(R.string.nc_guest)
} }
override fun getUser(): IUser { override fun getUser(): IUser =
return object : IUser { object : IUser {
override fun getId(): String { override fun getId(): String = "$actorType/$actorId"
return "$actorType/$actorId"
}
override fun getName(): String { override fun getName(): String =
return if (!TextUtils.isEmpty(actorDisplayName)) { if (!TextUtils.isEmpty(actorDisplayName)) {
actorDisplayName!! actorDisplayName!!
} else { } else {
sharedApplication!!.getString(R.string.nc_guest) sharedApplication!!.getString(R.string.nc_guest)
} }
}
override fun getAvatar(): String? { override fun getAvatar(): String? =
return when { when {
activeUser == null -> { activeUser == null -> {
null null
} }
@ -317,21 +315,14 @@ data class ChatMessage(
ApiUtils.getUrlForGuestAvatar(activeUser!!.baseUrl!!, apiId, true) ApiUtils.getUrlForGuestAvatar(activeUser!!.baseUrl!!, apiId, true)
} }
} }
}
} }
}
override fun getCreatedAt(): Date { override fun getCreatedAt(): Date = Date(timestamp * MILLIES)
return Date(timestamp * MILLIES)
}
override fun getSystemMessage(): String { override fun getSystemMessage(): String = EnumSystemMessageTypeConverter().convertToString(systemMessageType)
return EnumSystemMessageTypeConverter().convertToString(systemMessageType)
}
private fun isHashMapEntryEqualTo(map: HashMap<String?, String?>, key: String, searchTerm: String): Boolean { private fun isHashMapEntryEqualTo(map: HashMap<String?, String?>, key: String, searchTerm: String): Boolean =
return map != null && MessageDigest.isEqual(map[key]!!.toByteArray(), searchTerm.toByteArray()) map != null && MessageDigest.isEqual(map[key]!!.toByteArray(), searchTerm.toByteArray())
}
// needed a equals and hashcode function to fix detekt errors // needed a equals and hashcode function to fix detekt errors
override fun equals(other: Any?): Boolean { override fun equals(other: Any?): Boolean {
@ -340,9 +331,7 @@ data class ChatMessage(
return false return false
} }
override fun hashCode(): Int { override fun hashCode(): Int = 0
return 0
}
val isVoiceMessage: Boolean val isVoiceMessage: Boolean
get() = "voice-message" == messageType get() = "voice-message" == messageType

View File

@ -38,7 +38,7 @@ interface ChatNetworkDataSource {
url: String, url: String,
message: String, message: String,
displayName: String displayName: String
): Observable<GenericOverall> // last two fields are false ): Observable<ChatOverallSingleMessage>
fun checkForNoteToSelf(credentials: String, url: String, includeStatus: Boolean): Observable<RoomsOverall> fun checkForNoteToSelf(credentials: String, url: String, includeStatus: Boolean): Observable<RoomsOverall>
fun shareLocationToNotes( fun shareLocationToNotes(
@ -50,19 +50,20 @@ interface ChatNetworkDataSource {
): Observable<GenericOverall> ): Observable<GenericOverall>
fun leaveRoom(credentials: String, url: String): Observable<GenericOverall> fun leaveRoom(credentials: String, url: String): Observable<GenericOverall>
fun sendChatMessage( suspend fun sendChatMessage(
credentials: String, credentials: String,
url: String, url: String,
message: CharSequence, message: String,
displayName: String, displayName: String,
replyTo: Int, replyTo: Int,
sendWithoutNotification: Boolean sendWithoutNotification: Boolean,
): Observable<GenericOverall> referenceId: String
): ChatOverallSingleMessage
fun pullChatMessages(credentials: String, url: String, fieldMap: HashMap<String, Int>): Observable<Response<*>> fun pullChatMessages(credentials: String, url: String, fieldMap: HashMap<String, Int>): Observable<Response<*>>
fun deleteChatMessage(credentials: String, url: String): Observable<ChatOverallSingleMessage> fun deleteChatMessage(credentials: String, url: String): Observable<ChatOverallSingleMessage>
fun createRoom(credentials: String, url: String, map: Map<String, String>): Observable<RoomOverall> fun createRoom(credentials: String, url: String, map: Map<String, String>): Observable<RoomOverall>
fun setChatReadMarker(credentials: String, url: String, previousMessageId: Int): Observable<GenericOverall> 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 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.database.model.ChatMessageEntity
import com.nextcloud.talk.data.network.NetworkMonitor import com.nextcloud.talk.data.network.NetworkMonitor
import com.nextcloud.talk.data.user.model.User 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.domain.ConversationModel
import com.nextcloud.talk.models.json.chat.ChatMessageJson import com.nextcloud.talk.models.json.chat.ChatMessageJson
import com.nextcloud.talk.models.json.chat.ChatOverall 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.bundle.BundleKeys
import com.nextcloud.talk.utils.database.user.CurrentUserProviderNew 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.android.schedulers.AndroidSchedulers
import io.reactivex.schedulers.Schedulers import io.reactivex.schedulers.Schedulers
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
@ -36,18 +40,22 @@ import kotlinx.coroutines.cancel
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.retryWhen
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import java.io.IOException
import javax.inject.Inject import javax.inject.Inject
@Suppress("LargeClass", "TooManyFunctions")
class OfflineFirstChatRepository @Inject constructor( class OfflineFirstChatRepository @Inject constructor(
private val chatDao: ChatMessagesDao, private val chatDao: ChatMessagesDao,
private val chatBlocksDao: ChatBlocksDao, private val chatBlocksDao: ChatBlocksDao,
private val network: ChatNetworkDataSource, private val network: ChatNetworkDataSource,
private val datastore: AppPreferences,
private val monitor: NetworkMonitor, private val monitor: NetworkMonitor,
private val userProvider: CurrentUserProviderNew userProvider: CurrentUserProviderNew
) : ChatMessageRepository { ) : ChatMessageRepository {
val currentUser: User = userProvider.currentUser.blockingGet() val currentUser: User = userProvider.currentUser.blockingGet()
@ -71,8 +79,7 @@ class OfflineFirstChatRepository @Inject constructor(
> >
> = MutableSharedFlow() > = MutableSharedFlow()
override val updateMessageFlow: override val updateMessageFlow: Flow<ChatMessage>
Flow<ChatMessage>
get() = _updateMessageFlow get() = _updateMessageFlow
private val _updateMessageFlow: private val _updateMessageFlow:
@ -85,8 +92,7 @@ class OfflineFirstChatRepository @Inject constructor(
private val _lastCommonReadFlow: private val _lastCommonReadFlow:
MutableSharedFlow<Int> = MutableSharedFlow() MutableSharedFlow<Int> = MutableSharedFlow()
override val lastReadMessageFlow: override val lastReadMessageFlow: Flow<Int>
Flow<Int>
get() = _lastReadMessageFlow get() = _lastReadMessageFlow
private val _lastReadMessageFlow: private val _lastReadMessageFlow:
@ -97,6 +103,12 @@ class OfflineFirstChatRepository @Inject constructor(
private val _generalUIFlow: MutableSharedFlow<String> = MutableSharedFlow() 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 newXChatLastCommonRead: Int? = null
private var itIsPaused = false private var itIsPaused = false
private val scope = CoroutineScope(Dispatchers.IO) private val scope = CoroutineScope(Dispatchers.IO)
@ -169,26 +181,39 @@ class OfflineFirstChatRepository @Inject constructor(
Log.d(TAG, "newestMessageIdFromDb after sync: $newestMessageIdFromDb") Log.d(TAG, "newestMessageIdFromDb after sync: $newestMessageIdFromDb")
} }
if (newestMessageIdFromDb.toInt() != 0) { handleMessagesFromDb(newestMessageIdFromDb)
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)
}
initMessagePolling(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 { private suspend fun getCappedMessagesAmountOfChatBlock(messageId: Long): Int {
val chatBlock = getBlockOfMessage(messageId.toInt()) val chatBlock = getBlockOfMessage(messageId.toInt())
@ -293,8 +318,11 @@ class OfflineFirstChatRepository @Inject constructor(
val weHaveMessagesFromOurself = chatMessages.any { it.actorId == currentUser.userId } val weHaveMessagesFromOurself = chatMessages.any { it.actorId == currentUser.userId }
showUnreadMessagesMarker = showUnreadMessagesMarker && !weHaveMessagesFromOurself showUnreadMessagesMarker = showUnreadMessagesMarker && !weHaveMessagesFromOurself
val triple = Triple(true, showUnreadMessagesMarker, chatMessages) handleNewAndTempMessages(
_messageFlow.emit(triple) receivedChatMessages = chatMessages,
lookIntoFuture = true,
showUnreadMessagesMarker = showUnreadMessagesMarker
)
} else { } else {
Log.d(TAG, "resultsFromSync are null or empty") 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 { private suspend fun hasToLoadPreviousMessagesFromServer(beforeMessageId: Long): Boolean {
val loadFromServer: 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(
suspend fun getMessagesBeforeAndEqual( messageId: Long,
messageId: Long, internalConversationId: String,
internalConversationId: String, messageLimit: Int
messageLimit: Int ): List<ChatMessage> =
): List<ChatMessage> = chatDao.getMessagesForConversationBeforeAndEqual(
chatDao.getMessagesForConversationBeforeAndEqual(
internalConversationId,
messageId,
messageLimit
).map {
it.map(ChatMessageEntity::asModel)
}.first()
val list = getMessagesBeforeAndEqual(
messageId,
internalConversationId, internalConversationId,
limit messageId,
) messageLimit
).map {
if (list.isNotEmpty()) { it.map(ChatMessageEntity::asModel)
val triple = Triple(false, false, list) }.first()
_messageFlow.emit(triple)
}
}
private suspend fun showMessagesBefore(internalConversationId: String, messageId: Long, limit: Int) { private suspend fun showMessagesBefore(internalConversationId: String, messageId: Long, limit: Int) {
suspend fun getMessagesBefore( suspend fun getMessagesBefore(
@ -752,6 +800,227 @@ class OfflineFirstChatRepository @Inject constructor(
scope.cancel() 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 { companion object {
val TAG = OfflineFirstChatRepository::class.simpleName val TAG = OfflineFirstChatRepository::class.simpleName
private const val HTTP_CODE_OK: Int = 200 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 HALF_SECOND = 500L
private const val DELAY_TO_ENSURE_MESSAGES_ARE_ADDED: Long = 100 private const val DELAY_TO_ENSURE_MESSAGES_ARE_ADDED: Long = 100
private const val DEFAULT_MESSAGES_LIMIT = 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.reminder.Reminder
import com.nextcloud.talk.models.json.userAbsence.UserAbsenceOverall import com.nextcloud.talk.models.json.userAbsence.UserAbsenceOverall
import com.nextcloud.talk.utils.ApiUtils import com.nextcloud.talk.utils.ApiUtils
import com.nextcloud.talk.utils.message.SendMessageUtils
import io.reactivex.Observable import io.reactivex.Observable
import retrofit2.Response import retrofit2.Response
@ -108,26 +109,24 @@ class RetrofitChatNetwork(
url: String, url: String,
message: String, message: String,
displayName: String displayName: String
): Observable<GenericOverall> { ): Observable<ChatOverallSingleMessage> =
return ncApi.sendChatMessage( ncApi.sendChatMessage(
credentials, credentials,
url, url,
message, message,
displayName, displayName,
null, null,
false false,
SendMessageUtils().generateReferenceId()
).map { ).map {
it it
} }
}
override fun checkForNoteToSelf( override fun checkForNoteToSelf(
credentials: String, credentials: String,
url: String, url: String,
includeStatus: Boolean includeStatus: Boolean
): Observable<RoomsOverall> { ): Observable<RoomsOverall> = ncApi.getRooms(credentials, url, includeStatus).map { it }
return ncApi.getRooms(credentials, url, includeStatus).map { it }
}
override fun shareLocationToNotes( override fun shareLocationToNotes(
credentials: String, credentials: String,
@ -135,54 +134,56 @@ class RetrofitChatNetwork(
objectType: String, objectType: String,
objectId: String, objectId: String,
metadata: String metadata: String
): Observable<GenericOverall> { ): Observable<GenericOverall> = ncApi.sendLocation(credentials, url, objectType, objectId, metadata).map { it }
return ncApi.sendLocation(credentials, url, objectType, objectId, metadata).map { it }
}
override fun leaveRoom(credentials: String, url: String): Observable<GenericOverall> { override fun leaveRoom(credentials: String, url: String): Observable<GenericOverall> =
return ncApi.leaveRoom(credentials, url).map { it } ncApi.leaveRoom(credentials, url).map {
}
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 {
it 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( override fun pullChatMessages(
credentials: String, credentials: String,
url: String, url: String,
fieldMap: HashMap<String, Int> fieldMap: HashMap<String, Int>
): Observable<Response<*>> { ): Observable<Response<*>> = ncApi.pullChatMessages(credentials, url, fieldMap).map { it }
return ncApi.pullChatMessages(credentials, url, fieldMap).map { it }
}
override fun deleteChatMessage(credentials: String, url: String): Observable<ChatOverallSingleMessage> { override fun deleteChatMessage(credentials: String, url: String): Observable<ChatOverallSingleMessage> =
return ncApi.deleteChatMessage(credentials, url).map { it } ncApi.deleteChatMessage(credentials, url).map {
} it
}
override fun createRoom(credentials: String, url: String, map: Map<String, String>): Observable<RoomOverall> { override fun createRoom(credentials: String, url: String, map: Map<String, String>): Observable<RoomOverall> =
return ncApi.createRoom(credentials, url, map).map { it } ncApi.createRoom(credentials, url, map).map {
} it
}
override fun setChatReadMarker( override fun setChatReadMarker(
credentials: String, credentials: String,
url: String, url: String,
previousMessageId: Int previousMessageId: Int
): Observable<GenericOverall> { ): Observable<GenericOverall> = ncApi.setChatReadMarker(credentials, url, previousMessageId).map { it }
return ncApi.setChatReadMarker(credentials, url, previousMessageId).map { it }
}
override fun editChatMessage(credentials: String, url: String, text: String): Observable<ChatOverallSingleMessage> { override suspend fun editChatMessage(credentials: String, url: String, text: String): ChatOverallSingleMessage =
return ncApi.editChatMessage(credentials, url, text).map { it } ncApiCoroutines.editChatMessage(credentials, url, text)
}
override suspend fun getOutOfOfficeStatusForUser( override suspend fun getOutOfOfficeStatusForUser(
credentials: String, 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.chat.data.network.ChatNetworkDataSource
import com.nextcloud.talk.conversationlist.data.OfflineConversationsRepository import com.nextcloud.talk.conversationlist.data.OfflineConversationsRepository
import com.nextcloud.talk.data.user.model.User import com.nextcloud.talk.data.user.model.User
import com.nextcloud.talk.extensions.toIntOrZero
import com.nextcloud.talk.jobs.UploadAndShareFilesWorker import com.nextcloud.talk.jobs.UploadAndShareFilesWorker
import com.nextcloud.talk.models.domain.ConversationModel import com.nextcloud.talk.models.domain.ConversationModel
import com.nextcloud.talk.models.domain.ReactionAddedModel import com.nextcloud.talk.models.domain.ReactionAddedModel
@ -63,7 +64,8 @@ class ChatViewModel @Inject constructor(
private val mediaRecorderManager: MediaRecorderManager, private val mediaRecorderManager: MediaRecorderManager,
private val audioFocusRequestManager: AudioFocusRequestManager, private val audioFocusRequestManager: AudioFocusRequestManager,
private val userProvider: CurrentUserProviderNew private val userProvider: CurrentUserProviderNew
) : ViewModel(), DefaultLifecycleObserver { ) : ViewModel(),
DefaultLifecycleObserver {
enum class LifeCycleFlag { enum class LifeCycleFlag {
PAUSED, PAUSED,
@ -74,6 +76,10 @@ class ChatViewModel @Inject constructor(
lateinit var currentLifeCycleFlag: LifeCycleFlag lateinit var currentLifeCycleFlag: LifeCycleFlag
val disposableSet = mutableSetOf<Disposable>() val disposableSet = mutableSetOf<Disposable>()
fun getChatRepository(): ChatMessageRepository {
return chatRepository
}
override fun onResume(owner: LifecycleOwner) { override fun onResume(owner: LifecycleOwner) {
super.onResume(owner) super.onResume(owner)
currentLifeCycleFlag = LifeCycleFlag.RESUMED currentLifeCycleFlag = LifeCycleFlag.RESUMED
@ -131,6 +137,8 @@ class ChatViewModel @Inject constructor(
_chatMessageViewState.value = ChatMessageErrorState _chatMessageViewState.value = ChatMessageErrorState
} }
val getRemoveMessageFlow = chatRepository.removeMessageFlow
val getUpdateMessageFlow = chatRepository.updateMessageFlow val getUpdateMessageFlow = chatRepository.updateMessageFlow
val getLastCommonReadFlow = chatRepository.lastCommonReadFlow val getLastCommonReadFlow = chatRepository.lastCommonReadFlow
@ -239,14 +247,9 @@ class ChatViewModel @Inject constructor(
chatRepository.setData(conversationModel, credentials, urlForChatting) chatRepository.setData(conversationModel, credentials, urlForChatting)
} }
fun getRoom(user: User, token: String) { fun getRoom(token: String) {
_getRoomViewState.value = GetRoomStartState _getRoomViewState.value = GetRoomStartState
conversationRepository.getRoom(token) conversationRepository.getRoom(token)
// chatNetworkDataSource.getRoom(user, token)
// .subscribeOn(Schedulers.io())
// ?.observeOn(AndroidSchedulers.mainThread())
// ?.subscribe(GetRoomObserver())
} }
fun getCapabilities(user: User, token: String, conversationModel: ConversationModel) { fun getCapabilities(user: User, token: String, conversationModel: ConversationModel) {
@ -478,12 +481,12 @@ class ChatViewModel @Inject constructor(
chatNetworkDataSource.shareToNotes(credentials, url, message, displayName) chatNetworkDataSource.shareToNotes(credentials, url, message, displayName)
.subscribeOn(Schedulers.io()) .subscribeOn(Schedulers.io())
?.observeOn(AndroidSchedulers.mainThread()) ?.observeOn(AndroidSchedulers.mainThread())
?.subscribe(object : Observer<GenericOverall> { ?.subscribe(object : Observer<ChatOverallSingleMessage> {
override fun onSubscribe(d: Disposable) { override fun onSubscribe(d: Disposable) {
disposableSet.add(d) disposableSet.add(d)
} }
override fun onNext(genericOverall: GenericOverall) { override fun onNext(genericOverall: ChatOverallSingleMessage) {
// unused atm // unused atm
} }
@ -609,9 +612,7 @@ class ChatViewModel @Inject constructor(
cachedFile.delete() cachedFile.delete()
} }
fun getCurrentVoiceRecordFile(): String { fun getCurrentVoiceRecordFile(): String = mediaRecorderManager.currentVoiceRecordFile
return mediaRecorderManager.currentVoiceRecordFile
}
fun uploadFile(fileUri: String, room: String, displayName: String, metaData: String) { fun uploadFile(fileUri: String, room: String, displayName: String, metaData: String) {
try { try {
@ -650,7 +651,7 @@ class ChatViewModel @Inject constructor(
chatRepository.handleChatOnBackPress() chatRepository.handleChatOnBackPress()
} }
suspend fun getMessageById(url: String, conversationModel: ConversationModel, messageId: Long): Flow<ChatMessage> = fun getMessageById(url: String, conversationModel: ConversationModel, messageId: Long): Flow<ChatMessage> =
flow { flow {
val bundle = Bundle() val bundle = Bundle()
bundle.putString(BundleKeys.KEY_CHAT_URL, url) bundle.putString(BundleKeys.KEY_CHAT_URL, url)
@ -671,25 +672,6 @@ class ChatViewModel @Inject constructor(
fun getPlaybackSpeedPreference(message: ChatMessage) = fun getPlaybackSpeedPreference(message: ChatMessage) =
_voiceMessagePlaybackSpeedPreferences.value?.get(message.user.id) ?: PlaybackSpeed.NORMAL _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> { inner class JoinRoomObserver : Observer<ConversationModel> {
override fun onSubscribe(d: Disposable) { override fun onSubscribe(d: Disposable) {
disposableSet.add(d) 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 { companion object {
private val TAG = ChatViewModel::class.simpleName private val TAG = ChatViewModel::class.simpleName
const val JOIN_ROOM_RETRY_COUNT: Long = 3 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.LiveData
import androidx.lifecycle.MutableLiveData import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel 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.AudioFocusRequestManager
import com.nextcloud.talk.chat.data.io.AudioRecorderManager import com.nextcloud.talk.chat.data.io.AudioRecorderManager
import com.nextcloud.talk.chat.data.io.MediaPlayerManager import com.nextcloud.talk.chat.data.io.MediaPlayerManager
import com.nextcloud.talk.chat.data.model.ChatMessage 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.chat.ChatOverallSingleMessage
import com.nextcloud.talk.models.json.generic.GenericOverall import com.nextcloud.talk.utils.message.SendMessageUtils
import com.nextcloud.talk.utils.preferences.AppPreferences
import com.stfalcon.chatkit.commons.models.IMessage 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.disposables.Disposable
import io.reactivex.schedulers.Schedulers import kotlinx.coroutines.launch
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.update
import java.lang.Thread.sleep
import javax.inject.Inject import javax.inject.Inject
@Suppress("Detekt.TooManyFunctions")
class MessageInputViewModel @Inject constructor( class MessageInputViewModel @Inject constructor(
private val chatNetworkDataSource: ChatNetworkDataSource,
private val audioRecorderManager: AudioRecorderManager, private val audioRecorderManager: AudioRecorderManager,
private val mediaPlayerManager: MediaPlayerManager, private val mediaPlayerManager: MediaPlayerManager,
private val audioFocusRequestManager: AudioFocusRequestManager, private val audioFocusRequestManager: AudioFocusRequestManager
private val appPreferences: AppPreferences ) : ViewModel(),
) : ViewModel(), DefaultLifecycleObserver { DefaultLifecycleObserver {
enum class LifeCycleFlag { enum class LifeCycleFlag {
PAUSED, PAUSED,
RESUMED, RESUMED,
STOPPED STOPPED
} }
lateinit var chatRepository: ChatMessageRepository
lateinit var currentLifeCycleFlag: LifeCycleFlag lateinit var currentLifeCycleFlag: LifeCycleFlag
val disposableSet = mutableSetOf<Disposable>() val disposableSet = mutableSetOf<Disposable>()
data class QueuedMessage( fun setData(chatMessageRepository: ChatMessageRepository) {
val id: Int, chatRepository = chatMessageRepository
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()
override fun onResume(owner: LifecycleOwner) { override fun onResume(owner: LifecycleOwner) {
super.onResume(owner) super.onResume(owner)
@ -106,11 +96,12 @@ class MessageInputViewModel @Inject constructor(
sealed interface ViewState sealed interface ViewState
object SendChatMessageStartState : ViewState object SendChatMessageStartState : ViewState
class SendChatMessageSuccessState(val message: CharSequence) : 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) private val _sendChatMessageViewState: MutableLiveData<ViewState> = MutableLiveData(SendChatMessageStartState)
val sendChatMessageViewState: LiveData<ViewState> val sendChatMessageViewState: LiveData<ViewState>
get() = _sendChatMessageViewState get() = _sendChatMessageViewState
object EditMessageStartState : ViewState
object EditMessageErrorState : ViewState object EditMessageErrorState : ViewState
class EditMessageSuccessState(val messageEdited: ChatOverallSingleMessage) : ViewState class EditMessageSuccessState(val messageEdited: ChatOverallSingleMessage) : ViewState
@ -122,89 +113,93 @@ class MessageInputViewModel @Inject constructor(
val isVoicePreviewPlaying: LiveData<Boolean> val isVoicePreviewPlaying: LiveData<Boolean>
get() = _isVoicePreviewPlaying 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() private val _callStartedFlow: MutableLiveData<Pair<ChatMessage, Boolean>> = MutableLiveData()
val callStartedFlow: LiveData<Pair<ChatMessage, Boolean>> val callStartedFlow: LiveData<Pair<ChatMessage, Boolean>>
get() = _callStartedFlow get() = _callStartedFlow
@Suppress("LongParameterList") @Suppress("LongParameterList")
fun sendChatMessage( fun sendChatMessage(
internalId: String,
credentials: String, credentials: String,
url: String, url: String,
message: CharSequence, message: String,
displayName: String, displayName: String,
replyTo: Int, replyTo: Int,
sendWithoutNotification: Boolean sendWithoutNotification: Boolean
) { ) {
if (isQueueing) { val referenceId = SendMessageUtils().generateReferenceId()
val tempID = System.currentTimeMillis().toInt() Log.d(TAG, "Random SHA-256 Hash: $referenceId")
val qMsg = QueuedMessage(tempID, message, displayName, replyTo, sendWithoutNotification)
messageQueue = appPreferences.getMessageQueue(internalId) viewModelScope.launch {
messageQueue.add(qMsg) chatRepository.addTemporaryMessage(
appPreferences.saveMessageQueue(internalId, messageQueue) message,
_messageQueueSizeFlow.update { messageQueue.size } displayName,
_messageQueueFlow.postValue(listOf(qMsg)) replyTo,
return 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( viewModelScope.launch {
credentials, chatRepository.sendChatMessage(
url, credentials,
message, url,
displayName, message,
replyTo, displayName,
sendWithoutNotification replyTo,
).subscribeOn(Schedulers.io()) sendWithoutNotification,
?.observeOn(AndroidSchedulers.mainThread()) referenceId
?.subscribe(object : Observer<GenericOverall> { ).collect { result ->
override fun onSubscribe(d: Disposable) { if (result.isSuccess) {
disposableSet.add(d) 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) _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) { fun editChatMessage(credentials: String, url: String, text: String) {
chatNetworkDataSource.editChatMessage(credentials, url, text) viewModelScope.launch {
.subscribeOn(Schedulers.io()) chatRepository.editChatMessage(
?.observeOn(AndroidSchedulers.mainThread()) credentials,
?.subscribe(object : Observer<ChatOverallSingleMessage> { url,
override fun onSubscribe(d: Disposable) { text
disposableSet.add(d) ).collect { result ->
} if (result.isSuccess) {
_editMessageViewState.value = EditMessageSuccessState(result.getOrNull()!!)
override fun onError(e: Throwable) { } else {
Log.e(TAG, "failed to edit message", e)
_editMessageViewState.value = EditMessageErrorState _editMessageViewState.value = EditMessageErrorState
} }
}
}
}
override fun onComplete() { fun editTempChatMessage(message: ChatMessage, editedMessageText: String) {
// unused atm viewModelScope.launch {
} chatRepository.editTempChatMessage(
message,
override fun onNext(messageEdited: ChatOverallSingleMessage) { editedMessageText
_editMessageViewState.value = EditMessageSuccessState(messageEdited) ).collect {}
} }
})
} }
fun reply(message: IMessage?) { fun reply(message: IMessage?) {
@ -256,75 +251,11 @@ class MessageInputViewModel @Inject constructor(
_getRecordingTime.postValue(time) _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) { fun showCallStartedIndicator(recent: ChatMessage, show: Boolean) {
_callStartedFlow.postValue(Pair(recent, show)) _callStartedFlow.postValue(Pair(recent, show))
} }
companion object { companion object {
private val TAG = MessageInputViewModel::class.java.simpleName 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 android.util.Log
import com.nextcloud.talk.chat.data.network.ChatNetworkDataSource 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.conversationlist.data.OfflineConversationsRepository
import com.nextcloud.talk.data.database.dao.ConversationsDao import com.nextcloud.talk.data.database.dao.ConversationsDao
import com.nextcloud.talk.data.database.mappers.asEntity import com.nextcloud.talk.data.database.mappers.asEntity
@ -107,7 +106,7 @@ class OfflineFirstConversationsRepository @Inject constructor(
var conversationsFromSync: List<ConversationEntity>? = null var conversationsFromSync: List<ConversationEntity>? = null
if (!monitor.isOnline.first()) { 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 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.users.UserManager
import com.nextcloud.talk.utils.DateUtils import com.nextcloud.talk.utils.DateUtils
import com.nextcloud.talk.utils.database.user.CurrentUserProviderNew import com.nextcloud.talk.utils.database.user.CurrentUserProviderNew
import com.nextcloud.talk.utils.preferences.AppPreferences
import dagger.Module import dagger.Module
import dagger.Provides import dagger.Provides
import okhttp3.OkHttpClient import okhttp3.OkHttpClient
@ -74,87 +73,66 @@ class RepositoryModule {
ncApi: NcApi, ncApi: NcApi,
ncApiCoroutines: NcApiCoroutines, ncApiCoroutines: NcApiCoroutines,
userProvider: CurrentUserProviderNew userProvider: CurrentUserProviderNew
): ConversationsRepository { ): ConversationsRepository = ConversationsRepositoryImpl(ncApi, ncApiCoroutines, userProvider)
return ConversationsRepositoryImpl(ncApi, ncApiCoroutines, userProvider)
}
@Provides @Provides
fun provideSharedItemsRepository(ncApi: NcApi, dateUtils: DateUtils): SharedItemsRepository { fun provideSharedItemsRepository(ncApi: NcApi, dateUtils: DateUtils): SharedItemsRepository =
return SharedItemsRepositoryImpl(ncApi, dateUtils) SharedItemsRepositoryImpl(ncApi, dateUtils)
}
@Provides @Provides
fun provideUnifiedSearchRepository(ncApi: NcApi, userProvider: CurrentUserProviderNew): UnifiedSearchRepository { fun provideUnifiedSearchRepository(ncApi: NcApi, userProvider: CurrentUserProviderNew): UnifiedSearchRepository =
return UnifiedSearchRepositoryImpl(ncApi, userProvider) UnifiedSearchRepositoryImpl(ncApi, userProvider)
}
@Provides @Provides
fun provideDialogPollRepository(ncApi: NcApi, userProvider: CurrentUserProviderNew): PollRepository { fun provideDialogPollRepository(ncApi: NcApi, userProvider: CurrentUserProviderNew): PollRepository =
return PollRepositoryImpl(ncApi, userProvider) PollRepositoryImpl(ncApi, userProvider)
}
@Provides @Provides
fun provideRemoteFileBrowserItemsRepository( fun provideRemoteFileBrowserItemsRepository(
okHttpClient: OkHttpClient, okHttpClient: OkHttpClient,
userProvider: CurrentUserProviderNew userProvider: CurrentUserProviderNew
): RemoteFileBrowserItemsRepository { ): RemoteFileBrowserItemsRepository = RemoteFileBrowserItemsRepositoryImpl(okHttpClient, userProvider)
return RemoteFileBrowserItemsRepositoryImpl(okHttpClient, userProvider)
}
@Provides @Provides
fun provideUsersRepository(database: TalkDatabase): UsersRepository { fun provideUsersRepository(database: TalkDatabase): UsersRepository = UsersRepositoryImpl(database.usersDao())
return UsersRepositoryImpl(database.usersDao())
}
@Provides @Provides
fun provideArbitraryStoragesRepository(database: TalkDatabase): ArbitraryStoragesRepository { fun provideArbitraryStoragesRepository(database: TalkDatabase): ArbitraryStoragesRepository =
return ArbitraryStoragesRepositoryImpl(database.arbitraryStoragesDao()) ArbitraryStoragesRepositoryImpl(database.arbitraryStoragesDao())
}
@Provides @Provides
fun provideReactionsRepository( fun provideReactionsRepository(
ncApi: NcApi, ncApi: NcApi,
userProvider: CurrentUserProviderNew, userProvider: CurrentUserProviderNew,
dao: ChatMessagesDao dao: ChatMessagesDao
): ReactionsRepository { ): ReactionsRepository = ReactionsRepositoryImpl(ncApi, userProvider, dao)
return ReactionsRepositoryImpl(ncApi, userProvider, dao)
}
@Provides @Provides
fun provideCallRecordingRepository(ncApi: NcApi, userProvider: CurrentUserProviderNew): CallRecordingRepository { fun provideCallRecordingRepository(ncApi: NcApi, userProvider: CurrentUserProviderNew): CallRecordingRepository =
return CallRecordingRepositoryImpl(ncApi, userProvider) CallRecordingRepositoryImpl(ncApi, userProvider)
}
@Provides @Provides
fun provideRequestAssistanceRepository( fun provideRequestAssistanceRepository(
ncApi: NcApi, ncApi: NcApi,
userProvider: CurrentUserProviderNew userProvider: CurrentUserProviderNew
): RequestAssistanceRepository { ): RequestAssistanceRepository = RequestAssistanceRepositoryImpl(ncApi, userProvider)
return RequestAssistanceRepositoryImpl(ncApi, userProvider)
}
@Provides @Provides
fun provideOpenConversationsRepository( fun provideOpenConversationsRepository(
ncApi: NcApi, ncApi: NcApi,
userProvider: CurrentUserProviderNew userProvider: CurrentUserProviderNew
): OpenConversationsRepository { ): OpenConversationsRepository = OpenConversationsRepositoryImpl(ncApi, userProvider)
return OpenConversationsRepositoryImpl(ncApi, userProvider)
}
@Provides @Provides
fun translateRepository(ncApi: NcApi): TranslateRepository { fun translateRepository(ncApi: NcApi): TranslateRepository = TranslateRepositoryImpl(ncApi)
return TranslateRepositoryImpl(ncApi)
}
@Provides @Provides
fun provideChatNetworkDataSource(ncApi: NcApi, ncApiCoroutines: NcApiCoroutines): ChatNetworkDataSource { fun provideChatNetworkDataSource(ncApi: NcApi, ncApiCoroutines: NcApiCoroutines): ChatNetworkDataSource =
return RetrofitChatNetwork(ncApi, ncApiCoroutines) RetrofitChatNetwork(ncApi, ncApiCoroutines)
}
@Provides @Provides
fun provideConversationsNetworkDataSource(ncApi: NcApi): ConversationsNetworkDataSource { fun provideConversationsNetworkDataSource(ncApi: NcApi): ConversationsNetworkDataSource =
return RetrofitConversationsNetwork(ncApi) RetrofitConversationsNetwork(ncApi)
}
@Provides @Provides
fun provideConversationInfoEditRepository( fun provideConversationInfoEditRepository(
@ -166,33 +144,27 @@ class RepositoryModule {
} }
@Provides @Provides
fun provideConversationRepository(ncApi: NcApi, userProvider: CurrentUserProviderNew): ConversationRepository { fun provideConversationRepository(ncApi: NcApi, userProvider: CurrentUserProviderNew): ConversationRepository =
return ConversationRepositoryImpl(ncApi, userProvider) ConversationRepositoryImpl(ncApi, userProvider)
}
@Provides @Provides
fun provideInvitationsRepository(ncApi: NcApi): InvitationsRepository { fun provideInvitationsRepository(ncApi: NcApi): InvitationsRepository = InvitationsRepositoryImpl(ncApi)
return InvitationsRepositoryImpl(ncApi)
}
@Provides @Provides
fun provideOfflineFirstChatRepository( fun provideOfflineFirstChatRepository(
chatMessagesDao: ChatMessagesDao, chatMessagesDao: ChatMessagesDao,
chatBlocksDao: ChatBlocksDao, chatBlocksDao: ChatBlocksDao,
dataSource: ChatNetworkDataSource, dataSource: ChatNetworkDataSource,
appPreferences: AppPreferences,
networkMonitor: NetworkMonitor, networkMonitor: NetworkMonitor,
userProvider: CurrentUserProviderNew userProvider: CurrentUserProviderNew
): ChatMessageRepository { ): ChatMessageRepository =
return OfflineFirstChatRepository( OfflineFirstChatRepository(
chatMessagesDao, chatMessagesDao,
chatBlocksDao, chatBlocksDao,
dataSource, dataSource,
appPreferences,
networkMonitor, networkMonitor,
userProvider userProvider
) )
}
@Provides @Provides
fun provideOfflineFirstConversationsRepository( fun provideOfflineFirstConversationsRepository(
@ -201,26 +173,22 @@ class RepositoryModule {
chatNetworkDataSource: ChatNetworkDataSource, chatNetworkDataSource: ChatNetworkDataSource,
networkMonitor: NetworkMonitor, networkMonitor: NetworkMonitor,
currentUserProviderNew: CurrentUserProviderNew currentUserProviderNew: CurrentUserProviderNew
): OfflineConversationsRepository { ): OfflineConversationsRepository =
return OfflineFirstConversationsRepository( OfflineFirstConversationsRepository(
dao, dao,
dataSource, dataSource,
chatNetworkDataSource, chatNetworkDataSource,
networkMonitor, networkMonitor,
currentUserProviderNew currentUserProviderNew
) )
}
@Provides @Provides
fun provideContactsRepository(ncApiCoroutines: NcApiCoroutines, userManager: UserManager): ContactsRepository { fun provideContactsRepository(ncApiCoroutines: NcApiCoroutines, userManager: UserManager): ContactsRepository =
return ContactsRepositoryImpl(ncApiCoroutines, userManager) ContactsRepositoryImpl(ncApiCoroutines, userManager)
}
@Provides @Provides
fun provideConversationCreationRepository( fun provideConversationCreationRepository(
ncApiCoroutines: NcApiCoroutines, ncApiCoroutines: NcApiCoroutines,
userManager: UserManager userManager: UserManager
): ConversationCreationRepository { ): ConversationCreationRepository = ConversationCreationRepositoryImpl(ncApiCoroutines, userManager)
return ConversationCreationRepositoryImpl(ncApiCoroutines, userManager)
}
} }

View File

@ -16,12 +16,14 @@ import com.nextcloud.talk.data.database.model.ChatMessageEntity
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
@Dao @Dao
@Suppress("Detekt.TooManyFunctions")
interface ChatMessagesDao { interface ChatMessagesDao {
@Query( @Query(
""" """
SELECT MAX(id) as max_items SELECT MAX(id) as max_items
FROM ChatMessages FROM ChatMessages
WHERE internalConversationId = :internalConversationId WHERE internalConversationId = :internalConversationId
AND isTemporary = 0
""" """
) )
fun getNewestMessageId(internalConversationId: String): Long fun getNewestMessageId(internalConversationId: String): Long
@ -31,11 +33,35 @@ interface ChatMessagesDao {
SELECT * SELECT *
FROM ChatMessages FROM ChatMessages
WHERE internalConversationId = :internalConversationId WHERE internalConversationId = :internalConversationId
AND isTemporary = 0
ORDER BY timestamp DESC, id DESC ORDER BY timestamp DESC, id DESC
""" """
) )
fun getMessagesForConversation(internalConversationId: String): Flow<List<ChatMessageEntity>> 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) @Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun upsertChatMessages(chatMessages: List<ChatMessageEntity>) suspend fun upsertChatMessages(chatMessages: List<ChatMessageEntity>)
@ -54,10 +80,20 @@ interface ChatMessagesDao {
@Query( @Query(
value = """ value = """
DELETE FROM ChatMessages 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 @Update
fun updateChatMessage(message: ChatMessageEntity) fun updateChatMessage(message: ChatMessageEntity)
@ -77,6 +113,7 @@ interface ChatMessagesDao {
SELECT * SELECT *
FROM ChatMessages FROM ChatMessages
WHERE internalConversationId = :internalConversationId AND id >= :messageId WHERE internalConversationId = :internalConversationId AND id >= :messageId
AND isTemporary = 0
ORDER BY timestamp ASC, id ASC ORDER BY timestamp ASC, id ASC
""" """
) )
@ -87,6 +124,7 @@ interface ChatMessagesDao {
SELECT * SELECT *
FROM ChatMessages FROM ChatMessages
WHERE internalConversationId = :internalConversationId WHERE internalConversationId = :internalConversationId
AND isTemporary = 0
AND id < :messageId AND id < :messageId
ORDER BY timestamp DESC, id DESC ORDER BY timestamp DESC, id DESC
LIMIT :limit LIMIT :limit
@ -103,6 +141,7 @@ interface ChatMessagesDao {
SELECT * SELECT *
FROM ChatMessages FROM ChatMessages
WHERE internalConversationId = :internalConversationId WHERE internalConversationId = :internalConversationId
AND isTemporary = 0
AND id <= :messageId AND id <= :messageId
ORDER BY timestamp DESC, id DESC ORDER BY timestamp DESC, id DESC
LIMIT :limit LIMIT :limit
@ -119,6 +158,7 @@ interface ChatMessagesDao {
SELECT COUNT(*) SELECT COUNT(*)
FROM ChatMessages FROM ChatMessages
WHERE internalConversationId = :internalConversationId WHERE internalConversationId = :internalConversationId
AND isTemporary = 0
AND id BETWEEN :newestMessageId AND :oldestMessageId 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.models.json.chat.ChatMessageJson
import com.nextcloud.talk.data.database.model.ChatMessageEntity import com.nextcloud.talk.data.database.model.ChatMessageEntity
import com.nextcloud.talk.chat.data.model.ChatMessage import com.nextcloud.talk.chat.data.model.ChatMessage
import com.nextcloud.talk.models.json.chat.ReadStatus
fun ChatMessageJson.asEntity(accountId: Long) = fun ChatMessageJson.asEntity(accountId: Long) =
ChatMessageEntity( ChatMessageEntity(
@ -37,7 +38,9 @@ fun ChatMessageJson.asEntity(accountId: Long) =
lastEditActorId = lastEditActorId, lastEditActorId = lastEditActorId,
lastEditActorType = lastEditActorType, lastEditActorType = lastEditActorType,
lastEditTimestamp = lastEditTimestamp, lastEditTimestamp = lastEditTimestamp,
deleted = deleted deleted = deleted,
referenceId = referenceId,
silent = silent
) )
fun ChatMessageEntity.asModel() = fun ChatMessageEntity.asModel() =
@ -62,7 +65,12 @@ fun ChatMessageEntity.asModel() =
lastEditActorId = lastEditActorId, lastEditActorId = lastEditActorId,
lastEditActorType = lastEditActorType, lastEditActorType = lastEditActorType,
lastEditTimestamp = lastEditTimestamp, lastEditTimestamp = lastEditTimestamp,
isDeleted = deleted isDeleted = deleted,
referenceId = referenceId,
isTemporary = isTemporary,
sendingFailed = sendingFailed,
readStatus = ReadStatus.NONE,
silent = silent
) )
fun ChatMessageJson.asModel() = fun ChatMessageJson.asModel() =
@ -87,5 +95,7 @@ fun ChatMessageJson.asModel() =
lastEditActorId = lastEditActorId, lastEditActorId = lastEditActorId,
lastEditActorType = lastEditActorType, lastEditActorType = lastEditActorType,
lastEditTimestamp = lastEditTimestamp, 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 = "deleted") var deleted: Boolean = false,
@ColumnInfo(name = "expirationTimestamp") var expirationTimestamp: Int = 0, @ColumnInfo(name = "expirationTimestamp") var expirationTimestamp: Int = 0,
@ColumnInfo(name = "isReplyable") var replyable: Boolean = false, @ColumnInfo(name = "isReplyable") var replyable: Boolean = false,
@ColumnInfo(name = "isTemporary") var isTemporary: Boolean = false,
@ColumnInfo(name = "lastEditActorDisplayName") var lastEditActorDisplayName: String? = null, @ColumnInfo(name = "lastEditActorDisplayName") var lastEditActorDisplayName: String? = null,
@ColumnInfo(name = "lastEditActorId") var lastEditActorId: String? = null, @ColumnInfo(name = "lastEditActorId") var lastEditActorId: String? = null,
@ColumnInfo(name = "lastEditActorType") var lastEditActorType: 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 = "parent") var parentMessageId: Long? = null,
@ColumnInfo(name = "reactions") var reactions: LinkedHashMap<String, Int>? = null, @ColumnInfo(name = "reactions") var reactions: LinkedHashMap<String, Int>? = null,
@ColumnInfo(name = "reactionsSelf") var reactionsSelf: ArrayList<String>? = 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 = "systemMessage") var systemMessageType: ChatMessage.SystemMessageType,
@ColumnInfo(name = "timestamp") var timestamp: Long = 0 @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) { fun migrateToRoom(db: SupportSQLiteDatabase) {
db.execSQL( db.execSQL(
"CREATE TABLE User_new (" + "CREATE TABLE User_new (" +
@ -257,4 +264,42 @@ object Migrations {
Log.i("Migrations", "hasArchived already exists") 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, ChatMessageEntity::class,
ChatBlockEntity::class ChatBlockEntity::class
], ],
version = 12, version = 13,
autoMigrations = [ autoMigrations = [
AutoMigration(from = 9, to = 11) AutoMigration(from = 9, to = 11)
], ],
@ -114,7 +114,8 @@ abstract class TalkDatabase : RoomDatabase() {
Migrations.MIGRATION_7_8, Migrations.MIGRATION_7_8,
Migrations.MIGRATION_8_9, Migrations.MIGRATION_8_9,
Migrations.MIGRATION_10_11, Migrations.MIGRATION_10_11,
Migrations.MIGRATION_11_12 Migrations.MIGRATION_11_12,
Migrations.MIGRATION_12_13
) )
.allowMainThreadQueries() .allowMainThreadQueries()
.addCallback( .addCallback(
@ -128,8 +129,8 @@ abstract class TalkDatabase : RoomDatabase() {
.build() .build()
} }
private fun getCipherMigrationHook(): SQLiteDatabaseHook { private fun getCipherMigrationHook(): SQLiteDatabaseHook =
return object : SQLiteDatabaseHook { object : SQLiteDatabaseHook {
override fun preKey(database: SQLiteDatabase) { override fun preKey(database: SQLiteDatabase) {
// unused atm // unused atm
} }
@ -140,6 +141,5 @@ abstract class TalkDatabase : RoomDatabase() {
Log.i(TAG, "DB cipher_migrate END") 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) { if (user.getId() != null) {
String username = user.getUsername(); String username = user.getUsername();
try { try {
appPreferences.deleteAllMessageQueuesFor(user.getUserId());
userManager.deleteUser(user.getId()); userManager.deleteUser(user.getId());
Log.d(TAG, "deleted user: " + username); Log.d(TAG, "deleted user: " + username);
} catch (Throwable e) { } catch (Throwable e) {

View File

@ -42,5 +42,7 @@ data class ChatMessageJson(
@JsonField(name = ["lastEditActorId"]) var lastEditActorId: String? = null, @JsonField(name = ["lastEditActorId"]) var lastEditActorId: String? = null,
@JsonField(name = ["lastEditActorType"]) var lastEditActorType: String? = null, @JsonField(name = ["lastEditActorType"]) var lastEditActorType: String? = null,
@JsonField(name = ["lastEditTimestamp"]) var lastEditTimestamp: Long? = 0, @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 ) : Parcelable

View File

@ -23,13 +23,14 @@ import com.nextcloud.talk.R
import com.nextcloud.talk.api.NcApi import com.nextcloud.talk.api.NcApi
import com.nextcloud.talk.application.NextcloudTalkApplication import com.nextcloud.talk.application.NextcloudTalkApplication
import com.nextcloud.talk.data.user.model.User 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.users.UserManager
import com.nextcloud.talk.utils.ApiUtils import com.nextcloud.talk.utils.ApiUtils
import com.nextcloud.talk.utils.NotificationUtils 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_INTERNAL_USER_ID
import com.nextcloud.talk.utils.bundle.BundleKeys.KEY_ROOM_TOKEN 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.bundle.BundleKeys.KEY_SYSTEM_NOTIFICATION_ID
import com.nextcloud.talk.utils.message.SendMessageUtils
import io.reactivex.Observer import io.reactivex.Observer
import io.reactivex.Single import io.reactivex.Single
import io.reactivex.android.schedulers.AndroidSchedulers import io.reactivex.android.schedulers.AndroidSchedulers
@ -71,24 +72,31 @@ class DirectReplyReceiver : BroadcastReceiver() {
sendDirectReply() sendDirectReply()
} }
private fun getMessageText(intent: Intent): CharSequence? { private fun getMessageText(intent: Intent): CharSequence? =
return RemoteInput.getResultsFromIntent(intent)?.getCharSequence(NotificationUtils.KEY_DIRECT_REPLY) RemoteInput.getResultsFromIntent(intent)?.getCharSequence(NotificationUtils.KEY_DIRECT_REPLY)
}
private fun sendDirectReply() { private fun sendDirectReply() {
val credentials = ApiUtils.getCredentials(currentUser.username, currentUser.token) val credentials = ApiUtils.getCredentials(currentUser.username, currentUser.token)
val apiVersion = ApiUtils.getChatApiVersion(currentUser.capabilities!!.spreedCapability!!, intArrayOf(1)) val apiVersion = ApiUtils.getChatApiVersion(currentUser.capabilities!!.spreedCapability!!, intArrayOf(1))
val url = ApiUtils.getUrlForChat(apiVersion, currentUser.baseUrl!!, roomToken!!) 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()) ?.subscribeOn(Schedulers.io())
?.observeOn(AndroidSchedulers.mainThread()) ?.observeOn(AndroidSchedulers.mainThread())
?.subscribe(object : Observer<GenericOverall> { ?.subscribe(object : Observer<ChatOverallSingleMessage> {
override fun onSubscribe(d: Disposable) { override fun onSubscribe(d: Disposable) {
// unused atm // unused atm
} }
override fun onNext(genericOverall: GenericOverall) { override fun onNext(message: ChatOverallSingleMessage) {
confirmReplySent() 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); 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); void saveVoiceMessagePlaybackSpeedPreferences(Map<String, PlaybackSpeed> speeds);
Map<String, PlaybackSpeed> readVoiceMessagePlaybackSpeedPreferences(); 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.core.stringPreferencesKey
import androidx.datastore.preferences.preferencesDataStore import androidx.datastore.preferences.preferencesDataStore
import com.nextcloud.talk.R import com.nextcloud.talk.R
import com.nextcloud.talk.chat.viewmodels.MessageInputViewModel
import com.nextcloud.talk.ui.PlaybackSpeed import com.nextcloud.talk.ui.PlaybackSpeed
import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.async import kotlinx.coroutines.async
@ -501,76 +500,6 @@ class AppPreferencesImpl(val context: Context) : AppPreferences {
return if (lastReadId.isNotEmpty()) lastReadId.toInt() else defaultValue 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>) { override fun saveVoiceMessagePlaybackSpeedPreferences(speeds: Map<String, PlaybackSpeed>) {
Json.encodeToString(speeds).let { Json.encodeToString(speeds).let {
runBlocking<Unit> { async { writeString(VOICE_MESSAGE_PLAYBACK_SPEEDS, it) } } runBlocking<Unit> { async { writeString(VOICE_MESSAGE_PLAYBACK_SPEEDS, it) } }
@ -655,13 +584,7 @@ class AppPreferencesImpl(val context: Context) : AppPreferences {
@Suppress("UnusedPrivateProperty") @Suppress("UnusedPrivateProperty")
private val TAG = AppPreferencesImpl::class.simpleName private val TAG = AppPreferencesImpl::class.simpleName
private val Context.dataStore: DataStore<Preferences> by preferencesDataStore(name = "settings") 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_TYPE = "proxy_type"
const val PROXY_SERVER = "proxy_server"
const val PROXY_HOST = "proxy_host" const val PROXY_HOST = "proxy_host"
const val PROXY_PORT = "proxy_port" const val PROXY_PORT = "proxy_port"
const val PROXY_CRED = "proxy_credentials" 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 DB_ROOM_MIGRATED = "db_room_migrated"
const val PHONE_BOOK_INTEGRATION_LAST_RUN = "phone_book_integration_last_run" const val PHONE_BOOK_INTEGRATION_LAST_RUN = "phone_book_integration_last_run"
const val TYPING_STATUS = "typing_status" const val TYPING_STATUS = "typing_status"
const val MESSAGE_QUEUE = "@message_queue"
const val VOICE_MESSAGE_PLAYBACK_SPEEDS = "voice_message_playback_speeds" const val VOICE_MESSAGE_PLAYBACK_SPEEDS = "voice_message_playback_speeds"
const val SHOW_REGULAR_NOTIFICATION_WARNING = "show_regular_notification_warning" const val SHOW_REGULAR_NOTIFICATION_WARNING = "show_regular_notification_warning"
const val LAST_NOTIFICATION_WARNING = "last_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" android:textIsSelectable="false"
tools:text="Talk to you later!" /> tools:text="Talk to you later!" />
<TextView <TextView
android:id="@id/messageTime" android:id="@id/messageTime"
android:layout_width="wrap_content" android:layout_width="wrap_content"
@ -76,13 +75,36 @@
<ImageView <ImageView
android:id="@+id/checkMark" 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_width="wrap_content"
android:layout_height="@dimen/message_bubble_checkmark_height" android:layout_height="@dimen/message_bubble_checkmark_height"
android:layout_below="@id/messageTime" android:layout_below="@id/messageTime"
android:layout_marginStart="8dp" android:layout_marginStart="8dp"
android:contentDescription="@null" android:contentDescription="@null"
app:layout_alignSelf="center" 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 <include
android:id="@+id/reactions" 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_formatted_message_you">You: %1$s</string>
<string name="nc_message_read">Message read</string> <string name="nc_message_read">Message read</string>
<string name="nc_message_sent">Message sent</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_message_failed_to_send">Failed to send message:</string>
<string name="nc_remote_audio_off">Remote audio off</string> <string name="nc_remote_audio_off">Remote audio off</string>
<string name="nc_add_attachment">Add attachment</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="show_banned_participants">Show banned participants</string>
<string name="bans_list">Bans list</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_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="connection_established">Connection established</string>
<string name="message_deleted_by_you">Message deleted by you</string> <string name="message_deleted_by_you">Message deleted by you</string>
<string name="unban">Unban</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">%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_for_one_day">%1$s is out of office today</string>
<string name="user_absence_replacement">Replacement: </string> <string name="user_absence_replacement">Replacement: </string>
<string name="resend_message">Resend</string>
</resources> </resources>

View File

@ -1,2 +1,2 @@
DO NOT TOUCH; GENERATED BY DRONE 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>