diff --git a/dependencies.gradle b/dependencies.gradle index 5f4df15860..9fbaf5608d 100644 --- a/dependencies.gradle +++ b/dependencies.gradle @@ -101,7 +101,7 @@ ext.libs = [ ], element : [ 'opusencoder' : "io.element.android:opusencoder:1.1.0", - 'wysiwyg' : "io.element.android:wysiwyg:1.2.2" + 'wysiwyg' : "io.element.android:wysiwyg:2.2.0" ], squareup : [ 'moshi' : "com.squareup.moshi:moshi:$moshi", @@ -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 05a23bf0df..a5f368ff9d 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/androidTest/java/im/vector/app/core/utils/TestSpan.kt b/vector/src/androidTest/java/im/vector/app/core/utils/TestSpan.kt index ebbe565642..fe6f6cb987 100644 --- a/vector/src/androidTest/java/im/vector/app/core/utils/TestSpan.kt +++ b/vector/src/androidTest/java/im/vector/app/core/utils/TestSpan.kt @@ -23,7 +23,7 @@ import android.text.Spanned import android.text.style.StrikethroughSpan import androidx.core.text.getSpans import im.vector.app.features.html.HtmlCodeSpan -import io.element.android.wysiwyg.spans.InlineCodeSpan +import io.element.android.wysiwyg.view.spans.InlineCodeSpan import io.mockk.justRun import io.mockk.mockk import io.mockk.slot 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..2a5113ef6c 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 @@ -99,6 +107,9 @@ class AutoCompleter @AssistedInject constructor( } private fun setupCommands(backgroundDrawable: Drawable, editText: EditText) { + // Rich text editor is not yet supported + if (editText is EditorEditText) return + Autocomplete.on(editText) .with(commandAutocompletePolicy) .with(autocompleteCommandPresenter) @@ -128,17 +139,15 @@ class AutoCompleter @AssistedInject constructor( .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) { @@ -166,6 +175,9 @@ class AutoCompleter @AssistedInject constructor( } private fun setupEmojis(backgroundDrawable: Drawable, editText: EditText) { + // Rich text editor is not yet supported + if (editText is EditorEditText) return + Autocomplete.on(editText) .with(CharPolicy(TRIGGER_AUTO_COMPLETE_EMOJIS, false)) .with(autocompleteEmojiPresenter) @@ -197,7 +209,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..9dda413af7 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 @@ -315,9 +316,7 @@ class MessageComposerFragment : VectorBaseFragment(), A val composerEditText = composer.editText composerEditText.setHint(R.string.room_message_placeholder) - if (!vectorPreferences.isRichTextEditorEnabled()) { - autoCompleter.setup(composerEditText) - } + autoCompleter.setup(composerEditText) observerUserTyping() @@ -404,6 +403,13 @@ 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 sendTextMessage(text: CharSequence, formattedText: String? = null) { 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 a821458939..ac64d18737 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,10 +49,14 @@ 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.inputhandlers.models.InlineFormat -import io.element.android.wysiwyg.inputhandlers.models.LinkAction +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 import uniffi.wysiwyg_composer.ActionState import uniffi.wysiwyg_composer.ComposerAction @@ -102,6 +106,8 @@ internal class RichTextComposerLayout @JvmOverloads constructor( override val attachmentButton: ImageButton get() = views.attachmentButton + var pillDisplayHandler: PillDisplayHandler? = null + // Border of the EditText private val borderShapeDrawable: MaterialShapeDrawable by lazy { MaterialShapeDrawable().apply { @@ -227,6 +233,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) } @@ -269,7 +285,7 @@ internal class RichTextComposerLayout @JvmOverloads constructor( views.richTextComposerEditText.getLinkAction()?.let { when (it) { LinkAction.InsertLink -> callback?.onSetLink(isTextSupported = true, initialLink = null) - is LinkAction.SetLink -> callback?.onSetLink(isTextSupported = false, initialLink = it.currentLink) + is LinkAction.SetLink -> callback?.onSetLink(isTextSupported = false, initialLink = it.currentUrl) } } } 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..b657257b85 --- /dev/null +++ b/vector/src/main/java/im/vector/app/features/home/room/detail/composer/mentions/PillDisplayHandler.kt @@ -0,0 +1,78 @@ +/* + * 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 + +/** + * 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() + 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/main/java/im/vector/app/features/html/EventHtmlRenderer.kt b/vector/src/main/java/im/vector/app/features/html/EventHtmlRenderer.kt index cb3f12d867..5874474965 100644 --- a/vector/src/main/java/im/vector/app/features/html/EventHtmlRenderer.kt +++ b/vector/src/main/java/im/vector/app/features/html/EventHtmlRenderer.kt @@ -43,7 +43,7 @@ import im.vector.app.core.di.ActiveSessionHolder import im.vector.app.core.resources.ColorProvider import im.vector.app.core.utils.DimensionConverter import im.vector.app.features.settings.VectorPreferences -import io.element.android.wysiwyg.spans.InlineCodeSpan +import io.element.android.wysiwyg.view.spans.InlineCodeSpan import io.noties.markwon.AbstractMarkwonPlugin import io.noties.markwon.Markwon import io.noties.markwon.MarkwonPlugin diff --git a/vector/src/main/java/im/vector/app/features/html/HtmlCodeHandlers.kt b/vector/src/main/java/im/vector/app/features/html/HtmlCodeHandlers.kt index 3175996ba1..7ffd9ceb84 100644 --- a/vector/src/main/java/im/vector/app/features/html/HtmlCodeHandlers.kt +++ b/vector/src/main/java/im/vector/app/features/html/HtmlCodeHandlers.kt @@ -18,8 +18,8 @@ package im.vector.app.features.html import im.vector.app.core.utils.DimensionConverter import im.vector.app.features.settings.VectorPreferences -import io.element.android.wysiwyg.spans.CodeBlockSpan -import io.element.android.wysiwyg.spans.InlineCodeSpan +import io.element.android.wysiwyg.view.spans.CodeBlockSpan +import io.element.android.wysiwyg.view.spans.InlineCodeSpan import io.noties.markwon.MarkwonVisitor import io.noties.markwon.SpannableBuilder import io.noties.markwon.core.MarkwonTheme 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..6529cf162e --- /dev/null +++ b/vector/src/test/java/im/vector/app/features/home/room/detail/composer/mentions/PillDisplayHandlerTest.kt @@ -0,0 +1,198 @@ +/* + * 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" + } + + @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) + } + + @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) + } + + 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 + ) + + 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 + } + } +}