Merge pull request #8440 from vector-im/jonny/feat/rich-text-mentions

[Rich text editor] Add mentions and slash commands
This commit is contained in:
David Langley 2023-06-21 11:26:13 +01:00 committed by GitHub
commit a065cd338c
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
10 changed files with 505 additions and 53 deletions

1
changelog.d/8440.feature Normal file
View file

@ -0,0 +1 @@
[Rich text editor] Add mentions and slash commands

View file

@ -172,6 +172,7 @@ ext.libs = [
'kluent' : "org.amshove.kluent:kluent-android:1.73", 'kluent' : "org.amshove.kluent:kluent-android:1.73",
'timberJunitRule' : "net.lachlanmckee:timber-junit-rule:1.0.1", 'timberJunitRule' : "net.lachlanmckee:timber-junit-rule:1.0.1",
'junit' : "junit:junit:4.13.2", 'junit' : "junit:junit:4.13.2",
'robolectric' : "org.robolectric:robolectric:4.9",
] ]
] ]

View file

@ -189,6 +189,7 @@ ext.groups = [
'org.codehaus.groovy', 'org.codehaus.groovy',
'org.codehaus.mojo', 'org.codehaus.mojo',
'org.codehaus.woodstox', 'org.codehaus.woodstox',
'org.conscrypt',
'org.eclipse.ee4j', 'org.eclipse.ee4j',
'org.ec4j.core', 'org.ec4j.core',
'org.freemarker', 'org.freemarker',
@ -221,6 +222,7 @@ ext.groups = [
'org.ow2.asm', 'org.ow2.asm',
'org.ow2.asm', 'org.ow2.asm',
'org.reactivestreams', 'org.reactivestreams',
'org.robolectric',
'org.slf4j', 'org.slf4j',
'org.sonatype.oss', 'org.sonatype.oss',
'org.testng', 'org.testng',

View file

@ -299,6 +299,7 @@ dependencies {
testImplementation libs.tests.kluent testImplementation libs.tests.kluent
testImplementation libs.mockk.mockk testImplementation libs.mockk.mockk
testImplementation libs.androidx.coreTesting testImplementation libs.androidx.coreTesting
testImplementation libs.tests.robolectric
// Plant Timber tree for test // Plant Timber tree for test
testImplementation libs.tests.timberJunitRule testImplementation libs.tests.timberJunitRule
testImplementation libs.airbnb.mavericksTesting testImplementation libs.airbnb.mavericksTesting

View file

@ -40,23 +40,31 @@ import im.vector.app.features.displayname.getBestName
import im.vector.app.features.home.AvatarRenderer import im.vector.app.features.home.AvatarRenderer
import im.vector.app.features.html.PillImageSpan import im.vector.app.features.html.PillImageSpan
import im.vector.app.features.themes.ThemeUtils 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.session.room.model.RoomSummary
import org.matrix.android.sdk.api.util.MatrixItem import org.matrix.android.sdk.api.util.MatrixItem
import org.matrix.android.sdk.api.util.toEveryoneInRoomMatrixItem import org.matrix.android.sdk.api.util.toEveryoneInRoomMatrixItem
import org.matrix.android.sdk.api.util.toMatrixItem import org.matrix.android.sdk.api.util.toMatrixItem
import org.matrix.android.sdk.api.util.toRoomAliasMatrixItem import org.matrix.android.sdk.api.util.toRoomAliasMatrixItem
import timber.log.Timber
class AutoCompleter @AssistedInject constructor( class AutoCompleter @AssistedInject constructor(
@Assisted val roomId: String, @Assisted val roomId: String,
@Assisted val isInThreadTimeline: Boolean, @Assisted val isInThreadTimeline: Boolean,
private val session: Session,
private val avatarRenderer: AvatarRenderer, private val avatarRenderer: AvatarRenderer,
private val commandAutocompletePolicy: CommandAutocompletePolicy, private val commandAutocompletePolicy: CommandAutocompletePolicy,
autocompleteCommandPresenterFactory: AutocompleteCommandPresenter.Factory, autocompleteCommandPresenterFactory: AutocompleteCommandPresenter.Factory,
private val autocompleteMemberPresenterFactory: AutocompleteMemberPresenter.Factory, private val autocompleteMemberPresenterFactory: AutocompleteMemberPresenter.Factory,
private val autocompleteRoomPresenter: AutocompleteRoomPresenter, private val autocompleteRoomPresenter: AutocompleteRoomPresenter,
private val autocompleteEmojiPresenter: AutocompleteEmojiPresenter private val autocompleteEmojiPresenter: AutocompleteEmojiPresenter,
) { ) {
private val permalinkService: PermalinkService
get() = session.permalinkService()
private lateinit var autocompleteMemberPresenter: AutocompleteMemberPresenter private lateinit var autocompleteMemberPresenter: AutocompleteMemberPresenter
@AssistedFactory @AssistedFactory
@ -79,6 +87,7 @@ class AutoCompleter @AssistedInject constructor(
} }
private lateinit var glideRequests: GlideRequests private lateinit var glideRequests: GlideRequests
private val autocompletes: MutableSet<Autocomplete<*>> = hashSetOf()
fun setup(editText: EditText) { fun setup(editText: EditText) {
this.editText = editText this.editText = editText
@ -90,26 +99,41 @@ class AutoCompleter @AssistedInject constructor(
setupRooms(backgroundDrawable, editText) setupRooms(backgroundDrawable, editText)
} }
fun setEnabled(isEnabled: Boolean) =
autocompletes.forEach {
if (!isEnabled) { it.dismissPopup() }
it.setEnabled(isEnabled)
}
fun clear() { fun clear() {
this.editText = null this.editText = null
autocompleteEmojiPresenter.clear() autocompleteEmojiPresenter.clear()
autocompleteRoomPresenter.clear() autocompleteRoomPresenter.clear()
autocompleteCommandPresenter.clear() autocompleteCommandPresenter.clear()
autocompleteMemberPresenter.clear() autocompleteMemberPresenter.clear()
autocompletes.forEach {
it.setEnabled(false)
it.dismissPopup()
}
autocompletes.clear()
} }
private fun setupCommands(backgroundDrawable: Drawable, editText: EditText) { private fun setupCommands(backgroundDrawable: Drawable, editText: EditText) {
Autocomplete.on<Command>(editText) autocompletes += Autocomplete.on<Command>(editText)
.with(commandAutocompletePolicy) .with(commandAutocompletePolicy)
.with(autocompleteCommandPresenter) .with(autocompleteCommandPresenter)
.with(ELEVATION_DP) .with(ELEVATION_DP)
.with(backgroundDrawable) .with(backgroundDrawable)
.with(object : AutocompleteCallback<Command> { .with(object : AutocompleteCallback<Command> {
override fun onPopupItemClicked(editable: Editable, item: Command): Boolean { override fun onPopupItemClicked(editable: Editable, item: Command): Boolean {
editable.clear() if (editText is EditorEditText) {
editable editText.replaceTextSuggestion(item.command)
.append(item.command) } else {
.append(" ") editable.clear()
editable
.append(item.command)
.append(" ")
}
return true return true
} }
@ -121,24 +145,22 @@ class AutoCompleter @AssistedInject constructor(
private fun setupMembers(backgroundDrawable: ColorDrawable, editText: EditText) { private fun setupMembers(backgroundDrawable: ColorDrawable, editText: EditText) {
autocompleteMemberPresenter = autocompleteMemberPresenterFactory.create(roomId) autocompleteMemberPresenter = autocompleteMemberPresenterFactory.create(roomId)
Autocomplete.on<AutocompleteMemberItem>(editText) autocompletes += Autocomplete.on<AutocompleteMemberItem>(editText)
.with(CharPolicy(TRIGGER_AUTO_COMPLETE_MEMBERS, true)) .with(CharPolicy(TRIGGER_AUTO_COMPLETE_MEMBERS, true))
.with(autocompleteMemberPresenter) .with(autocompleteMemberPresenter)
.with(ELEVATION_DP) .with(ELEVATION_DP)
.with(backgroundDrawable) .with(backgroundDrawable)
.with(object : AutocompleteCallback<AutocompleteMemberItem> { .with(object : AutocompleteCallback<AutocompleteMemberItem> {
override fun onPopupItemClicked(editable: Editable, item: AutocompleteMemberItem): Boolean { override fun onPopupItemClicked(editable: Editable, item: AutocompleteMemberItem): Boolean {
return when (item) { val matrixItem = when (item) {
is AutocompleteMemberItem.Header -> false // do nothing header is not clickable is AutocompleteMemberItem.Header -> null // do nothing header is not clickable
is AutocompleteMemberItem.RoomMember -> { is AutocompleteMemberItem.RoomMember -> item.roomMemberSummary.toMatrixItem()
insertMatrixItem(editText, editable, TRIGGER_AUTO_COMPLETE_MEMBERS, item.roomMemberSummary.toMatrixItem()) is AutocompleteMemberItem.Everyone -> item.roomSummary.toEveryoneInRoomMatrixItem()
true } ?: return false
}
is AutocompleteMemberItem.Everyone -> { insertMatrixItem(editText, editable, TRIGGER_AUTO_COMPLETE_MEMBERS, matrixItem)
insertMatrixItem(editText, editable, TRIGGER_AUTO_COMPLETE_MEMBERS, item.roomSummary.toEveryoneInRoomMatrixItem())
true return true
}
}
} }
override fun onPopupVisibilityChanged(shown: Boolean) { override fun onPopupVisibilityChanged(shown: Boolean) {
@ -148,7 +170,7 @@ class AutoCompleter @AssistedInject constructor(
} }
private fun setupRooms(backgroundDrawable: ColorDrawable, editText: EditText) { private fun setupRooms(backgroundDrawable: ColorDrawable, editText: EditText) {
Autocomplete.on<RoomSummary>(editText) autocompletes += Autocomplete.on<RoomSummary>(editText)
.with(CharPolicy(TRIGGER_AUTO_COMPLETE_ROOMS, true)) .with(CharPolicy(TRIGGER_AUTO_COMPLETE_ROOMS, true))
.with(autocompleteRoomPresenter) .with(autocompleteRoomPresenter)
.with(ELEVATION_DP) .with(ELEVATION_DP)
@ -166,7 +188,10 @@ class AutoCompleter @AssistedInject constructor(
} }
private fun setupEmojis(backgroundDrawable: Drawable, editText: EditText) { private fun setupEmojis(backgroundDrawable: Drawable, editText: EditText) {
Autocomplete.on<String>(editText) // Rich text editor is not yet supported
if (editText is EditorEditText) return
autocompletes += Autocomplete.on<String>(editText)
.with(CharPolicy(TRIGGER_AUTO_COMPLETE_EMOJIS, false)) .with(CharPolicy(TRIGGER_AUTO_COMPLETE_EMOJIS, false))
.with(autocompleteEmojiPresenter) .with(autocompleteEmojiPresenter)
.with(ELEVATION_DP) .with(ELEVATION_DP)
@ -197,7 +222,41 @@ class AutoCompleter @AssistedInject constructor(
.build() .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 // Detect last firstChar and remove it
var startIndex = editable.lastIndexOf(firstChar) var startIndex = editable.lastIndexOf(firstChar)
if (startIndex == -1) { if (startIndex == -1) {

View file

@ -765,6 +765,9 @@ class TimelineViewModel @AssistedInject constructor(
return room?.membershipService()?.getRoomMember(userId) return room?.membershipService()?.getRoomMember(userId)
} }
fun getRoom(roomId: String): RoomSummary? =
session.roomService().getRoomSummary(roomId)
private fun handleComposerFocusChange(action: RoomDetailAction.ComposerFocusChange) { private fun handleComposerFocusChange(action: RoomDetailAction.ComposerFocusChange) {
if (room == null) return if (room == null) return
// Ensure outbound session keys // Ensure outbound session keys

View file

@ -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.SetLinkFragment
import im.vector.app.features.home.room.detail.composer.link.SetLinkSharedAction 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.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.composer.voice.VoiceMessageRecorderView
import im.vector.app.features.home.room.detail.timeline.action.MessageSharedActionViewModel import im.vector.app.features.home.room.detail.timeline.action.MessageSharedActionViewModel
import im.vector.app.features.home.room.detail.upgrade.MigrateRoomBottomSheet 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 kotlinx.coroutines.flow.onEach
import org.matrix.android.sdk.api.session.Session import org.matrix.android.sdk.api.session.Session
import org.matrix.android.sdk.api.session.content.ContentAttachmentData 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 org.matrix.android.sdk.api.util.MatrixItem
import reactivecircus.flowbinding.android.view.focusChanges import reactivecircus.flowbinding.android.view.focusChanges
import reactivecircus.flowbinding.android.widget.textChanges import reactivecircus.flowbinding.android.widget.textChanges
@ -122,11 +124,12 @@ class MessageComposerFragment : VectorBaseFragment<FragmentComposerBinding>(), A
@Inject lateinit var session: Session @Inject lateinit var session: Session
@Inject lateinit var errorTracker: ErrorTracker @Inject lateinit var errorTracker: ErrorTracker
private val permalinkService: PermalinkService
get() = session.permalinkService()
private val roomId: String get() = withState(timelineViewModel) { it.roomId } private val roomId: String get() = withState(timelineViewModel) { it.roomId }
private val autoCompleter: AutoCompleter by lazy { private val autoCompleters: MutableMap<EditText, AutoCompleter> = hashMapOf()
autoCompleterFactory.create(roomId, isThreadTimeLine())
}
private val emojiPopup: EmojiPopup by lifecycleAwareLazy { private val emojiPopup: EmojiPopup by lifecycleAwareLazy {
createEmojiPopup() createEmojiPopup()
@ -261,9 +264,8 @@ class MessageComposerFragment : VectorBaseFragment<FragmentComposerBinding>(), A
override fun onDestroyView() { override fun onDestroyView() {
super.onDestroyView() super.onDestroyView()
if (!vectorPreferences.isRichTextEditorEnabled()) { autoCompleters.values.forEach(AutoCompleter::clear)
autoCompleter.clear() autoCompleters.clear()
}
messageComposerViewModel.endAllVoiceActions() messageComposerViewModel.endAllVoiceActions()
} }
@ -274,7 +276,12 @@ class MessageComposerFragment : VectorBaseFragment<FragmentComposerBinding>(), A
(composer as? View)?.isVisible = messageComposerState.isComposerVisible (composer as? View)?.isVisible = messageComposerState.isComposerVisible
composer.sendButton.isInvisible = !messageComposerState.isSendButtonVisible 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() { private fun setupBottomSheet() {
@ -315,8 +322,11 @@ class MessageComposerFragment : VectorBaseFragment<FragmentComposerBinding>(), A
val composerEditText = composer.editText val composerEditText = composer.editText
composerEditText.setHint(R.string.room_message_placeholder) composerEditText.setHint(R.string.room_message_placeholder)
if (!vectorPreferences.isRichTextEditorEnabled()) { (composer as? RichTextComposerLayout)?.let {
autoCompleter.setup(composerEditText) initAutoCompleter(it.richTextEditText)
initAutoCompleter(it.plainTextEditText)
} ?: run {
initAutoCompleter(composer.editText)
} }
observerUserTyping() observerUserTyping()
@ -404,6 +414,21 @@ class MessageComposerFragment : VectorBaseFragment<FragmentComposerBinding>(), A
SetLinkFragment.show(isTextSupported, initialLink, childFragmentManager) 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) { private fun sendTextMessage(text: CharSequence, formattedText: String? = null) {
@ -435,12 +460,12 @@ class MessageComposerFragment : VectorBaseFragment<FragmentComposerBinding>(), A
} }
private fun renderRegularMode(content: CharSequence) { private fun renderRegularMode(content: CharSequence) {
autoCompleter.exitSpecialMode() autoCompleters.values.forEach(AutoCompleter::exitSpecialMode)
composer.renderComposerMode(MessageComposerMode.Normal(content)) composer.renderComposerMode(MessageComposerMode.Normal(content))
} }
private fun renderSpecialMode(mode: MessageComposerMode.Special) { private fun renderSpecialMode(mode: MessageComposerMode.Special) {
autoCompleter.enterSpecialMode() autoCompleters.values.forEach(AutoCompleter::enterSpecialMode)
composer.renderComposerMode(mode) composer.renderComposerMode(mode)
} }
@ -771,30 +796,37 @@ class MessageComposerFragment : VectorBaseFragment<FragmentComposerBinding>(), A
} else { } else {
val roomMember = timelineViewModel.getMember(userId) val roomMember = timelineViewModel.getMember(userId)
val displayName = sanitizeDisplayName(roomMember?.displayName ?: userId) val displayName = sanitizeDisplayName(roomMember?.displayName ?: userId)
val pill = buildSpannedString { if ((composer as? RichTextComposerLayout)?.isTextFormattingEnabled == true) {
append(displayName) // Rich text editor is enabled so we need to use its APIs
setSpan( permalinkService.createPermalink(userId)?.let { url ->
PillImageSpan( (composer as RichTextComposerLayout).insertMention(url, displayName)
glideRequests, composer.editText.append(" ")
avatarRenderer, }
requireContext(), } else {
MatrixItem.UserItem(userId, displayName, roomMember?.avatarUrl) val pill = buildSpannedString {
) append(displayName)
.also { it.bind(composer.editText) }, setSpan(
0, PillImageSpan(
displayName.length, glideRequests,
Spannable.SPAN_EXCLUSIVE_EXCLUSIVE avatarRenderer,
) requireContext(),
append(if (startToCompose) ": " else " ") MatrixItem.UserItem(userId, displayName, roomMember?.avatarUrl),
} )
if (startToCompose) { .also { it.bind(composer.editText) },
if (displayName.startsWith("/")) { 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 // Ensure displayName will not be interpreted as a Slash command
composer.editText.append("\\") composer.editText.append("\\")
} }
composer.editText.append(pill) // Always use EditText.getText().insert for adding pills as TextView.append doesn't appear
} else { // to upgrade to BufferType.Spannable as hinted at in the docs:
composer.editText.text?.insert(composer.editText.selectionStart, pill) // https://developer.android.com/reference/android/widget/TextView#append(java.lang.CharSequence)
composer.editText.text.insert(composer.editText.selectionStart, pill)
} }
} }
focusComposerAndShowKeyboard() focusComposerAndShowKeyboard()

View file

@ -49,7 +49,11 @@ import im.vector.app.core.utils.DimensionConverter
import im.vector.app.databinding.ComposerRichTextLayoutBinding import im.vector.app.databinding.ComposerRichTextLayoutBinding
import im.vector.app.databinding.ViewRichTextMenuButtonBinding 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.images.UriContentListener
import im.vector.app.features.home.room.detail.composer.mentions.PillDisplayHandler
import io.element.android.wysiwyg.EditorEditText 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.utils.RustErrorCollector
import io.element.android.wysiwyg.view.models.InlineFormat import io.element.android.wysiwyg.view.models.InlineFormat
import io.element.android.wysiwyg.view.models.LinkAction import io.element.android.wysiwyg.view.models.LinkAction
@ -102,6 +106,13 @@ internal class RichTextComposerLayout @JvmOverloads constructor(
override val attachmentButton: ImageButton override val attachmentButton: ImageButton
get() = views.attachmentButton get() = views.attachmentButton
val richTextEditText: EditText get() =
views.richTextComposerEditText
val plainTextEditText: EditText get() =
views.plainTextComposerEditText
var pillDisplayHandler: PillDisplayHandler? = null
// Border of the EditText // Border of the EditText
private val borderShapeDrawable: MaterialShapeDrawable by lazy { private val borderShapeDrawable: MaterialShapeDrawable by lazy {
MaterialShapeDrawable().apply { MaterialShapeDrawable().apply {
@ -227,6 +238,16 @@ internal class RichTextComposerLayout @JvmOverloads constructor(
views.composerEditTextOuterBorder.background = borderShapeDrawable views.composerEditTextOuterBorder.background = borderShapeDrawable
setupRichTextMenu() setupRichTextMenu()
views.richTextComposerEditText.linkDisplayHandler = LinkDisplayHandler { text, url ->
pillDisplayHandler?.resolveLinkDisplay(text, url) ?: TextDisplay.Plain
}
views.richTextComposerEditText.keywordDisplayHandler = object : KeywordDisplayHandler {
override val keywords: List<String>
get() = pillDisplayHandler?.keywords.orEmpty()
override fun resolveKeywordDisplay(text: String): TextDisplay =
pillDisplayHandler?.resolveKeywordDisplay(text) ?: TextDisplay.Plain
}
updateTextFieldBorder(isFullScreen) updateTextFieldBorder(isFullScreen)
} }
@ -284,6 +305,10 @@ internal class RichTextComposerLayout @JvmOverloads constructor(
fun removeLink() = fun removeLink() =
views.richTextComposerEditText.removeLink() views.richTextComposerEditText.removeLink()
// Update the API to insertMention when available
fun insertMention(url: String, displayText: String) =
views.richTextComposerEditText.insertLink(url, displayText)
@SuppressLint("ClickableViewAccessibility") @SuppressLint("ClickableViewAccessibility")
private fun disallowParentInterceptTouchEvent(view: View) { private fun disallowParentInterceptTouchEvent(view: View) {
view.setOnTouchListener { v, event -> view.setOnTouchListener { v, event ->

View file

@ -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<String>
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
}
}

View file

@ -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
}
}
}