Merge pull request #4747 from nextcloud/fix_edit_bot_messages

Edit checkbox messages directly
This commit is contained in:
Marcel Hibbe 2025-04-04 13:00:51 +00:00 committed by GitHub
commit 5d78fed901
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 369 additions and 71 deletions

View File

@ -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<CheckBox>()
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<CheckBox>) {
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<CheckBox>): 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
}
}

View File

@ -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<CheckBox>()
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<CheckBox>) {
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<CheckBox>): 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
}
}

View File

@ -65,7 +65,7 @@ class MessageUtils(val context: Context) {
}
}
private fun enrichChatMessageText(
fun enrichChatMessageText(
context: Context,
message: String,
incoming: Boolean,

View File

@ -64,6 +64,17 @@
app:layout_wrapBefore="true"
tools:text="Talk to you later!" />
<LinearLayout
android:id="@+id/checkboxContainer"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:orientation="vertical"
android:layout_below="@id/messageText"
android:layout_marginBottom="8dp"
android:visibility="gone">
</LinearLayout>
<TextView
android:id="@id/messageTime"
android:layout_width="wrap_content"
@ -73,7 +84,7 @@
android:alpha="0.6"
android:textColor="@color/no_emphasis_text"
android:textIsSelectable="false"
android:gravity = "end"
android:gravity="end"
app:layout_alignSelf="flex_end"
app:layout_flexGrow="1"
app:layout_wrapBefore="false"
@ -86,14 +97,12 @@
android:layout_height="wrap_content"
android:layout_below="@id/messageText"
android:layout_marginStart="8dp"
android:gravity="end"
android:alpha="0.6"
android:textColor="@color/no_emphasis_text"
android:textIsSelectable="false"
app:layout_alignSelf="flex_end"
android:text = "@string/hint_edited_message"
android:textSize="12sp">
</TextView>
<include

View File

@ -41,8 +41,21 @@
android:textAlignment="viewStart"
android:textColorHighlight="@color/nc_grey"
android:textIsSelectable="false"
app:layout_alignSelf="flex_start"
app:layout_flexGrow="1"
tools:text="Talk to you later!" />
<LinearLayout
android:id="@+id/checkboxContainer"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:orientation="vertical"
android:layout_marginBottom="8dp"
android:layout_below="@id/messageText"
android:visibility="gone">
</LinearLayout>
<TextView
android:id="@id/messageTime"
android:layout_width="wrap_content"
@ -54,7 +67,6 @@
android:textColor="@color/no_emphasis_text"
android:textIsSelectable="false"
app:layout_alignSelf="flex_end"
android:layout_gravity="end"
app:layout_flexGrow="1"
app:layout_wrapBefore="false"
tools:text="10:35" />
@ -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"/>