diff --git a/changelog.d/8440.feature b/changelog.d/8440.feature new file mode 100644 index 0000000000..19c9d919eb --- /dev/null +++ b/changelog.d/8440.feature @@ -0,0 +1 @@ +[Rich text editor] Add mentions and slash commands \ No newline at end of file diff --git a/dependencies.gradle b/dependencies.gradle index 78d842a7a5..355f5de661 100644 --- a/dependencies.gradle +++ b/dependencies.gradle @@ -172,6 +172,7 @@ ext.libs = [ 'kluent' : "org.amshove.kluent:kluent-android:1.73", 'timberJunitRule' : "net.lachlanmckee:timber-junit-rule:1.0.1", 'junit' : "junit:junit:4.13.2", + 'robolectric' : "org.robolectric:robolectric:4.9", ] ] diff --git a/dependencies_groups.gradle b/dependencies_groups.gradle index 6292b5d231..66d07f258b 100644 --- a/dependencies_groups.gradle +++ b/dependencies_groups.gradle @@ -189,6 +189,7 @@ ext.groups = [ 'org.codehaus.groovy', 'org.codehaus.mojo', 'org.codehaus.woodstox', + 'org.conscrypt', 'org.eclipse.ee4j', 'org.ec4j.core', 'org.freemarker', @@ -221,6 +222,7 @@ ext.groups = [ 'org.ow2.asm', 'org.ow2.asm', 'org.reactivestreams', + 'org.robolectric', 'org.slf4j', 'org.sonatype.oss', 'org.testng', diff --git a/vector/build.gradle b/vector/build.gradle index fb9c63ad2a..dab8b8bdeb 100644 --- a/vector/build.gradle +++ b/vector/build.gradle @@ -299,6 +299,7 @@ dependencies { testImplementation libs.tests.kluent testImplementation libs.mockk.mockk testImplementation libs.androidx.coreTesting + testImplementation libs.tests.robolectric // Plant Timber tree for test testImplementation libs.tests.timberJunitRule testImplementation libs.airbnb.mavericksTesting diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/AutoCompleter.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/AutoCompleter.kt index 51e1fb06f2..568f4cf9e7 100644 --- a/vector/src/main/java/im/vector/app/features/home/room/detail/AutoCompleter.kt +++ b/vector/src/main/java/im/vector/app/features/home/room/detail/AutoCompleter.kt @@ -40,23 +40,31 @@ import im.vector.app.features.displayname.getBestName import im.vector.app.features.home.AvatarRenderer import im.vector.app.features.html.PillImageSpan import im.vector.app.features.themes.ThemeUtils +import io.element.android.wysiwyg.EditorEditText +import org.matrix.android.sdk.api.session.Session +import org.matrix.android.sdk.api.session.permalinks.PermalinkService import org.matrix.android.sdk.api.session.room.model.RoomSummary import org.matrix.android.sdk.api.util.MatrixItem import org.matrix.android.sdk.api.util.toEveryoneInRoomMatrixItem import org.matrix.android.sdk.api.util.toMatrixItem import org.matrix.android.sdk.api.util.toRoomAliasMatrixItem +import timber.log.Timber class AutoCompleter @AssistedInject constructor( @Assisted val roomId: String, @Assisted val isInThreadTimeline: Boolean, + private val session: Session, private val avatarRenderer: AvatarRenderer, private val commandAutocompletePolicy: CommandAutocompletePolicy, autocompleteCommandPresenterFactory: AutocompleteCommandPresenter.Factory, private val autocompleteMemberPresenterFactory: AutocompleteMemberPresenter.Factory, private val autocompleteRoomPresenter: AutocompleteRoomPresenter, - private val autocompleteEmojiPresenter: AutocompleteEmojiPresenter + private val autocompleteEmojiPresenter: AutocompleteEmojiPresenter, ) { + private val permalinkService: PermalinkService + get() = session.permalinkService() + private lateinit var autocompleteMemberPresenter: AutocompleteMemberPresenter @AssistedFactory @@ -79,6 +87,7 @@ class AutoCompleter @AssistedInject constructor( } private lateinit var glideRequests: GlideRequests + private val autocompletes: MutableSet> = hashSetOf() fun setup(editText: EditText) { this.editText = editText @@ -90,26 +99,41 @@ class AutoCompleter @AssistedInject constructor( setupRooms(backgroundDrawable, editText) } + fun setEnabled(isEnabled: Boolean) = + autocompletes.forEach { + if (!isEnabled) { it.dismissPopup() } + it.setEnabled(isEnabled) + } + fun clear() { this.editText = null autocompleteEmojiPresenter.clear() autocompleteRoomPresenter.clear() autocompleteCommandPresenter.clear() autocompleteMemberPresenter.clear() + autocompletes.forEach { + it.setEnabled(false) + it.dismissPopup() + } + autocompletes.clear() } private fun setupCommands(backgroundDrawable: Drawable, editText: EditText) { - Autocomplete.on(editText) + autocompletes += Autocomplete.on(editText) .with(commandAutocompletePolicy) .with(autocompleteCommandPresenter) .with(ELEVATION_DP) .with(backgroundDrawable) .with(object : AutocompleteCallback { override fun onPopupItemClicked(editable: Editable, item: Command): Boolean { - editable.clear() - editable - .append(item.command) - .append(" ") + if (editText is EditorEditText) { + editText.replaceTextSuggestion(item.command) + } else { + editable.clear() + editable + .append(item.command) + .append(" ") + } return true } @@ -121,24 +145,22 @@ class AutoCompleter @AssistedInject constructor( private fun setupMembers(backgroundDrawable: ColorDrawable, editText: EditText) { autocompleteMemberPresenter = autocompleteMemberPresenterFactory.create(roomId) - Autocomplete.on(editText) + autocompletes += Autocomplete.on(editText) .with(CharPolicy(TRIGGER_AUTO_COMPLETE_MEMBERS, true)) .with(autocompleteMemberPresenter) .with(ELEVATION_DP) .with(backgroundDrawable) .with(object : AutocompleteCallback { override fun onPopupItemClicked(editable: Editable, item: AutocompleteMemberItem): Boolean { - return when (item) { - is AutocompleteMemberItem.Header -> false // do nothing header is not clickable - is AutocompleteMemberItem.RoomMember -> { - insertMatrixItem(editText, editable, TRIGGER_AUTO_COMPLETE_MEMBERS, item.roomMemberSummary.toMatrixItem()) - true - } - is AutocompleteMemberItem.Everyone -> { - insertMatrixItem(editText, editable, TRIGGER_AUTO_COMPLETE_MEMBERS, item.roomSummary.toEveryoneInRoomMatrixItem()) - true - } - } + val matrixItem = when (item) { + is AutocompleteMemberItem.Header -> null // do nothing header is not clickable + is AutocompleteMemberItem.RoomMember -> item.roomMemberSummary.toMatrixItem() + is AutocompleteMemberItem.Everyone -> item.roomSummary.toEveryoneInRoomMatrixItem() + } ?: return false + + insertMatrixItem(editText, editable, TRIGGER_AUTO_COMPLETE_MEMBERS, matrixItem) + + return true } override fun onPopupVisibilityChanged(shown: Boolean) { @@ -148,7 +170,7 @@ class AutoCompleter @AssistedInject constructor( } private fun setupRooms(backgroundDrawable: ColorDrawable, editText: EditText) { - Autocomplete.on(editText) + autocompletes += Autocomplete.on(editText) .with(CharPolicy(TRIGGER_AUTO_COMPLETE_ROOMS, true)) .with(autocompleteRoomPresenter) .with(ELEVATION_DP) @@ -166,7 +188,10 @@ class AutoCompleter @AssistedInject constructor( } private fun setupEmojis(backgroundDrawable: Drawable, editText: EditText) { - Autocomplete.on(editText) + // Rich text editor is not yet supported + if (editText is EditorEditText) return + + autocompletes += Autocomplete.on(editText) .with(CharPolicy(TRIGGER_AUTO_COMPLETE_EMOJIS, false)) .with(autocompleteEmojiPresenter) .with(ELEVATION_DP) @@ -197,7 +222,41 @@ class AutoCompleter @AssistedInject constructor( .build() } - private fun insertMatrixItem(editText: EditText, editable: Editable, firstChar: Char, matrixItem: MatrixItem) { + private fun insertMatrixItem(editText: EditText, editable: Editable, firstChar: Char, matrixItem: MatrixItem) = + if (editText is EditorEditText) { + insertMatrixItemIntoRichTextEditor(editText, matrixItem) + } else { + insertMatrixItemIntoEditable(editText, editable, firstChar, matrixItem) + } + + private fun insertMatrixItemIntoRichTextEditor(editorEditText: EditorEditText, matrixItem: MatrixItem) { + if (matrixItem is MatrixItem.EveryoneInRoomItem) { + editorEditText.replaceTextSuggestion(matrixItem.displayName) + return + } + + val permalink = permalinkService.createPermalink(matrixItem.id) + + if (permalink == null) { + Timber.e(NullPointerException("Cannot autocomplete as permalink is null")) + return + } + + val linkText = when (matrixItem) { + is MatrixItem.RoomAliasItem, + is MatrixItem.RoomItem, + is MatrixItem.SpaceItem -> + matrixItem.id + is MatrixItem.EveryoneInRoomItem, + is MatrixItem.UserItem, + is MatrixItem.EventItem -> + matrixItem.getBestName() + } + + editorEditText.setLinkSuggestion(url = permalink, text = linkText) + } + + private fun insertMatrixItemIntoEditable(editText: EditText, editable: Editable, firstChar: Char, matrixItem: MatrixItem) { // Detect last firstChar and remove it var startIndex = editable.lastIndexOf(firstChar) if (startIndex == -1) { diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/TimelineViewModel.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/TimelineViewModel.kt index 746396d1e1..3793ed18d2 100644 --- a/vector/src/main/java/im/vector/app/features/home/room/detail/TimelineViewModel.kt +++ b/vector/src/main/java/im/vector/app/features/home/room/detail/TimelineViewModel.kt @@ -765,6 +765,9 @@ class TimelineViewModel @AssistedInject constructor( return room?.membershipService()?.getRoomMember(userId) } + fun getRoom(roomId: String): RoomSummary? = + session.roomService().getRoomSummary(roomId) + private fun handleComposerFocusChange(action: RoomDetailAction.ComposerFocusChange) { if (room == null) return // Ensure outbound session keys diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/composer/MessageComposerFragment.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/composer/MessageComposerFragment.kt index d9459d259a..338a635818 100644 --- a/vector/src/main/java/im/vector/app/features/home/room/detail/composer/MessageComposerFragment.kt +++ b/vector/src/main/java/im/vector/app/features/home/room/detail/composer/MessageComposerFragment.kt @@ -83,6 +83,7 @@ import im.vector.app.features.home.room.detail.TimelineViewModel import im.vector.app.features.home.room.detail.composer.link.SetLinkFragment import im.vector.app.features.home.room.detail.composer.link.SetLinkSharedAction import im.vector.app.features.home.room.detail.composer.link.SetLinkSharedActionViewModel +import im.vector.app.features.home.room.detail.composer.mentions.PillDisplayHandler import im.vector.app.features.home.room.detail.composer.voice.VoiceMessageRecorderView import im.vector.app.features.home.room.detail.timeline.action.MessageSharedActionViewModel import im.vector.app.features.home.room.detail.upgrade.MigrateRoomBottomSheet @@ -100,6 +101,7 @@ import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.onEach import org.matrix.android.sdk.api.session.Session import org.matrix.android.sdk.api.session.content.ContentAttachmentData +import org.matrix.android.sdk.api.session.permalinks.PermalinkService import org.matrix.android.sdk.api.util.MatrixItem import reactivecircus.flowbinding.android.view.focusChanges import reactivecircus.flowbinding.android.widget.textChanges @@ -122,11 +124,12 @@ class MessageComposerFragment : VectorBaseFragment(), A @Inject lateinit var session: Session @Inject lateinit var errorTracker: ErrorTracker + private val permalinkService: PermalinkService + get() = session.permalinkService() + private val roomId: String get() = withState(timelineViewModel) { it.roomId } - private val autoCompleter: AutoCompleter by lazy { - autoCompleterFactory.create(roomId, isThreadTimeLine()) - } + private val autoCompleters: MutableMap = hashMapOf() private val emojiPopup: EmojiPopup by lifecycleAwareLazy { createEmojiPopup() @@ -261,9 +264,8 @@ class MessageComposerFragment : VectorBaseFragment(), A override fun onDestroyView() { super.onDestroyView() - if (!vectorPreferences.isRichTextEditorEnabled()) { - autoCompleter.clear() - } + autoCompleters.values.forEach(AutoCompleter::clear) + autoCompleters.clear() messageComposerViewModel.endAllVoiceActions() } @@ -274,7 +276,12 @@ class MessageComposerFragment : VectorBaseFragment(), A (composer as? View)?.isVisible = messageComposerState.isComposerVisible composer.sendButton.isInvisible = !messageComposerState.isSendButtonVisible - (composer as? RichTextComposerLayout)?.isTextFormattingEnabled = attachmentState.isTextFormattingEnabled + (composer as? RichTextComposerLayout)?.also { + val isTextFormattingEnabled = attachmentState.isTextFormattingEnabled + it.isTextFormattingEnabled = isTextFormattingEnabled + autoCompleters[it.richTextEditText]?.setEnabled(isTextFormattingEnabled) + autoCompleters[it.plainTextEditText]?.setEnabled(!isTextFormattingEnabled) + } } private fun setupBottomSheet() { @@ -315,8 +322,11 @@ class MessageComposerFragment : VectorBaseFragment(), A val composerEditText = composer.editText composerEditText.setHint(R.string.room_message_placeholder) - if (!vectorPreferences.isRichTextEditorEnabled()) { - autoCompleter.setup(composerEditText) + (composer as? RichTextComposerLayout)?.let { + initAutoCompleter(it.richTextEditText) + initAutoCompleter(it.plainTextEditText) + } ?: run { + initAutoCompleter(composer.editText) } observerUserTyping() @@ -404,6 +414,21 @@ class MessageComposerFragment : VectorBaseFragment(), A SetLinkFragment.show(isTextSupported, initialLink, childFragmentManager) } } + (composer as? RichTextComposerLayout)?.pillDisplayHandler = PillDisplayHandler( + roomId = roomId, + getRoom = timelineViewModel::getRoom, + getMember = timelineViewModel::getMember, + ) { matrixItem: MatrixItem -> + PillImageSpan(glideRequests, avatarRenderer, requireContext(), matrixItem) + } + } + + private fun initAutoCompleter(editText: EditText) { + if (autoCompleters.containsKey(editText)) return + + autoCompleters[editText] = + autoCompleterFactory.create(roomId, isThreadTimeLine()) + .also { it.setup(editText) } } private fun sendTextMessage(text: CharSequence, formattedText: String? = null) { @@ -435,12 +460,12 @@ class MessageComposerFragment : VectorBaseFragment(), A } private fun renderRegularMode(content: CharSequence) { - autoCompleter.exitSpecialMode() + autoCompleters.values.forEach(AutoCompleter::exitSpecialMode) composer.renderComposerMode(MessageComposerMode.Normal(content)) } private fun renderSpecialMode(mode: MessageComposerMode.Special) { - autoCompleter.enterSpecialMode() + autoCompleters.values.forEach(AutoCompleter::enterSpecialMode) composer.renderComposerMode(mode) } @@ -771,30 +796,37 @@ class MessageComposerFragment : VectorBaseFragment(), A } else { val roomMember = timelineViewModel.getMember(userId) val displayName = sanitizeDisplayName(roomMember?.displayName ?: userId) - val pill = buildSpannedString { - append(displayName) - setSpan( - PillImageSpan( - glideRequests, - avatarRenderer, - requireContext(), - MatrixItem.UserItem(userId, displayName, roomMember?.avatarUrl) - ) - .also { it.bind(composer.editText) }, - 0, - displayName.length, - Spannable.SPAN_EXCLUSIVE_EXCLUSIVE - ) - append(if (startToCompose) ": " else " ") - } - if (startToCompose) { - if (displayName.startsWith("/")) { + if ((composer as? RichTextComposerLayout)?.isTextFormattingEnabled == true) { + // Rich text editor is enabled so we need to use its APIs + permalinkService.createPermalink(userId)?.let { url -> + (composer as RichTextComposerLayout).insertMention(url, displayName) + composer.editText.append(" ") + } + } else { + val pill = buildSpannedString { + append(displayName) + setSpan( + PillImageSpan( + glideRequests, + avatarRenderer, + requireContext(), + MatrixItem.UserItem(userId, displayName, roomMember?.avatarUrl), + ) + .also { it.bind(composer.editText) }, + 0, + displayName.length, + Spannable.SPAN_EXCLUSIVE_EXCLUSIVE + ) + append(if (startToCompose) ": " else " ") + } + if (startToCompose && displayName.startsWith("/")) { // Ensure displayName will not be interpreted as a Slash command composer.editText.append("\\") } - composer.editText.append(pill) - } else { - composer.editText.text?.insert(composer.editText.selectionStart, pill) + // Always use EditText.getText().insert for adding pills as TextView.append doesn't appear + // to upgrade to BufferType.Spannable as hinted at in the docs: + // https://developer.android.com/reference/android/widget/TextView#append(java.lang.CharSequence) + composer.editText.text.insert(composer.editText.selectionStart, pill) } } focusComposerAndShowKeyboard() diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/composer/RichTextComposerLayout.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/composer/RichTextComposerLayout.kt index baba98ed8b..48163a43bf 100644 --- a/vector/src/main/java/im/vector/app/features/home/room/detail/composer/RichTextComposerLayout.kt +++ b/vector/src/main/java/im/vector/app/features/home/room/detail/composer/RichTextComposerLayout.kt @@ -49,7 +49,11 @@ import im.vector.app.core.utils.DimensionConverter import im.vector.app.databinding.ComposerRichTextLayoutBinding import im.vector.app.databinding.ViewRichTextMenuButtonBinding import im.vector.app.features.home.room.detail.composer.images.UriContentListener +import im.vector.app.features.home.room.detail.composer.mentions.PillDisplayHandler import io.element.android.wysiwyg.EditorEditText +import io.element.android.wysiwyg.display.KeywordDisplayHandler +import io.element.android.wysiwyg.display.LinkDisplayHandler +import io.element.android.wysiwyg.display.TextDisplay import io.element.android.wysiwyg.utils.RustErrorCollector import io.element.android.wysiwyg.view.models.InlineFormat import io.element.android.wysiwyg.view.models.LinkAction @@ -102,6 +106,13 @@ internal class RichTextComposerLayout @JvmOverloads constructor( override val attachmentButton: ImageButton get() = views.attachmentButton + val richTextEditText: EditText get() = + views.richTextComposerEditText + val plainTextEditText: EditText get() = + views.plainTextComposerEditText + + var pillDisplayHandler: PillDisplayHandler? = null + // Border of the EditText private val borderShapeDrawable: MaterialShapeDrawable by lazy { MaterialShapeDrawable().apply { @@ -227,6 +238,16 @@ internal class RichTextComposerLayout @JvmOverloads constructor( views.composerEditTextOuterBorder.background = borderShapeDrawable setupRichTextMenu() + views.richTextComposerEditText.linkDisplayHandler = LinkDisplayHandler { text, url -> + pillDisplayHandler?.resolveLinkDisplay(text, url) ?: TextDisplay.Plain + } + views.richTextComposerEditText.keywordDisplayHandler = object : KeywordDisplayHandler { + override val keywords: List + get() = pillDisplayHandler?.keywords.orEmpty() + + override fun resolveKeywordDisplay(text: String): TextDisplay = + pillDisplayHandler?.resolveKeywordDisplay(text) ?: TextDisplay.Plain + } updateTextFieldBorder(isFullScreen) } @@ -284,6 +305,10 @@ internal class RichTextComposerLayout @JvmOverloads constructor( fun removeLink() = views.richTextComposerEditText.removeLink() + // Update the API to insertMention when available + fun insertMention(url: String, displayText: String) = + views.richTextComposerEditText.insertLink(url, displayText) + @SuppressLint("ClickableViewAccessibility") private fun disallowParentInterceptTouchEvent(view: View) { view.setOnTouchListener { v, event -> diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/composer/mentions/PillDisplayHandler.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/composer/mentions/PillDisplayHandler.kt new file mode 100644 index 0000000000..c2b71ea15b --- /dev/null +++ b/vector/src/main/java/im/vector/app/features/home/room/detail/composer/mentions/PillDisplayHandler.kt @@ -0,0 +1,80 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package im.vector.app.features.home.room.detail.composer.mentions + +import android.text.style.ReplacementSpan +import io.element.android.wysiwyg.display.KeywordDisplayHandler +import io.element.android.wysiwyg.display.LinkDisplayHandler +import io.element.android.wysiwyg.display.TextDisplay +import org.matrix.android.sdk.api.session.permalinks.PermalinkData +import org.matrix.android.sdk.api.session.permalinks.PermalinkParser +import org.matrix.android.sdk.api.session.room.model.RoomMemberSummary +import org.matrix.android.sdk.api.session.room.model.RoomSummary +import org.matrix.android.sdk.api.util.MatrixItem +import org.matrix.android.sdk.api.util.toEveryoneInRoomMatrixItem +import org.matrix.android.sdk.api.util.toMatrixItem +import org.matrix.android.sdk.api.util.toRoomAliasMatrixItem + +/** + * A rich text editor [LinkDisplayHandler] and [KeywordDisplayHandler] + * that helps with replacing user and room links with pills. + */ +internal class PillDisplayHandler( + private val roomId: String, + private val getRoom: (roomId: String) -> RoomSummary?, + private val getMember: (userId: String) -> RoomMemberSummary?, + private val replacementSpanFactory: (matrixItem: MatrixItem) -> ReplacementSpan, +) : LinkDisplayHandler, KeywordDisplayHandler { + override fun resolveLinkDisplay(text: String, url: String): TextDisplay { + val matrixItem = when (val permalink = PermalinkParser.parse(url)) { + is PermalinkData.UserLink -> { + val userId = permalink.userId + when (val roomMember = getMember(userId)) { + null -> MatrixItem.UserItem(userId, userId, null) + else -> roomMember.toMatrixItem() + } + } + is PermalinkData.RoomLink -> { + val roomId = permalink.roomIdOrAlias + val room = getRoom(roomId) + when { + room == null -> MatrixItem.RoomItem(roomId, roomId, null) + text == MatrixItem.NOTIFY_EVERYONE -> room.toEveryoneInRoomMatrixItem() + permalink.isRoomAlias -> room.toRoomAliasMatrixItem() + else -> room.toMatrixItem() + } + } + else -> + return TextDisplay.Plain + } + val replacement = replacementSpanFactory.invoke(matrixItem) + return TextDisplay.Custom(customSpan = replacement) + } + + override val keywords: List + get() = listOf(MatrixItem.NOTIFY_EVERYONE) + + override fun resolveKeywordDisplay(text: String): TextDisplay = + when (text) { + MatrixItem.NOTIFY_EVERYONE -> { + val matrixItem = getRoom(roomId)?.toEveryoneInRoomMatrixItem() + ?: MatrixItem.EveryoneInRoomItem(roomId) + TextDisplay.Custom(replacementSpanFactory.invoke(matrixItem)) + } + else -> TextDisplay.Plain + } +} diff --git a/vector/src/test/java/im/vector/app/features/home/room/detail/composer/mentions/PillDisplayHandlerTest.kt b/vector/src/test/java/im/vector/app/features/home/room/detail/composer/mentions/PillDisplayHandlerTest.kt new file mode 100644 index 0000000000..57c7aee420 --- /dev/null +++ b/vector/src/test/java/im/vector/app/features/home/room/detail/composer/mentions/PillDisplayHandlerTest.kt @@ -0,0 +1,248 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package im.vector.app.features.home.room.detail.composer.mentions + +import android.graphics.Canvas +import android.graphics.Paint +import android.text.style.ReplacementSpan +import io.element.android.wysiwyg.display.TextDisplay +import io.mockk.every +import io.mockk.mockk +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotNull +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.matrix.android.sdk.api.session.room.model.Membership +import org.matrix.android.sdk.api.session.room.model.RoomMemberSummary +import org.matrix.android.sdk.api.session.room.model.RoomSummary +import org.matrix.android.sdk.api.util.MatrixItem +import org.matrix.android.sdk.api.util.MatrixItem.Companion.NOTIFY_EVERYONE +import org.robolectric.RobolectricTestRunner + +@RunWith(RobolectricTestRunner::class) +internal class PillDisplayHandlerTest { + private val mockGetMember = mockk<(userId: String) -> RoomMemberSummary?>() + private val mockGetRoom = mockk<(roomId: String) -> RoomSummary?>() + private val fakeReplacementSpanFactory = { matrixItem: MatrixItem -> MatrixItemHolderSpan(matrixItem) } + + private companion object { + const val ROOM_ID = "!thisroom:matrix.org" + const val NON_MATRIX_URL = "https://example.com" + const val UNKNOWN_MATRIX_ROOM_ID = "!unknown:matrix.org" + const val UNKNOWN_MATRIX_ROOM_URL = "https://matrix.to/#/$UNKNOWN_MATRIX_ROOM_ID" + const val KNOWN_MATRIX_ROOM_ID = "!known:matrix.org" + const val KNOWN_MATRIX_ROOM_URL = "https://matrix.to/#/$KNOWN_MATRIX_ROOM_ID" + const val KNOWN_MATRIX_ROOM_AVATAR = "https://example.com/avatar.png" + const val KNOWN_MATRIX_ROOM_NAME = "known room" + const val UNKNOWN_MATRIX_USER_ID = "@unknown:matrix.org" + const val UNKNOWN_MATRIX_USER_URL = "https://matrix.to/#/$UNKNOWN_MATRIX_USER_ID" + const val KNOWN_MATRIX_USER_ID = "@known:matrix.org" + const val KNOWN_MATRIX_USER_URL = "https://matrix.to/#/$KNOWN_MATRIX_USER_ID" + const val KNOWN_MATRIX_USER_AVATAR = "https://example.com/avatar.png" + const val KNOWN_MATRIX_USER_NAME = "known user" + const val CUSTOM_DOMAIN_MATRIX_ROOM_URL = "https://customdomain/#/room/$KNOWN_MATRIX_ROOM_ID" + const val CUSTOM_DOMAIN_MATRIX_USER_URL = "https://customdomain.com/#/user/$KNOWN_MATRIX_USER_ID" + const val KNOWN_MATRIX_ROOM_ALIAS = "#known-alias:matrix.org" + const val KNOWN_MATRIX_ROOM_ALIAS_URL = "https://matrix.to/#/$KNOWN_MATRIX_ROOM_ALIAS" + } + + @Before + fun setUp() { + every { mockGetMember(UNKNOWN_MATRIX_USER_ID) } returns null + every { mockGetMember(KNOWN_MATRIX_USER_ID) } returns createFakeRoomMember(KNOWN_MATRIX_USER_NAME, KNOWN_MATRIX_USER_ID, KNOWN_MATRIX_USER_AVATAR) + every { mockGetRoom(UNKNOWN_MATRIX_ROOM_ID) } returns null + every { mockGetRoom(KNOWN_MATRIX_ROOM_ID) } returns createFakeRoom(KNOWN_MATRIX_ROOM_ID, KNOWN_MATRIX_ROOM_NAME, KNOWN_MATRIX_ROOM_AVATAR) + every { mockGetRoom(ROOM_ID) } returns createFakeRoom(ROOM_ID, KNOWN_MATRIX_ROOM_NAME, KNOWN_MATRIX_ROOM_AVATAR) + every { mockGetRoom(KNOWN_MATRIX_ROOM_ALIAS) } returns createFakeRoomWithAlias( + KNOWN_MATRIX_ROOM_ALIAS, + KNOWN_MATRIX_ROOM_ID, + KNOWN_MATRIX_ROOM_NAME, + KNOWN_MATRIX_ROOM_AVATAR + ) + } + + @Test + fun `when resolve non-matrix link, then it returns plain text`() { + val subject = createSubject() + + val result = subject.resolveLinkDisplay("text", NON_MATRIX_URL) + + assertEquals(TextDisplay.Plain, result) + } + + @Test + fun `when resolve unknown user link, then it returns generic custom pill`() { + val subject = createSubject() + + val matrixItem = subject.resolveLinkDisplay("text", UNKNOWN_MATRIX_USER_URL) + .getMatrixItem() + + assertEquals(MatrixItem.UserItem(UNKNOWN_MATRIX_USER_ID, UNKNOWN_MATRIX_USER_ID, null), matrixItem) + } + + @Test + fun `when resolve known user link, then it returns named custom pill`() { + val subject = createSubject() + + val matrixItem = subject.resolveLinkDisplay("text", KNOWN_MATRIX_USER_URL) + .getMatrixItem() + + assertEquals(MatrixItem.UserItem(KNOWN_MATRIX_USER_ID, KNOWN_MATRIX_USER_NAME, KNOWN_MATRIX_USER_AVATAR), matrixItem) + } + + @Test + fun `when resolve unknown room link, then it returns generic custom pill`() { + val subject = createSubject() + + val matrixItem = subject.resolveLinkDisplay("text", UNKNOWN_MATRIX_ROOM_URL) + .getMatrixItem() + + assertEquals(MatrixItem.RoomItem(UNKNOWN_MATRIX_ROOM_ID, UNKNOWN_MATRIX_ROOM_ID, null), matrixItem) + } + + @Test + fun `when resolve known room link, then it returns named custom pill`() { + val subject = createSubject() + + val matrixItem = subject.resolveLinkDisplay("text", KNOWN_MATRIX_ROOM_URL) + .getMatrixItem() + + assertEquals(MatrixItem.RoomItem(KNOWN_MATRIX_ROOM_ID, KNOWN_MATRIX_ROOM_NAME, KNOWN_MATRIX_ROOM_AVATAR), matrixItem) + } + + @Test + fun `when resolve @room link, then it returns room notification custom pill`() { + val subject = createSubject() + + val matrixItem = subject.resolveLinkDisplay("@room", KNOWN_MATRIX_ROOM_URL) + .getMatrixItem() + + assertEquals(MatrixItem.EveryoneInRoomItem(KNOWN_MATRIX_ROOM_ID, NOTIFY_EVERYONE, KNOWN_MATRIX_ROOM_AVATAR, KNOWN_MATRIX_ROOM_NAME), matrixItem) + } + + @Test + fun `when resolve @room keyword, then it returns room notification custom pill`() { + val subject = createSubject() + + val matrixItem = subject.resolveKeywordDisplay("@room") + .getMatrixItem() + + assertEquals(MatrixItem.EveryoneInRoomItem(ROOM_ID, NOTIFY_EVERYONE, KNOWN_MATRIX_ROOM_AVATAR, KNOWN_MATRIX_ROOM_NAME), matrixItem) + } + + @Test + fun `given cannot get current room, when resolve @room keyword, then it returns room notification custom pill`() { + val subject = createSubject() + every { mockGetRoom(ROOM_ID) } returns null + + val matrixItem = subject.resolveKeywordDisplay("@room") + .getMatrixItem() + + assertEquals(MatrixItem.EveryoneInRoomItem(ROOM_ID, NOTIFY_EVERYONE, null, null), matrixItem) + } + + @Test + fun `when get keywords, then it returns @room`() { + val subject = createSubject() + + assertEquals(listOf("@room"), subject.keywords) + } + + @Test + fun `when resolve known user for custom domain link, then it returns named custom pill`() { + val subject = createSubject() + + val matrixItem = subject.resolveLinkDisplay("text", CUSTOM_DOMAIN_MATRIX_USER_URL) + .getMatrixItem() + + assertEquals(MatrixItem.UserItem(KNOWN_MATRIX_USER_ID, KNOWN_MATRIX_USER_NAME, KNOWN_MATRIX_USER_AVATAR), matrixItem) + } + + @Test + fun `when resolve known room for custom domain link, then it returns named custom pill`() { + val subject = createSubject() + + val matrixItem = subject.resolveLinkDisplay("text", CUSTOM_DOMAIN_MATRIX_ROOM_URL) + .getMatrixItem() + + assertEquals(MatrixItem.RoomItem(KNOWN_MATRIX_ROOM_ID, KNOWN_MATRIX_ROOM_NAME, KNOWN_MATRIX_ROOM_AVATAR), matrixItem) + } + + @Test + fun `when resolve known room with alias link, then it returns named custom pill`() { + val subject = createSubject() + + val matrixItem = subject.resolveLinkDisplay("text", KNOWN_MATRIX_ROOM_ALIAS_URL) + .getMatrixItem() + + assertEquals(MatrixItem.RoomAliasItem(KNOWN_MATRIX_ROOM_ALIAS, KNOWN_MATRIX_ROOM_NAME, KNOWN_MATRIX_ROOM_AVATAR), matrixItem) + } + + private fun TextDisplay.getMatrixItem(): MatrixItem? { + val customSpan = this as? TextDisplay.Custom + assertNotNull("The URL did not resolve to a custom link display method", customSpan) + + val matrixItemHolderSpan = customSpan!!.customSpan as MatrixItemHolderSpan + return matrixItemHolderSpan.matrixItem + } + + private fun createSubject(): PillDisplayHandler = PillDisplayHandler( + roomId = ROOM_ID, + getRoom = mockGetRoom, + getMember = mockGetMember, + replacementSpanFactory = fakeReplacementSpanFactory + ) + + private fun createFakeRoomMember(displayName: String, userId: String, avatarUrl: String): RoomMemberSummary = RoomMemberSummary( + membership = Membership.JOIN, + userId = userId, + displayName = displayName, + avatarUrl = avatarUrl, + ) + + private fun createFakeRoom(roomId: String, roomName: String, avatarUrl: String): RoomSummary = RoomSummary( + roomId = roomId, + displayName = roomName, + avatarUrl = avatarUrl, + encryptionEventTs = null, + typingUsers = emptyList(), + isEncrypted = false + ) + + private fun createFakeRoomWithAlias(roomAlias: String, roomId: String, roomName: String, avatarUrl: String): RoomSummary = RoomSummary( + roomId = roomId, + displayName = roomName, + avatarUrl = avatarUrl, + encryptionEventTs = null, + typingUsers = emptyList(), + isEncrypted = false, + canonicalAlias = roomAlias + ) + + data class MatrixItemHolderSpan( + val matrixItem: MatrixItem + ) : ReplacementSpan() { + override fun draw(canvas: Canvas, text: CharSequence?, start: Int, end: Int, x: Float, top: Int, y: Int, bottom: Int, paint: Paint) { + // Do nothing + } + + override fun getSize(paint: Paint, text: CharSequence?, start: Int, end: Int, fm: Paint.FontMetricsInt?): Int { + return 0 + } + } +}