diff --git a/app/src/main/kotlin/com/wire/android/ui/common/textfield/MentionDeletionHandler.kt b/app/src/main/kotlin/com/wire/android/ui/common/textfield/MentionDeletionHandler.kt deleted file mode 100644 index 9f34070fc6f..00000000000 --- a/app/src/main/kotlin/com/wire/android/ui/common/textfield/MentionDeletionHandler.kt +++ /dev/null @@ -1,50 +0,0 @@ -/* - * Wire - * Copyright (C) 2024 Wire Swiss GmbH - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see http://www.gnu.org/licenses/. - */ -package com.wire.android.ui.common.textfield - -import androidx.compose.ui.text.TextRange - -object MentionDeletionHandler { - @Suppress("ReturnCount") - fun handle( - oldText: String, - newText: String, - oldSelection: TextRange, - mentions: List - ): String { - if (oldText == newText) { - // No change in text, only cursor movement, return as is - return oldText - } - for (mention in mentions) { - // Find the start position of the mention in the text - val mentionStart = oldText.indexOf(mention) - - if (mentionStart == -1) continue - - val mentionEnd = mentionStart + mention.length - - // Check if the selection (i.e., user's cursor position) is inside the mention's range - if (oldSelection.start in mentionStart + 1..mentionEnd || oldSelection.end in mentionStart + 1..mentionEnd) { - // If the user is deleting inside the mention, remove the entire mention - return oldText.removeRange(mentionStart, mentionEnd) - } - } - return newText - } -} diff --git a/app/src/main/kotlin/com/wire/android/ui/common/textfield/WireTextField.kt b/app/src/main/kotlin/com/wire/android/ui/common/textfield/WireTextField.kt index 4ee35be59bb..2350ba611a3 100644 --- a/app/src/main/kotlin/com/wire/android/ui/common/textfield/WireTextField.kt +++ b/app/src/main/kotlin/com/wire/android/ui/common/textfield/WireTextField.kt @@ -222,17 +222,7 @@ internal fun WireTextField( innerBasicTextField = { _, textFieldModifier, decoratorBox -> BasicTextField( value = textFieldValue.value, - onValueChange = { newText -> - val mentionsByName = mentions.map { it.handler } - val updatedText = - MentionDeletionHandler.handle( - textFieldValue.value.text, - newText.text, - textFieldValue.value.selection, - mentionsByName - ) - onValueChange(TextFieldValue(updatedText, newText.selection)) - }, + onValueChange = onValueChange, textStyle = textStyle.copy( color = colors.textColor(state = state).value, textDirection = TextDirection.ContentOrLtr diff --git a/app/src/main/kotlin/com/wire/android/ui/common/textfield/mention/MentionAdjuster.kt b/app/src/main/kotlin/com/wire/android/ui/common/textfield/mention/MentionAdjuster.kt new file mode 100644 index 00000000000..bf220943e78 --- /dev/null +++ b/app/src/main/kotlin/com/wire/android/ui/common/textfield/mention/MentionAdjuster.kt @@ -0,0 +1,96 @@ +/* + * Wire + * Copyright (C) 2024 Wire Swiss GmbH + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see http://www.gnu.org/licenses/. + */ +package com.wire.android.ui.common.textfield.mention + +import androidx.compose.ui.text.TextRange +import com.wire.android.ui.home.conversations.model.UIMention + +/** + * Adjusts mentions based on changes in the text. + */ +class MentionAdjuster { + + /** + * Adjusts mentions based on the deleted text. + * @param mentions The list of mentions in the text. + * @param deletedLength The length of the deleted text. + * @param text The new text after deletion. + * @param selection The current selection. + */ + @Suppress("NestedBlockDepth") + fun adjustMentionsForDeletion( + mentions: List, + deletedLength: Int, + text: String, + selection: TextRange + ): Pair, TextRange> { + val updatedMentions = mutableListOf() + var newSelection = selection + + mentions.forEach { mention -> + if (selection.start >= mention.start + mention.length) { + // No change for mentions that are before the deleted text. + updatedMentions.add(mention) + // if the cursor is at the end of the mention, select te mention + if (mention.start + mention.length == selection.end) { + newSelection = TextRange(mention.start, mention.start + mention.length) + } + } else { + // Handle mentions that were affected by the deletion and adjusting their start position. + val newStart = mention.start - deletedLength + if (newStart >= 0) { + val mentionSubstring = text.substring(newStart, newStart + mention.length) + if (mentionSubstring == mention.handler) { + updatedMentions.add(mention.copy(start = newStart)) + } + } + } + } + + return Pair(updatedMentions, newSelection) + } + + /** + * Adjusts mentions based on the inserted text. + * @param mentions The list of mentions in the text. + * @param text The new text after insertion. + * @param addedLength The length of the inserted text. + * @param selection The current selection. + */ + fun adjustMentionsForInsertion( + mentions: List, + text: String, + selection: TextRange, + addedLength: Int + ): Pair, TextRange> { + val updatedMentions = mutableListOf() + // Adjust mentions based on the inserted text. + mentions.forEach { mention -> + val mentionSubstring = text.substring(mention.start, mention.start + mention.length) + if (mentionSubstring == mention.handler) { + // No change if the mention text remains the same. + updatedMentions.add(mention) + } else { + // Handle mentions that were affected by the insertion and shift their start position. + updatedMentions.add(mention.copy(start = mention.start + addedLength)) + } + } + + return Pair(updatedMentions, selection) + } +} diff --git a/app/src/main/kotlin/com/wire/android/ui/common/textfield/mention/MentionSelectionManager.kt b/app/src/main/kotlin/com/wire/android/ui/common/textfield/mention/MentionSelectionManager.kt new file mode 100644 index 00000000000..eb03e606906 --- /dev/null +++ b/app/src/main/kotlin/com/wire/android/ui/common/textfield/mention/MentionSelectionManager.kt @@ -0,0 +1,58 @@ +/* + * Wire + * Copyright (C) 2024 Wire Swiss GmbH + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see http://www.gnu.org/licenses/. + */ +package com.wire.android.ui.common.textfield.mention + +import androidx.compose.ui.text.TextRange +import com.wire.android.ui.home.conversations.model.UIMention + +/** + * Manages the selection for mentions. + */ +class MentionSelectionManager { + + /** + * if the selection is inside the mention's range, it will return the mention's range. + * Otherwise, it will return the new selection. + * @param oldSelection the old selection. + * @param newSelection the new selection. + * @param mentions the list of mentions. + * @return the new selection. + */ + fun updateSelectionForMention( + oldSelection: TextRange, + newSelection: TextRange, + mentions: List + ): TextRange { + if (oldSelection != newSelection) { + mentions.forEach { mention -> + if (newSelection.isInside(mention)) { + return TextRange(mention.start, mention.start + mention.length) + } + } + } + return newSelection + } + + /** + * Extension function to check if the selection is inside the mention's range. + */ + private fun TextRange.isInside(mention: UIMention): Boolean { + return this.start in mention.start until mention.start + mention.length && + this.end in mention.start until mention.start + mention.length + } +} diff --git a/app/src/main/kotlin/com/wire/android/ui/common/textfield/mention/MentionUpdateCoordinator.kt b/app/src/main/kotlin/com/wire/android/ui/common/textfield/mention/MentionUpdateCoordinator.kt new file mode 100644 index 00000000000..b4a1c883f46 --- /dev/null +++ b/app/src/main/kotlin/com/wire/android/ui/common/textfield/mention/MentionUpdateCoordinator.kt @@ -0,0 +1,88 @@ +/* + * Wire + * Copyright (C) 2024 Wire Swiss GmbH + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see http://www.gnu.org/licenses/. + */ +package com.wire.android.ui.common.textfield.mention + +import androidx.compose.ui.text.input.TextFieldValue +import com.wire.android.ui.home.conversations.model.UIMention + +/** + * Manages the updates to a `TextFieldValue` based on changes in the text and mentions. + */ +class MentionUpdateCoordinator( + private val mentionAdjuster: MentionAdjuster = MentionAdjuster(), + private val selectionManager: MentionSelectionManager = MentionSelectionManager() +) { + @Suppress("ReturnCount") + fun handle( + oldTextFieldValue: TextFieldValue, + newTextFieldValue: TextFieldValue, + mentions: List, + updateMentions: (List) -> Unit + ): TextFieldValue { + if (newTextFieldValue.text.isEmpty()) { + updateMentions(emptyList()) + return newTextFieldValue + } + + // If there are no mentions, simply return the new TextFieldValue. + if (mentions.isEmpty()) { + return newTextFieldValue + } + + val deletedLength = oldTextFieldValue.text.length - newTextFieldValue.text.length + val addedLength = newTextFieldValue.text.length - oldTextFieldValue.text.length + + when { + deletedLength > 0 -> { + val result = mentionAdjuster.adjustMentionsForDeletion( + mentions = mentions, + deletedLength = deletedLength, + text = newTextFieldValue.text, + selection = newTextFieldValue.selection + ) + updateMentions(result.first) + return newTextFieldValue.copy(selection = result.second) + } + + addedLength > 0 -> { + val result = + mentionAdjuster.adjustMentionsForInsertion( + mentions = mentions, + text = newTextFieldValue.text, + selection = newTextFieldValue.selection, + addedLength = addedLength + ) + updateMentions(result.first) + return newTextFieldValue.copy(selection = result.second) + } + } + + // To select the mention if the user clicks on it + val newSelection = if (oldTextFieldValue.text == newTextFieldValue.text) { + selectionManager.updateSelectionForMention( + oldTextFieldValue.selection, + newTextFieldValue.selection, + mentions + ) + } else { + newTextFieldValue.selection + } + + return newTextFieldValue.copy(selection = newSelection) + } +} diff --git a/app/src/main/kotlin/com/wire/android/ui/home/messagecomposer/EnabledMessageComposer.kt b/app/src/main/kotlin/com/wire/android/ui/home/messagecomposer/EnabledMessageComposer.kt index ee003250489..fc14ab01833 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/messagecomposer/EnabledMessageComposer.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/messagecomposer/EnabledMessageComposer.kt @@ -69,9 +69,11 @@ import com.wire.android.ui.common.banner.SecurityClassificationBannerForConversa import com.wire.android.ui.common.bottombar.bottomNavigationBarHeight import com.wire.android.ui.common.colorsScheme import com.wire.android.ui.common.dimensions +import com.wire.android.ui.common.textfield.mention.MentionUpdateCoordinator import com.wire.android.ui.home.conversations.ConversationActionPermissionType import com.wire.android.ui.home.conversations.UsersTypingIndicatorForConversation import com.wire.android.ui.home.conversations.model.UriAsset +import com.wire.android.ui.home.messagecomposer.model.update import com.wire.android.ui.home.messagecomposer.state.AdditionalOptionSubMenuState import com.wire.android.ui.home.messagecomposer.state.InputType import com.wire.android.ui.home.messagecomposer.state.MessageComposerStateHolder @@ -206,10 +208,18 @@ fun EnabledMessageComposer( conversationId = conversationId, messageComposition = messageComposition.value, messageTextFieldValue = inputStateHolder.messageTextFieldValue, - onValueChange = { - inputStateHolder.messageTextFieldValue.value = it + onValueChange = { newTextField -> + val updatedTextField = MentionUpdateCoordinator().handle( + inputStateHolder.messageTextFieldValue.value, + newTextField, + messageComposition.value.selectedMentions, + updateMentions = { mentions -> + messageComposition.update { it.copy(selectedMentions = mentions) } + } + ) + inputStateHolder.messageTextFieldValue.value = updatedTextField }, - mentions = (messageComposition.value.selectedMentions), + mentions = messageComposition.value.selectedMentions, isTextExpanded = inputStateHolder.isTextExpanded, inputType = messageCompositionInputStateHolder.inputType, focusRequester = messageCompositionInputStateHolder.focusRequester, diff --git a/app/src/main/kotlin/com/wire/android/ui/home/messagecomposer/MessageComposerInput.kt b/app/src/main/kotlin/com/wire/android/ui/home/messagecomposer/MessageComposerInput.kt index e9c2b9012bf..1a4888f7f8c 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/messagecomposer/MessageComposerInput.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/messagecomposer/MessageComposerInput.kt @@ -189,7 +189,7 @@ private fun InputContent( viewModel: SelfDeletingMessageActionViewModel = hiltViewModelScoped( SelfDeletingMessageActionArgs(conversationId = conversationId) - ), + ) ) { ConstraintLayout(modifier = modifier) { val (additionalOptionButton, input, actions) = createRefs() diff --git a/app/src/main/kotlin/com/wire/android/ui/home/messagecomposer/state/MessageCompositionHolder.kt b/app/src/main/kotlin/com/wire/android/ui/home/messagecomposer/state/MessageCompositionHolder.kt index 18059202bb4..b7e22716c3d 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/messagecomposer/state/MessageCompositionHolder.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/messagecomposer/state/MessageCompositionHolder.kt @@ -112,7 +112,6 @@ class MessageCompositionHolder( .distinctUntilChanged() .collectLatest { (messageText, selection) -> updateTypingEvent(messageText) - updateMentionsIfNeeded(messageText) requestMentionSuggestionIfNeeded(messageText, selection) onSaveDraft(messageComposition.value.toDraft(messageText)) } @@ -125,10 +124,6 @@ class MessageCompositionHolder( } } - private fun updateMentionsIfNeeded(messageText: String) { - messageComposition.update { it.copy(selectedMentions = it.getSelectedMentions(messageText)) } - } - private fun requestMentionSuggestionIfNeeded(messageText: String, selection: TextRange) { if (selection.min != selection.max) { onClearMentionSearchResult() @@ -192,17 +187,24 @@ class MessageCompositionHolder( } fun addMention(contact: Contact) { - val mention = UIMention( + val mentionToAdd = UIMention( start = currentMentionStartIndex(messageTextFieldValue.value.text, messageTextFieldValue.value.selection), length = contact.name.length + 1, // +1 cause there is an "@" before it userId = UserId(contact.id, contact.domain), handler = String.MENTION_SYMBOL + contact.name ) - insertMentionIntoText(mention) + val updatedList = mutableListOf() + messageComposition.value.selectedMentions.forEach { mention -> + if (messageTextFieldValue.value.selection.start < mention.start) { + updatedList.add(mention.copy(start = mention.start + mentionToAdd.length)) + } else { + updatedList.add(mention) + } + } + updatedList.add(mentionToAdd) + insertMentionIntoText(mentionToAdd) messageComposition.update { - it.copy( - selectedMentions = it.selectedMentions.plus(mention).sortedBy { it.start } - ) + it.copy(selectedMentions = updatedList.sortedBy { it.start }) } } diff --git a/app/src/test/kotlin/com/wire/android/ui/common/textfield/MentionDeletionHandlerTest.kt b/app/src/test/kotlin/com/wire/android/ui/common/textfield/MentionDeletionHandlerTest.kt deleted file mode 100644 index f28ad9b2b00..00000000000 --- a/app/src/test/kotlin/com/wire/android/ui/common/textfield/MentionDeletionHandlerTest.kt +++ /dev/null @@ -1,97 +0,0 @@ -/* - * Wire - * Copyright (C) 2024 Wire Swiss GmbH - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see http://www.gnu.org/licenses/. - */ -package com.wire.android.ui.common.textfield - -import androidx.compose.ui.text.TextRange -import org.junit.jupiter.api.Assertions.assertEquals -import org.junit.jupiter.api.Test - -class MentionDeletionHandlerTest { - - @Test - fun `given mention in text when deleting inside mention then mention is removed`() { - val oldText = "Hello @John Doe, how are you?" - val newText = "Hello , how are you?" - val oldSelection = TextRange(6, 17) - val mentions = listOf("@John Doe") - - val result = MentionDeletionHandler.handle(oldText, newText, oldSelection, mentions) - - assertEquals("Hello , how are you?", result) - } - - @Test - fun `given mention with last character deleted when deleting last character then mention is removed`() { - val oldText = "Hello @John Doe, how are you?" - val newText = "Hello @John Do, how are you?" - val oldSelection = TextRange(3, 13) - val mentions = listOf("@John Doe") - - val result = MentionDeletionHandler.handle(oldText, newText, oldSelection, mentions) - - assertEquals("Hello , how are you?", result) - } - - @Test - fun `given cursor at beginning of mention when no deletion then text remains unchanged`() { - val oldText = "Hello @John Doe, how are you?" - val newText = "Hello @John Doe, how are you?" - val oldSelection = TextRange(6, 6) - val mentions = listOf("@John Doe") - - val result = MentionDeletionHandler.handle(oldText, newText, oldSelection, mentions) - - assertEquals(oldText, result) - } - - @Test - fun `given text with mention when deleting outside of mention then text remains unchanged`() { - val oldText = "Hello @John Doe, how are you?" - val newText = "Hello @John Doehow are you?" - val oldSelection = TextRange(5, 6) - val mentions = listOf("@John Doe") - - val result = MentionDeletionHandler.handle(oldText, newText, oldSelection, mentions) - - assertEquals(newText, result) - } - - @Test - fun `given multiple mentions in text when deleting inside mentions then all mentions are removed`() { - val oldText = "Hello @John Doe and @Jane Doe, how are you?" - val newText = "Hello , how are you?" - val oldSelection = TextRange(6, 17) - val mentions = listOf("@John Doe", "@Jane Doe") - - val result = MentionDeletionHandler.handle(oldText, newText, oldSelection, mentions) - - assertEquals(newText, result) - } - - @Test - fun `given text without mentions when no mentions to delete then text remains unchanged`() { - val oldText = "Hello there, how are you?" - val newText = "Hello, how are you?" - val oldSelection = TextRange(6, 6) - val mentions = listOf("@John Doe") - - val result = MentionDeletionHandler.handle(oldText, newText, oldSelection, mentions) - - assertEquals(newText, result) - } -} diff --git a/app/src/test/kotlin/com/wire/android/ui/common/textfield/mention/MentionAdjusterTest.kt b/app/src/test/kotlin/com/wire/android/ui/common/textfield/mention/MentionAdjusterTest.kt new file mode 100644 index 00000000000..abc2c8b43db --- /dev/null +++ b/app/src/test/kotlin/com/wire/android/ui/common/textfield/mention/MentionAdjusterTest.kt @@ -0,0 +1,163 @@ +/* + * Wire + * Copyright (C) 2024 Wire Swiss GmbH + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see http://www.gnu.org/licenses/. + */ +package com.wire.android.ui.common.textfield.mention + +import androidx.compose.ui.text.TextRange +import com.wire.android.framework.TestUser +import com.wire.android.ui.home.conversations.model.UIMention +import org.junit.Assert.assertEquals +import org.junit.Test + +class MentionAdjusterTest { + + private val mentionAdjuster: MentionAdjuster = MentionAdjuster() + + // --- adjustMentionsForDeletion Tests --- + + @Test + fun `Given deleted text does not affect any mentions, When adjustMentionsForDeletion is called, Then mentions remain unchanged`() { + // Given + val mentions = listOf(UIMention(start = 6, length = 5, handler = "@user", userId = TestUser.USER_ID)) + val deletedLength = 2 + val text = "Hello @user again" + val selection = TextRange(13, 10) + + // When + val (updatedMentions, updatedSelection) = mentionAdjuster.adjustMentionsForDeletion( + mentions = mentions, + deletedLength = deletedLength, + text = text, + selection = selection + ) + + // Then + assertEquals(mentions, updatedMentions) + assertEquals(selection, updatedSelection) + } + + @Test + fun `Given deleted text affects mentions, When adjustMentionsForDeletion is called, Then mentions are adjusted and selection updated`() { + // Given + val mentions = listOf(UIMention(start = 12, length = 5, handler = "@user", userId = TestUser.USER_ID)) + val deletedLength = 2 // Simulate deleting 2 characters before the mention + val text = "Hello ain @user" + val selection = TextRange(7, 7) + + // When + val (updatedMentions, updatedSelection) = mentionAdjuster.adjustMentionsForDeletion( + mentions = mentions, + deletedLength = deletedLength, + text = text, + selection = selection + ) + + // Then + val expectedMention = UIMention(start = 10, length = 5, handler = "@user", userId = TestUser.USER_ID) + assertEquals(listOf(expectedMention), updatedMentions) + assertEquals(selection, updatedSelection) + } + + @Test + fun `Given cursor is at end of a mention, When adjustMentionsForDeletion is called, Then selection is updated to the mention's range`() { + // Given + val mentions = listOf(UIMention(start = 0, length = 5, handler = "@user", userId = TestUser.USER_ID)) + val deletedLength = 1 // Simulate deleting 1 character inside the mention + val text = "@user" + val selection = TextRange(5, 5) + + // When + val (updatedMentions, updatedSelection) = mentionAdjuster.adjustMentionsForDeletion( + mentions = mentions, + deletedLength = deletedLength, + text = text, + selection = selection + ) + + // Then + val expectedMention = UIMention(start = 0, length = 5, handler = "@user", userId = TestUser.USER_ID) + assertEquals(listOf(expectedMention), updatedMentions) + assertEquals(TextRange(0, 5), updatedSelection) + } + + // --- adjustMentionsForInsertion Tests --- + + @Test + fun `Given inserted text does not affect any mentions, When adjustMentionsForInsertion is called, Then mentions remain unchanged`() { + // Given + val mentions = listOf(UIMention(start = 5, length = 5, handler = "@user", userId = TestUser.USER_ID)) + val addedLength = 0 + val text = "Hello world" + val selection = TextRange(0, 5) + + // When + val (updatedMentions, updatedSelection) = mentionAdjuster.adjustMentionsForInsertion( + mentions = mentions, + text = text, + selection = selection, + addedLength = addedLength + ) + + // Then + assertEquals(mentions, updatedMentions) + assertEquals(selection, updatedSelection) + } + + @Test + fun `Given inserted text shifts mentions, When adjustMentionsForInsertion is called, Then mentions are adjusted`() { + // Given + val mentions = listOf(UIMention(start = 5, length = 5, handler = "@user", userId = TestUser.USER_ID)) + val addedLength = 2 // Simulate inserting 2 characters before the mention + val text = "Hello @user" + val selection = TextRange(0, 5) + + // When + val (updatedMentions, updatedSelection) = mentionAdjuster.adjustMentionsForInsertion( + mentions = mentions, + text = text, + selection = selection, + addedLength = addedLength + ) + + // Then + val expectedMention = UIMention(start = 7, length = 5, handler = "@user", userId = TestUser.USER_ID) + assertEquals(listOf(expectedMention), updatedMentions) + assertEquals(selection, updatedSelection) + } + + @Test + fun `Given inserted text shifts mentions, When adjustMentionsForInsertion is called, Then mentions are adjusted accordingly`() { + // Given + val mentions = listOf(UIMention(start = 0, length = 5, handler = "@user", userId = TestUser.USER_ID)) + val addedLength = 3 // Simulate inserting 3 characters before the mention + val text = "Hel world" + val selection = TextRange(0, 5) + + // When + val (updatedMentions, updatedSelection) = mentionAdjuster.adjustMentionsForInsertion( + mentions = mentions, + text = text, + selection = selection, + addedLength = addedLength + ) + + // Then + val expectedMention = UIMention(start = 3, length = 5, handler = "@user", userId = TestUser.USER_ID) + assertEquals(listOf(expectedMention), updatedMentions) + assertEquals(selection, updatedSelection) + } +} diff --git a/app/src/test/kotlin/com/wire/android/ui/common/textfield/mention/MentionSelectionManagerTest.kt b/app/src/test/kotlin/com/wire/android/ui/common/textfield/mention/MentionSelectionManagerTest.kt new file mode 100644 index 00000000000..8daf2c2e19c --- /dev/null +++ b/app/src/test/kotlin/com/wire/android/ui/common/textfield/mention/MentionSelectionManagerTest.kt @@ -0,0 +1,105 @@ +/* + * Wire + * Copyright (C) 2024 Wire Swiss GmbH + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see http://www.gnu.org/licenses/. + */ +package com.wire.android.ui.common.textfield.mention + +import androidx.compose.ui.text.TextRange +import com.wire.android.framework.TestUser +import com.wire.android.ui.home.conversations.model.UIMention +import org.junit.Assert.assertEquals +import org.junit.Test + +class MentionSelectionManagerTest { + + private val selectionManager: MentionSelectionManager = MentionSelectionManager() + + @Test + fun `Given same old and new selection, When updateSelectionForMention is called, Then selection remains unchanged`() { + // Given + val oldSelection = TextRange(0, 5) + val newSelection = TextRange(0, 5) // Same as old selection + val mentions = listOf(UIMention(start = 0, length = 5, handler = "@user", userId = TestUser.USER_ID)) + + // When + val updatedSelection = selectionManager.updateSelectionForMention( + oldSelection = oldSelection, + newSelection = newSelection, + mentions = mentions + ) + + // Then + assertEquals(newSelection, updatedSelection) + } + + @Test + fun `Given new selection inside a mention, When updateSelectionForMention is called, Then selection updates to the mention's range`() { + // Given + val oldSelection = TextRange(0, 5) + val newSelection = TextRange(3, 3) // Inside the mention range + val mentions = listOf(UIMention(start = 0, length = 5, handler = "@user", userId = TestUser.USER_ID)) + + // When + val updatedSelection = selectionManager.updateSelectionForMention( + oldSelection = oldSelection, + newSelection = newSelection, + mentions = mentions + ) + + // Then + assertEquals(TextRange(0, 5), updatedSelection) + } + + @Test + fun `Given new selection outside of any mention, When updateSelectionForMention is called, Then selection remains unchanged`() { + // Given + val oldSelection = TextRange(0, 5) + val newSelection = TextRange(10, 10) // Outside the mention range + val mentions = listOf(UIMention(start = 0, length = 5, handler = "@user", userId = TestUser.USER_ID)) + + // When + val updatedSelection = selectionManager.updateSelectionForMention( + oldSelection = oldSelection, + newSelection = newSelection, + mentions = mentions + ) + + // Then + assertEquals(newSelection, updatedSelection) // Should remain unchanged + } + + @Test + fun `Given multiple mentions, When new selection is inside one of them, Then selection updates to the correct mention's range`() { + // Given + val oldSelection = TextRange(0, 5) + val newSelection = TextRange(8, 8) // Inside the second mention + val mentions = listOf( + UIMention(start = 0, length = 5, handler = "@user1", userId = TestUser.USER_ID), + UIMention(start = 6, length = 5, handler = "@user2", userId = TestUser.SELF_USER_ID), + UIMention(start = 15, length = 5, handler = "@user2", userId = TestUser.SELF_USER_ID) + ) + + // When + val updatedSelection = selectionManager.updateSelectionForMention( + oldSelection = oldSelection, + newSelection = newSelection, + mentions = mentions + ) + + // Then + assertEquals(TextRange(6, 11), updatedSelection) + } +} diff --git a/app/src/test/kotlin/com/wire/android/ui/common/textfield/mention/MentionUpdateCoordinatorTest.kt b/app/src/test/kotlin/com/wire/android/ui/common/textfield/mention/MentionUpdateCoordinatorTest.kt new file mode 100644 index 00000000000..94dd56d4ece --- /dev/null +++ b/app/src/test/kotlin/com/wire/android/ui/common/textfield/mention/MentionUpdateCoordinatorTest.kt @@ -0,0 +1,181 @@ +/* + * Wire + * Copyright (C) 2024 Wire Swiss GmbH + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see http://www.gnu.org/licenses/. + */ +package com.wire.android.ui.common.textfield.mention + +import androidx.compose.ui.text.TextRange +import androidx.compose.ui.text.input.TextFieldValue +import com.wire.android.framework.TestUser +import com.wire.android.ui.home.conversations.model.UIMention +import com.wire.android.util.EMPTY +import io.mockk.MockKAnnotations +import io.mockk.every +import io.mockk.impl.annotations.MockK +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class MentionUpdateCoordinatorTest { + + @MockK + private lateinit var mentionAdjuster: MentionAdjuster + + @MockK + private lateinit var selectionManager: MentionSelectionManager + + init { + MockKAnnotations.init(this) + } + + private val coordinator = MentionUpdateCoordinator( + mentionAdjuster = mentionAdjuster, + selectionManager = selectionManager + ) + + @Test + fun `Given empty new text, When handle is called, Then mentions should be cleared`() { + // Given + val oldTextFieldValue = TextFieldValue(text = "Hello", selection = TextRange(0, 5)) + val newTextFieldValue = TextFieldValue(text = "", selection = TextRange(0, 0)) + val mentions = listOf(UIMention(start = 0, length = 5, handler = "@user", userId = TestUser.USER_ID)) + var isInvoked = false + // When + val updatedTextFieldValue = coordinator.handle( + oldTextFieldValue, + newTextFieldValue, + mentions, + updateMentions = { isInvoked = true } + ) + + // Then + assertEquals(String.EMPTY, updatedTextFieldValue.text) + assertTrue(isInvoked) + } + + @Test + fun `Given no mention change, When handle is called, Then mentions should remain unchanged`() { + // Given + val oldTextFieldValue = TextFieldValue(text = "Hello", selection = TextRange(0, 5)) + val newTextFieldValue = TextFieldValue(text = "Hello", selection = TextRange(0, 5)) + val mentions = listOf(UIMention(start = 0, length = 5, handler = "@user", userId = TestUser.USER_ID)) + var isInvoked = false + every { + selectionManager.updateSelectionForMention(any(), any(), any()) + } returns TextRange(0, 5) + + // When + val updatedTextFieldValue = coordinator.handle( + oldTextFieldValue, + newTextFieldValue, + mentions, + updateMentions = { isInvoked = true } + ) + + // Then + assertEquals("Hello", updatedTextFieldValue.text) + assertEquals(TextRange(0, 5), updatedTextFieldValue.selection) + assertFalse(isInvoked) + } + + @Test + fun `Given text deletion, When handle is called, Then mentions and selection should adjust`() { + // Given + val oldTextFieldValue = TextFieldValue(text = "Hello @user", selection = TextRange(0, 11)) + val newTextFieldValue = TextFieldValue(text = "Hello", selection = TextRange(0, 5)) + val mentions = listOf(UIMention(start = 6, length = 5, handler = "@user", userId = TestUser.USER_ID)) + var isInvoked = false + + every { + mentionAdjuster.adjustMentionsForDeletion( + mentions = mentions, + deletedLength = 6, + text = "Hello", + selection = newTextFieldValue.selection + ) + } returns Pair(listOf(UIMention(start = 6, length = 5, handler = "@user", userId = TestUser.USER_ID)), TextRange(0, 5)) + + // When + val updatedTextFieldValue = coordinator.handle( + oldTextFieldValue, + newTextFieldValue, + mentions, + updateMentions = { isInvoked = true } + ) + + // Then + assertEquals("Hello", updatedTextFieldValue.text) + assertEquals(TextRange(0, 5), updatedTextFieldValue.selection) + assertTrue(isInvoked) + } + + @Test + fun `Given text insertion, When handle is called, Then mentions should shift accordingly`() { + // Given + val oldTextFieldValue = TextFieldValue(text = "Hello", selection = TextRange(0, 5)) + val newTextFieldValue = TextFieldValue(text = "Hello @user", selection = TextRange(0, 5)) + val mentions = listOf(UIMention(start = 6, length = 5, handler = "@user", userId = TestUser.USER_ID)) + var isInvoked = false + + every { + mentionAdjuster.adjustMentionsForInsertion( + mentions = mentions, + addedLength = 6, + text = any(), + selection = any() + ) + } returns Pair(listOf(UIMention(start = 12, length = 5, handler = "@user", userId = TestUser.USER_ID)), TextRange(0, 5)) + + // When + val updatedTextFieldValue = coordinator.handle( + oldTextFieldValue, + newTextFieldValue, + mentions, + updateMentions = { isInvoked = true } + ) + + // Then + assertEquals("Hello @user", updatedTextFieldValue.text) + assertEquals(TextRange(0, 5), updatedTextFieldValue.selection) + assertTrue(isInvoked) + } + + @Test + fun `Given selection inside mention, When handle is called, Then selection should update`() { + // Given + val oldTextFieldValue = TextFieldValue(text = "Hello @user", selection = TextRange(0, 5)) + val newTextFieldValue = TextFieldValue(text = "Hello @user", selection = TextRange(0, 5)) + val mentions = listOf(UIMention(start = 6, length = 5, handler = "@user", userId = TestUser.USER_ID)) + var isInvoked = false + + every { + selectionManager.updateSelectionForMention(any(), any(), any()) + } returns TextRange(6, 11) + + // When + val updatedTextFieldValue = coordinator.handle( + oldTextFieldValue, + newTextFieldValue, + mentions, + updateMentions = { isInvoked = true } + ) + + // Then + assertEquals(TextRange(6, 11), updatedTextFieldValue.selection) + assertFalse(isInvoked) + } +} diff --git a/kalium b/kalium index 4c476f7390e..b391c817503 160000 --- a/kalium +++ b/kalium @@ -1 +1 @@ -Subproject commit 4c476f7390ed6a7ffe27bc9ad0ce649c532ac35b +Subproject commit b391c8175037b57766789f353452bd45abe364a5