From 2d1dcd34c0a9e8a2bbaec84c5fd749630205b402 Mon Sep 17 00:00:00 2001 From: jonnyandrew Date: Fri, 5 May 2023 09:29:57 +0100 Subject: [PATCH 01/11] Add mentions to rich text editor --- dependencies.gradle | 3 +- dependencies_groups.gradle | 2 + vector/build.gradle | 1 + .../java/im/vector/app/core/utils/TestSpan.kt | 2 +- .../home/room/detail/AutoCompleter.kt | 72 +++++-- .../home/room/detail/TimelineViewModel.kt | 3 + .../composer/MessageComposerFragment.kt | 12 +- .../detail/composer/RichTextComposerLayout.kt | 22 +- .../composer/mentions/PillDisplayHandler.kt | 78 +++++++ .../app/features/html/EventHtmlRenderer.kt | 2 +- .../app/features/html/HtmlCodeHandlers.kt | 4 +- .../mentions/PillDisplayHandlerTest.kt | 198 ++++++++++++++++++ 12 files changed, 375 insertions(+), 24 deletions(-) create mode 100644 vector/src/main/java/im/vector/app/features/home/room/detail/composer/mentions/PillDisplayHandler.kt create mode 100644 vector/src/test/java/im/vector/app/features/home/room/detail/composer/mentions/PillDisplayHandlerTest.kt 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 + } + } +} From 29d8845792df5c53c7b099ae3f7d9c26baeecea2 Mon Sep 17 00:00:00 2001 From: jonnyandrew Date: Wed, 17 May 2023 11:59:20 +0100 Subject: [PATCH 02/11] Add slash commands to rich text editor --- .../features/home/room/detail/AutoCompleter.kt | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) 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 2a5113ef6c..3c6228caeb 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 @@ -107,9 +107,6 @@ 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) @@ -117,10 +114,14 @@ class AutoCompleter @AssistedInject constructor( .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 } From 24614bbbae2ac8e171505305e94bebcaf111291f Mon Sep 17 00:00:00 2001 From: jonnyandrew Date: Wed, 17 May 2023 12:04:53 +0100 Subject: [PATCH 03/11] Add changelog --- changelog.d/8440.feature | 1 + 1 file changed, 1 insertion(+) create mode 100644 changelog.d/8440.feature 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 From 3157a35b74a5f56206da856e22cd67d66ed92da8 Mon Sep 17 00:00:00 2001 From: jonnyandrew Date: Thu, 18 May 2023 11:05:04 +0100 Subject: [PATCH 04/11] Add autocomplete to plain text composer --- .../home/room/detail/AutoCompleter.kt | 20 ++++++++--- .../composer/MessageComposerFragment.kt | 35 +++++++++++++------ .../detail/composer/RichTextComposerLayout.kt | 5 +++ 3 files changed, 46 insertions(+), 14 deletions(-) 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 3c6228caeb..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 @@ -87,6 +87,7 @@ class AutoCompleter @AssistedInject constructor( } private lateinit var glideRequests: GlideRequests + private val autocompletes: MutableSet> = hashSetOf() fun setup(editText: EditText) { this.editText = editText @@ -98,16 +99,27 @@ 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) @@ -133,7 +145,7 @@ 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) @@ -158,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) @@ -179,7 +191,7 @@ class AutoCompleter @AssistedInject constructor( // Rich text editor is not yet supported if (editText is EditorEditText) return - Autocomplete.on(editText) + autocompletes += Autocomplete.on(editText) .with(CharPolicy(TRIGGER_AUTO_COMPLETE_EMOJIS, false)) .with(autocompleteEmojiPresenter) .with(ELEVATION_DP) 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 9dda413af7..a7fc72d2d4 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 @@ -125,9 +125,7 @@ class MessageComposerFragment : VectorBaseFragment(), A 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() @@ -262,9 +260,8 @@ class MessageComposerFragment : VectorBaseFragment(), A override fun onDestroyView() { super.onDestroyView() - if (!vectorPreferences.isRichTextEditorEnabled()) { - autoCompleter.clear() - } + autoCompleters.values.forEach(AutoCompleter::clear) + autoCompleters.clear() messageComposerViewModel.endAllVoiceActions() } @@ -275,7 +272,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() { @@ -316,7 +318,12 @@ class MessageComposerFragment : VectorBaseFragment(), A val composerEditText = composer.editText composerEditText.setHint(R.string.room_message_placeholder) - autoCompleter.setup(composerEditText) + (composer as? RichTextComposerLayout)?.let { + initAutoCompleter(it.richTextEditText) + initAutoCompleter(it.plainTextEditText) + } ?: run { + initAutoCompleter(composer.editText) + } observerUserTyping() @@ -412,6 +419,14 @@ class MessageComposerFragment : VectorBaseFragment(), A } } + 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) { if (lockSendButton) { Timber.w("Send button is locked") @@ -441,12 +456,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) } 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 ac64d18737..c028498405 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 @@ -106,6 +106,11 @@ 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 From f3db4a857af48ecc6ffe3f8782e80f6d06ef29ce Mon Sep 17 00:00:00 2001 From: David Langley Date: Fri, 9 Jun 2023 14:58:17 +0100 Subject: [PATCH 05/11] always use getText().insert for adding pills --- .../detail/composer/MessageComposerFragment.kt | 15 +++++++-------- 1 file changed, 7 insertions(+), 8 deletions(-) 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 a7fc72d2d4..6a3ca0f1c0 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 @@ -808,15 +808,14 @@ class MessageComposerFragment : VectorBaseFragment(), A ) append(if (startToCompose) ": " else " ") } - if (startToCompose) { - if (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) + if (startToCompose && displayName.startsWith("/")) { + // Ensure displayName will not be interpreted as a Slash command + composer.editText.append("\\") } + // 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() } From cfa0f95799237faf707cfb4d3b188fe7322b484f Mon Sep 17 00:00:00 2001 From: David Langley Date: Fri, 9 Jun 2023 16:02:09 +0100 Subject: [PATCH 06/11] Add PillDisplayHandler tests for custom domains. --- .../mentions/PillDisplayHandlerTest.kt | 22 +++++++++++++++++++ 1 file changed, 22 insertions(+) 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 index 6529cf162e..61347e67fb 100644 --- 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 @@ -55,6 +55,8 @@ internal class PillDisplayHandlerTest { 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" } @Before @@ -153,6 +155,26 @@ internal class PillDisplayHandlerTest { 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) + } + private fun TextDisplay.getMatrixItem(): MatrixItem? { val customSpan = this as? TextDisplay.Custom assertNotNull("The URL did not resolve to a custom link display method", customSpan) From 3b887fdf4ec8d82f5b35f06cb0743bae2e8273dc Mon Sep 17 00:00:00 2001 From: David Langley Date: Fri, 9 Jun 2023 18:12:52 +0100 Subject: [PATCH 07/11] Test room alias links and fix bug found with PillDisplayHander not handling them --- .../composer/mentions/PillDisplayHandler.kt | 2 ++ .../mentions/PillDisplayHandlerTest.kt | 24 +++++++++++++++++++ 2 files changed, 26 insertions(+) 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 index b657257b85..c2b71ea15b 100644 --- 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 @@ -27,6 +27,7 @@ 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] @@ -53,6 +54,7 @@ internal class PillDisplayHandler( when { room == null -> MatrixItem.RoomItem(roomId, roomId, null) text == MatrixItem.NOTIFY_EVERYONE -> room.toEveryoneInRoomMatrixItem() + permalink.isRoomAlias -> room.toRoomAliasMatrixItem() else -> room.toMatrixItem() } } 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 index 61347e67fb..ee24b7a7fc 100644 --- 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 @@ -57,7 +57,10 @@ internal class PillDisplayHandlerTest { 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" } + https://matrix.to/#/#rich-text-editor:matrix.org @Before fun setUp() { @@ -66,6 +69,7 @@ internal class PillDisplayHandlerTest { 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 @@ -175,6 +179,16 @@ internal class PillDisplayHandlerTest { 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) @@ -206,6 +220,16 @@ internal class PillDisplayHandlerTest { 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() { From cb64175c2bfcb394849d4a061ac979c061bf25b7 Mon Sep 17 00:00:00 2001 From: David Langley Date: Fri, 9 Jun 2023 19:43:26 +0100 Subject: [PATCH 08/11] Fix line length and typo. --- .../detail/composer/mentions/PillDisplayHandlerTest.kt | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) 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 index ee24b7a7fc..57c7aee420 100644 --- 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 @@ -60,7 +60,6 @@ internal class PillDisplayHandlerTest { const val KNOWN_MATRIX_ROOM_ALIAS = "#known-alias:matrix.org" const val KNOWN_MATRIX_ROOM_ALIAS_URL = "https://matrix.to/#/$KNOWN_MATRIX_ROOM_ALIAS" } - https://matrix.to/#/#rich-text-editor:matrix.org @Before fun setUp() { @@ -69,7 +68,12 @@ internal class PillDisplayHandlerTest { 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) + 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 From 9d239bf94da509006be576312c39762398ecd59f Mon Sep 17 00:00:00 2001 From: jonnyandrew Date: Mon, 19 Jun 2023 13:41:08 +0100 Subject: [PATCH 09/11] Use proper API to insert mention from timeline user --- .../composer/MessageComposerFragment.kt | 57 +++++++++++-------- .../detail/composer/RichTextComposerLayout.kt | 4 ++ 2 files changed, 38 insertions(+), 23 deletions(-) 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 6a3ca0f1c0..2b85fc482f 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 @@ -101,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 @@ -123,6 +124,9 @@ 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 autoCompleters: MutableMap = hashMapOf() @@ -792,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 ((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) + } + } 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("\\") + } + // 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) } - if (startToCompose && displayName.startsWith("/")) { - // Ensure displayName will not be interpreted as a Slash command - composer.editText.append("\\") - } - // 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 c028498405..14c9e0f95e 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 @@ -305,6 +305,10 @@ internal class RichTextComposerLayout @JvmOverloads constructor( fun removeLink() = views.richTextComposerEditText.removeLink() + // TODO: 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 -> From e988308dc6d9969d198bfa8cabc43ec56f482ee2 Mon Sep 17 00:00:00 2001 From: David Langley Date: Wed, 21 Jun 2023 09:30:45 +0100 Subject: [PATCH 10/11] Add space after mention inserstion. --- .../home/room/detail/composer/MessageComposerFragment.kt | 1 + 1 file changed, 1 insertion(+) 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 2b85fc482f..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 @@ -800,6 +800,7 @@ class MessageComposerFragment : VectorBaseFragment(), A // 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 { From cfae6e9e514df2b2c12a91679504e37c34d4f248 Mon Sep 17 00:00:00 2001 From: David Langley Date: Wed, 21 Jun 2023 09:49:44 +0100 Subject: [PATCH 11/11] Remove TODO causing failed lint check. --- .../home/room/detail/composer/RichTextComposerLayout.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 14c9e0f95e..b7a8d195d8 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 @@ -305,7 +305,7 @@ internal class RichTextComposerLayout @JvmOverloads constructor( fun removeLink() = views.richTextComposerEditText.removeLink() - // TODO: update the API to insertMention when available + // Update the API to insertMention when available fun insertMention(url: String, displayText: String) = views.richTextComposerEditText.insertLink(url, displayText)