diff --git a/app/src/main/java/com/nextcloud/talk/adapters/messages/IncomingTextMessageViewHolder.kt b/app/src/main/java/com/nextcloud/talk/adapters/messages/IncomingTextMessageViewHolder.kt index 9fab9306c..5636cc1de 100644 --- a/app/src/main/java/com/nextcloud/talk/adapters/messages/IncomingTextMessageViewHolder.kt +++ b/app/src/main/java/com/nextcloud/talk/adapters/messages/IncomingTextMessageViewHolder.kt @@ -13,27 +13,38 @@ import android.content.Context import android.util.Log import android.util.TypedValue import android.view.View +import android.widget.CheckBox +import androidx.core.content.ContextCompat +import androidx.core.text.toSpanned import autodagger.AutoInjector import coil.load +import com.google.android.material.snackbar.Snackbar import com.nextcloud.talk.R import com.nextcloud.talk.application.NextcloudTalkApplication import com.nextcloud.talk.application.NextcloudTalkApplication.Companion.sharedApplication import com.nextcloud.talk.chat.ChatActivity +import com.nextcloud.talk.chat.data.ChatMessageRepository import com.nextcloud.talk.chat.data.model.ChatMessage +import com.nextcloud.talk.data.user.model.User import com.nextcloud.talk.databinding.ItemCustomIncomingTextMessageBinding import com.nextcloud.talk.ui.theme.ViewThemeUtils import com.nextcloud.talk.utils.ApiUtils +import com.nextcloud.talk.utils.CapabilitiesUtil.hasSpreedFeatureCapability import com.nextcloud.talk.utils.ChatMessageUtils import com.nextcloud.talk.utils.DateUtils +import com.nextcloud.talk.utils.SpreedFeatures import com.nextcloud.talk.utils.TextMatchers +import com.nextcloud.talk.utils.database.user.CurrentUserProviderNew import com.nextcloud.talk.utils.message.MessageUtils import com.nextcloud.talk.utils.preferences.AppPreferences import com.stfalcon.chatkit.messages.MessageHolders import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job import kotlinx.coroutines.flow.first import kotlinx.coroutines.launch import kotlinx.coroutines.withContext +import java.util.Date import javax.inject.Inject @AutoInjector(NextcloudTalkApplication::class) @@ -57,47 +68,64 @@ class IncomingTextMessageViewHolder(itemView: View, payload: Any) : @Inject lateinit var dateUtils: DateUtils + @Inject + lateinit var currentUserProvider: CurrentUserProviderNew + lateinit var commonMessageInterface: CommonMessageInterface + @Inject + lateinit var chatRepository: ChatMessageRepository + + private var job: Job? = null + override fun onBind(message: ChatMessage) { super.onBind(message) sharedApplication!!.componentApplication.inject(this) - setAvatarAndAuthorOnMessageItem(message) colorizeMessageBubble(message) - itemView.isSelected = false + val user = currentUserProvider.currentUser.blockingGet() + val hasCheckboxes = processCheckboxes( + message, + user + ) + processMessage(message, hasCheckboxes) + } + private fun processMessage(message: ChatMessage, hasCheckboxes: Boolean) { var textSize = context.resources!!.getDimension(R.dimen.chat_text_size) - - var processedMessageText = messageUtils.enrichChatMessageText( - binding.messageText.context, - message, - true, - viewThemeUtils - ) - - processedMessageText = messageUtils.processMessageParameters( - binding.messageText.context, - viewThemeUtils, - processedMessageText!!, - message, - itemView - ) - - val messageParameters = message.messageParameters - if ( - (messageParameters == null || messageParameters.size <= 0) && - TextMatchers.isMessageWithSingleEmoticonOnly(message.text) - ) { - textSize = (textSize * TEXT_SIZE_MULTIPLIER).toFloat() - itemView.isSelected = true - binding.messageAuthor.visibility = View.GONE + if (!hasCheckboxes) { + binding.messageText.visibility = View.VISIBLE + binding.checkboxContainer.visibility = View.GONE + var processedMessageText = messageUtils.enrichChatMessageText( + binding.messageText.context, + message, + true, + viewThemeUtils + ) + processedMessageText = messageUtils.processMessageParameters( + binding.messageText.context, + viewThemeUtils, + processedMessageText!!, + message, + itemView + ) + val messageParameters = message.messageParameters + if ( + (messageParameters == null || messageParameters.size <= 0) && + TextMatchers.isMessageWithSingleEmoticonOnly(message.text) + ) { + textSize = (textSize * TEXT_SIZE_MULTIPLIER).toFloat() + itemView.isSelected = true + binding.messageAuthor.visibility = View.GONE + } + binding.messageText.setTextSize(TypedValue.COMPLEX_UNIT_PX, textSize) + binding.messageText.text = processedMessageText + } else { + binding.messageText.visibility = View.GONE + binding.checkboxContainer.visibility = View.VISIBLE } - binding.messageText.setTextSize(TypedValue.COMPLEX_UNIT_PX, textSize) - binding.messageText.text = processedMessageText - if (message.lastEditTimestamp != 0L && !message.isDeleted) { binding.messageEditIndicator.visibility = View.VISIBLE binding.messageTime.text = dateUtils.getLocalTimeStringFromTimestamp(message.lastEditTimestamp!!) @@ -105,7 +133,7 @@ class IncomingTextMessageViewHolder(itemView: View, payload: Any) : binding.messageEditIndicator.visibility = View.GONE binding.messageTime.text = dateUtils.getLocalTimeStringFromTimestamp(message.timestamp) } - + binding.messageTime.setTextColor(ContextCompat.getColor(context, R.color.no_emphasis_text)) // parent message handling if (!message.isDeleted && message.parentMessageId != null) { processParentMessage(message) @@ -127,6 +155,105 @@ class IncomingTextMessageViewHolder(itemView: View, payload: Any) : ) } + private fun processCheckboxes(chatMessage: ChatMessage, user: User): Boolean { + val chatActivity = commonMessageInterface as ChatActivity + val message = chatMessage.message!!.toSpanned() + val messageTextView = binding.messageText + val checkBoxContainer = binding.checkboxContainer + val isOlderThanTwentyFourHours = chatMessage + .createdAt + .before(Date(System.currentTimeMillis() - AGE_THRESHOLD_FOR_EDIT_MESSAGE)) + + val messageIsEditable = hasSpreedFeatureCapability( + user.capabilities?.spreedCapability!!, + SpreedFeatures.EDIT_MESSAGES + ) && + !isOlderThanTwentyFourHours + + checkBoxContainer.removeAllViews() + val regex = """(- \[(X|x| )])\s*(.+)""".toRegex(RegexOption.MULTILINE) + val matches = regex.findAll(message) + + if (matches.none()) return false + + val firstPart = message.toString().substringBefore("\n- [") + messageTextView.text = messageUtils.enrichChatMessageText( + binding.messageText.context, firstPart, true, viewThemeUtils + ) + + val checkboxList = mutableListOf() + + matches.forEach { matchResult -> + val isChecked = matchResult.groupValues[CHECKED_GROUP_INDEX] == "X" || + matchResult.groupValues[CHECKED_GROUP_INDEX] == "x" + val taskText = matchResult.groupValues[TASK_TEXT_GROUP_INDEX].trim() + + val checkBox = CheckBox(checkBoxContainer.context).apply { + text = taskText + this.isChecked = isChecked + this.isEnabled = ( + chatMessage.actorType == "bots" || + chatActivity.userAllowedByPrivilages(chatMessage) + ) && messageIsEditable + + setTextColor(ContextCompat.getColor(context, R.color.no_emphasis_text)) + + setOnCheckedChangeListener { _, _ -> + updateCheckboxStates(chatMessage, user, checkboxList) + } + } + checkBoxContainer.addView(checkBox) + checkboxList.add(checkBox) + viewThemeUtils.platform.themeCheckbox(checkBox) + } + return true + } + + private fun updateCheckboxStates(chatMessage: ChatMessage, user: User, checkboxes: List) { + job = CoroutineScope(Dispatchers.Main).launch { + withContext(Dispatchers.IO) { + val apiVersion: Int = ApiUtils.getChatApiVersion( + user.capabilities?.spreedCapability!!, + intArrayOf(1) + ) + val updatedMessage = updateMessageWithCheckboxStates(chatMessage.message!!, checkboxes) + chatRepository.editChatMessage( + user.getCredentials(), + ApiUtils.getUrlForChatMessage(apiVersion, user.baseUrl!!, chatMessage.token!!, chatMessage.id), + updatedMessage + ).collect { result -> + withContext(Dispatchers.Main) { + if (result.isSuccess) { + val editedMessage = result.getOrNull()?.ocs?.data!!.parentMessage!! + Log.d(TAG, "EditedMessage: $editedMessage") + binding.messageEditIndicator.apply { + visibility = View.VISIBLE + } + binding.messageTime.text = + dateUtils.getLocalTimeStringFromTimestamp(editedMessage.lastEditTimestamp!!) + } else { + Snackbar.make(binding.root, R.string.nc_common_error_sorry, Snackbar.LENGTH_LONG).show() + } + } + } + } + } + } + + private fun updateMessageWithCheckboxStates(originalMessage: String, checkboxes: List): String { + var updatedMessage = originalMessage + val regex = """(- \[(X|x| )])\s*(.+)""".toRegex(RegexOption.MULTILINE) + + checkboxes.forEach { _ -> + updatedMessage = regex.replace(updatedMessage) { matchResult -> + val taskText = matchResult.groupValues[TASK_TEXT_GROUP_INDEX].trim() + val checkboxState = if (checkboxes.find { it.text == taskText }?.isChecked == true) "X" else " " + "- [$checkboxState] $taskText" + } + } + return updatedMessage + } + private fun longClickOnReaction(chatMessage: ChatMessage) { commonMessageInterface.onLongClickReactions(chatMessage) } @@ -231,8 +358,16 @@ class IncomingTextMessageViewHolder(itemView: View, payload: Any) : this.commonMessageInterface = commonMessageInterface } + override fun viewDetached() { + super.viewDetached() + job?.cancel() + } + companion object { const val TEXT_SIZE_MULTIPLIER = 2.5 private val TAG = IncomingTextMessageViewHolder::class.java.simpleName + private const val CHECKED_GROUP_INDEX = 2 + private const val TASK_TEXT_GROUP_INDEX = 3 + private const val AGE_THRESHOLD_FOR_EDIT_MESSAGE: Long = 86400000 } } diff --git a/app/src/main/java/com/nextcloud/talk/adapters/messages/OutcomingTextMessageViewHolder.kt b/app/src/main/java/com/nextcloud/talk/adapters/messages/OutcomingTextMessageViewHolder.kt index 907b3cd1d..ae6caa30b 100644 --- a/app/src/main/java/com/nextcloud/talk/adapters/messages/OutcomingTextMessageViewHolder.kt +++ b/app/src/main/java/com/nextcloud/talk/adapters/messages/OutcomingTextMessageViewHolder.kt @@ -13,31 +13,43 @@ import android.content.Context import android.util.Log import android.util.TypedValue import android.view.View +import android.widget.CheckBox +import androidx.core.content.ContextCompat import androidx.core.content.res.ResourcesCompat +import androidx.core.text.toSpanned import androidx.lifecycle.lifecycleScope import autodagger.AutoInjector import coil.load import com.google.android.flexbox.FlexboxLayout +import com.google.android.material.snackbar.Snackbar import com.nextcloud.android.common.ui.theme.utils.ColorRole import com.nextcloud.talk.R import com.nextcloud.talk.application.NextcloudTalkApplication import com.nextcloud.talk.application.NextcloudTalkApplication.Companion.sharedApplication import com.nextcloud.talk.chat.ChatActivity +import com.nextcloud.talk.chat.data.ChatMessageRepository import com.nextcloud.talk.chat.data.model.ChatMessage import com.nextcloud.talk.data.network.NetworkMonitor +import com.nextcloud.talk.data.user.model.User import com.nextcloud.talk.databinding.ItemCustomOutcomingTextMessageBinding import com.nextcloud.talk.models.json.chat.ReadStatus +import com.nextcloud.talk.models.json.conversations.ConversationEnums import com.nextcloud.talk.ui.theme.ViewThemeUtils import com.nextcloud.talk.utils.ApiUtils +import com.nextcloud.talk.utils.CapabilitiesUtil.hasSpreedFeatureCapability import com.nextcloud.talk.utils.DateUtils +import com.nextcloud.talk.utils.SpreedFeatures import com.nextcloud.talk.utils.TextMatchers +import com.nextcloud.talk.utils.database.user.CurrentUserProviderNew import com.nextcloud.talk.utils.message.MessageUtils import com.stfalcon.chatkit.messages.MessageHolders.OutcomingTextMessageViewHolder import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job import kotlinx.coroutines.flow.first import kotlinx.coroutines.launch import kotlinx.coroutines.withContext +import java.util.Date import javax.inject.Inject @AutoInjector(NextcloudTalkApplication::class) @@ -65,48 +77,71 @@ class OutcomingTextMessageViewHolder(itemView: View) : lateinit var commonMessageInterface: CommonMessageInterface + @Inject + lateinit var chatRepository: ChatMessageRepository + + @Inject + lateinit var currentUserProvider: CurrentUserProviderNew + + private var job: Job? = null + @Suppress("Detekt.LongMethod") override fun onBind(message: ChatMessage) { super.onBind(message) sharedApplication!!.componentApplication.inject(this) - realView.isSelected = false - val layoutParams = binding.messageTime.layoutParams as FlexboxLayout.LayoutParams - layoutParams.isWrapBefore = false - var textSize = context.resources.getDimension(R.dimen.chat_text_size) - viewThemeUtils.platform.colorTextView(binding.messageTime, ColorRole.ON_SURFACE_VARIANT) - - var processedMessageText = messageUtils.enrichChatMessageText( - binding.messageText.context, + val user = currentUserProvider.currentUser.blockingGet() + val hasCheckboxes = processCheckboxes( message, - false, - viewThemeUtils - ) - processedMessageText = messageUtils.processMessageParameters( - binding.messageText.context, - viewThemeUtils, - processedMessageText!!, - message, - itemView + user ) + processMessage(message, hasCheckboxes) + } + @Suppress("Detekt.LongMethod") + private fun processMessage(message: ChatMessage, hasCheckboxes: Boolean) { var isBubbled = true - if ( - (message.messageParameters == null || message.messageParameters!!.size <= 0) && - TextMatchers.isMessageWithSingleEmoticonOnly(message.text) - ) { - textSize = (textSize * TEXT_SIZE_MULTIPLIER).toFloat() - layoutParams.isWrapBefore = true - realView.isSelected = true - isBubbled = false + val layoutParams = binding.messageTime.layoutParams as FlexboxLayout.LayoutParams + var textSize = context.resources.getDimension(R.dimen.chat_text_size) + if (!hasCheckboxes) { + realView.isSelected = false + layoutParams.isWrapBefore = false + viewThemeUtils.platform.colorTextView(binding.messageTime, ColorRole.ON_SURFACE_VARIANT) + + binding.messageText.visibility = View.VISIBLE + binding.checkboxContainer.visibility = View.GONE + + var processedMessageText = messageUtils.enrichChatMessageText( + binding.messageText.context, + message, + false, + viewThemeUtils + ) + processedMessageText = messageUtils.processMessageParameters( + binding.messageText.context, + viewThemeUtils, + processedMessageText!!, + message, + itemView + ) + + if ( + (message.messageParameters == null || message.messageParameters!!.size <= 0) && + TextMatchers.isMessageWithSingleEmoticonOnly(message.text) + ) { + textSize = (textSize * TEXT_SIZE_MULTIPLIER).toFloat() + layoutParams.isWrapBefore = true + realView.isSelected = true + isBubbled = false + } + + binding.messageTime.layoutParams = layoutParams + viewThemeUtils.platform.colorTextView(binding.messageText, ColorRole.ON_SURFACE_VARIANT) + binding.messageText.text = processedMessageText + } else { + binding.messageText.visibility = View.GONE + binding.checkboxContainer.visibility = View.VISIBLE } - - setBubbleOnChatMessage(message) - binding.messageText.setTextSize(TypedValue.COMPLEX_UNIT_PX, textSize) - binding.messageTime.layoutParams = layoutParams - viewThemeUtils.platform.colorTextView(binding.messageText, ColorRole.ON_SURFACE_VARIANT) - binding.messageText.text = processedMessageText - if (message.lastEditTimestamp != 0L && !message.isDeleted) { binding.messageEditIndicator.visibility = View.VISIBLE binding.messageTime.text = dateUtils.getLocalTimeStringFromTimestamp(message.lastEditTimestamp!!) @@ -114,7 +149,8 @@ class OutcomingTextMessageViewHolder(itemView: View) : binding.messageEditIndicator.visibility = View.GONE binding.messageTime.text = dateUtils.getLocalTimeStringFromTimestamp(message.timestamp) } - + binding.messageTime.setTextColor(ContextCompat.getColor(context, R.color.no_emphasis_text)) + setBubbleOnChatMessage(message) // parent message handling if (!message.isDeleted && message.parentMessageId != null) { processParentMessage(message) @@ -161,6 +197,106 @@ class OutcomingTextMessageViewHolder(itemView: View) : ) } + private fun processCheckboxes(chatMessage: ChatMessage, user: User): Boolean { + val chatActivity = commonMessageInterface as ChatActivity + val message = chatMessage.message!!.toSpanned() + val messageTextView = binding.messageText + val checkBoxContainer = binding.checkboxContainer + val isOlderThanTwentyFourHours = chatMessage + .createdAt + .before(Date(System.currentTimeMillis() - AGE_THRESHOLD_FOR_EDIT_MESSAGE)) + val messageIsEditable = hasSpreedFeatureCapability( + user.capabilities?.spreedCapability!!, + SpreedFeatures.EDIT_MESSAGES + ) && !isOlderThanTwentyFourHours + + val isNoTimeLimitOnNoteToSelf = hasSpreedFeatureCapability( + user.capabilities?.spreedCapability!!, + SpreedFeatures + .EDIT_MESSAGES_NOTE_TO_SELF + ) && chatActivity.currentConversation?.type == ConversationEnums.ConversationType.NOTE_TO_SELF + + checkBoxContainer.removeAllViews() + val regex = """(- \[(X|x| )])\s*(.+)""".toRegex(RegexOption.MULTILINE) + val matches = regex.findAll(message) + + if (matches.none()) return false + + val firstPart = message.toString().substringBefore("\n- [") + messageTextView.text = messageUtils.enrichChatMessageText( + binding.messageText.context, firstPart, true, viewThemeUtils + ) + + val checkboxList = mutableListOf() + + matches.forEach { matchResult -> + val isChecked = matchResult.groupValues[CHECKED_GROUP_INDEX] == "X" || + matchResult.groupValues[CHECKED_GROUP_INDEX] == "x" + val taskText = matchResult.groupValues[TASK_TEXT_GROUP_INDEX].trim() + + val checkBox = CheckBox(checkBoxContainer.context).apply { + text = taskText + this.isChecked = isChecked + this.isEnabled = messageIsEditable || isNoTimeLimitOnNoteToSelf + + setTextColor(ContextCompat.getColor(context, R.color.no_emphasis_text)) + + setOnCheckedChangeListener { _, _ -> + updateCheckboxStates(chatMessage, user, checkboxList) + } + } + checkBoxContainer.addView(checkBox) + checkboxList.add(checkBox) + viewThemeUtils.platform.themeCheckbox(checkBox) + } + return true + } + + private fun updateCheckboxStates(chatMessage: ChatMessage, user: User, checkboxes: List) { + job = CoroutineScope(Dispatchers.Main).launch { + withContext(Dispatchers.IO) { + val apiVersion: Int = ApiUtils.getChatApiVersion( + user.capabilities?.spreedCapability!!, + intArrayOf(1) + ) + val updatedMessage = updateMessageWithCheckboxStates(chatMessage.message!!, checkboxes) + chatRepository.editChatMessage( + user.getCredentials(), + ApiUtils.getUrlForChatMessage(apiVersion, user.baseUrl!!, chatMessage.token!!, chatMessage.id), + updatedMessage + ).collect { result -> + withContext(Dispatchers.Main) { + if (result.isSuccess) { + val editedMessage = result.getOrNull()?.ocs?.data!!.parentMessage!! + Log.d(TAG, "EditedMessage: $editedMessage") + binding.messageEditIndicator.apply { + visibility = View.VISIBLE + } + binding.messageTime.text = + dateUtils.getLocalTimeStringFromTimestamp(editedMessage.lastEditTimestamp!!) + } else { + Snackbar.make(binding.root, R.string.nc_common_error_sorry, Snackbar.LENGTH_LONG).show() + } + } + } + } + } + } + + private fun updateMessageWithCheckboxStates(originalMessage: String, checkboxes: List): String { + var updatedMessage = originalMessage + val regex = """(- \[(X|x| )])\s*(.+)""".toRegex(RegexOption.MULTILINE) + + checkboxes.forEach { _ -> + updatedMessage = regex.replace(updatedMessage) { matchResult -> + val taskText = matchResult.groupValues[TASK_TEXT_GROUP_INDEX].trim() + val checkboxState = if (checkboxes.find { it.text == taskText }?.isChecked == true) "X" else " " + "- [$checkboxState] $taskText" + } + } + return updatedMessage + } + private fun updateStatus(readStatusDrawableInt: Int, description: String?) { binding.sendingProgress.visibility = View.GONE binding.checkMark.visibility = View.VISIBLE @@ -245,8 +381,16 @@ class OutcomingTextMessageViewHolder(itemView: View) : this.commonMessageInterface = commonMessageInterface } + override fun viewDetached() { + super.viewDetached() + job?.cancel() + } + companion object { const val TEXT_SIZE_MULTIPLIER = 2.5 private val TAG = OutcomingTextMessageViewHolder::class.java.simpleName + private const val CHECKED_GROUP_INDEX = 2 + private const val TASK_TEXT_GROUP_INDEX = 3 + private const val AGE_THRESHOLD_FOR_EDIT_MESSAGE: Long = 86400000 } } diff --git a/app/src/main/java/com/nextcloud/talk/utils/message/MessageUtils.kt b/app/src/main/java/com/nextcloud/talk/utils/message/MessageUtils.kt index 3e4d43696..ffedb19d9 100644 --- a/app/src/main/java/com/nextcloud/talk/utils/message/MessageUtils.kt +++ b/app/src/main/java/com/nextcloud/talk/utils/message/MessageUtils.kt @@ -65,7 +65,7 @@ class MessageUtils(val context: Context) { } } - private fun enrichChatMessageText( + fun enrichChatMessageText( context: Context, message: String, incoming: Boolean, diff --git a/app/src/main/res/layout/item_custom_incoming_text_message.xml b/app/src/main/res/layout/item_custom_incoming_text_message.xml index 06f60829c..b025cc40f 100644 --- a/app/src/main/res/layout/item_custom_incoming_text_message.xml +++ b/app/src/main/res/layout/item_custom_incoming_text_message.xml @@ -64,6 +64,17 @@ app:layout_wrapBefore="true" tools:text="Talk to you later!" /> + + + + - + + + + @@ -68,7 +80,6 @@ android:alpha="0.6" android:textColor="@color/no_emphasis_text" android:textIsSelectable="false" - android:gravity="end" app:layout_alignSelf="flex_end" android:text = "@string/hint_edited_message" android:textSize="12sp"> @@ -83,7 +94,6 @@ android:layout_marginStart="8dp" android:contentDescription="@null" app:layout_alignSelf="flex_end" - android:gravity="end" app:tint="@color/high_emphasis_text" tools:src="@drawable/ic_check_all" /> @@ -94,7 +104,7 @@ android:layout_below="@id/messageTime" android:layout_marginStart="8dp" android:contentDescription="@null" - app:layout_alignSelf="center" + app:layout_alignSelf="flex_end" app:tint="@color/high_emphasis_text" tools:src="@drawable/ic_warning_white"/>