mirror of
https://github.com/vector-im/element-android.git
synced 2024-11-22 14:38:15 +08:00
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:
commit
a065cd338c
1
changelog.d/8440.feature
Normal file
1
changelog.d/8440.feature
Normal file
@ -0,0 +1 @@
|
||||
[Rich text editor] Add mentions and slash commands
|
@ -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",
|
||||
]
|
||||
]
|
||||
|
||||
|
@ -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',
|
||||
|
@ -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
|
||||
|
@ -40,23 +40,31 @@ import im.vector.app.features.displayname.getBestName
|
||||
import im.vector.app.features.home.AvatarRenderer
|
||||
import im.vector.app.features.html.PillImageSpan
|
||||
import im.vector.app.features.themes.ThemeUtils
|
||||
import io.element.android.wysiwyg.EditorEditText
|
||||
import org.matrix.android.sdk.api.session.Session
|
||||
import org.matrix.android.sdk.api.session.permalinks.PermalinkService
|
||||
import org.matrix.android.sdk.api.session.room.model.RoomSummary
|
||||
import org.matrix.android.sdk.api.util.MatrixItem
|
||||
import org.matrix.android.sdk.api.util.toEveryoneInRoomMatrixItem
|
||||
import org.matrix.android.sdk.api.util.toMatrixItem
|
||||
import org.matrix.android.sdk.api.util.toRoomAliasMatrixItem
|
||||
import timber.log.Timber
|
||||
|
||||
class AutoCompleter @AssistedInject constructor(
|
||||
@Assisted val roomId: String,
|
||||
@Assisted val isInThreadTimeline: Boolean,
|
||||
private val session: Session,
|
||||
private val avatarRenderer: AvatarRenderer,
|
||||
private val commandAutocompletePolicy: CommandAutocompletePolicy,
|
||||
autocompleteCommandPresenterFactory: AutocompleteCommandPresenter.Factory,
|
||||
private val autocompleteMemberPresenterFactory: AutocompleteMemberPresenter.Factory,
|
||||
private val autocompleteRoomPresenter: AutocompleteRoomPresenter,
|
||||
private val autocompleteEmojiPresenter: AutocompleteEmojiPresenter
|
||||
private val autocompleteEmojiPresenter: AutocompleteEmojiPresenter,
|
||||
) {
|
||||
|
||||
private val permalinkService: PermalinkService
|
||||
get() = session.permalinkService()
|
||||
|
||||
private lateinit var autocompleteMemberPresenter: AutocompleteMemberPresenter
|
||||
|
||||
@AssistedFactory
|
||||
@ -79,6 +87,7 @@ class AutoCompleter @AssistedInject constructor(
|
||||
}
|
||||
|
||||
private lateinit var glideRequests: GlideRequests
|
||||
private val autocompletes: MutableSet<Autocomplete<*>> = hashSetOf()
|
||||
|
||||
fun setup(editText: EditText) {
|
||||
this.editText = editText
|
||||
@ -90,26 +99,41 @@ class AutoCompleter @AssistedInject constructor(
|
||||
setupRooms(backgroundDrawable, editText)
|
||||
}
|
||||
|
||||
fun setEnabled(isEnabled: Boolean) =
|
||||
autocompletes.forEach {
|
||||
if (!isEnabled) { it.dismissPopup() }
|
||||
it.setEnabled(isEnabled)
|
||||
}
|
||||
|
||||
fun clear() {
|
||||
this.editText = null
|
||||
autocompleteEmojiPresenter.clear()
|
||||
autocompleteRoomPresenter.clear()
|
||||
autocompleteCommandPresenter.clear()
|
||||
autocompleteMemberPresenter.clear()
|
||||
autocompletes.forEach {
|
||||
it.setEnabled(false)
|
||||
it.dismissPopup()
|
||||
}
|
||||
autocompletes.clear()
|
||||
}
|
||||
|
||||
private fun setupCommands(backgroundDrawable: Drawable, editText: EditText) {
|
||||
Autocomplete.on<Command>(editText)
|
||||
autocompletes += Autocomplete.on<Command>(editText)
|
||||
.with(commandAutocompletePolicy)
|
||||
.with(autocompleteCommandPresenter)
|
||||
.with(ELEVATION_DP)
|
||||
.with(backgroundDrawable)
|
||||
.with(object : AutocompleteCallback<Command> {
|
||||
override fun onPopupItemClicked(editable: Editable, item: Command): Boolean {
|
||||
if (editText is EditorEditText) {
|
||||
editText.replaceTextSuggestion(item.command)
|
||||
} else {
|
||||
editable.clear()
|
||||
editable
|
||||
.append(item.command)
|
||||
.append(" ")
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
@ -121,24 +145,22 @@ class AutoCompleter @AssistedInject constructor(
|
||||
|
||||
private fun setupMembers(backgroundDrawable: ColorDrawable, editText: EditText) {
|
||||
autocompleteMemberPresenter = autocompleteMemberPresenterFactory.create(roomId)
|
||||
Autocomplete.on<AutocompleteMemberItem>(editText)
|
||||
autocompletes += Autocomplete.on<AutocompleteMemberItem>(editText)
|
||||
.with(CharPolicy(TRIGGER_AUTO_COMPLETE_MEMBERS, true))
|
||||
.with(autocompleteMemberPresenter)
|
||||
.with(ELEVATION_DP)
|
||||
.with(backgroundDrawable)
|
||||
.with(object : AutocompleteCallback<AutocompleteMemberItem> {
|
||||
override fun onPopupItemClicked(editable: Editable, item: AutocompleteMemberItem): Boolean {
|
||||
return when (item) {
|
||||
is AutocompleteMemberItem.Header -> false // do nothing header is not clickable
|
||||
is AutocompleteMemberItem.RoomMember -> {
|
||||
insertMatrixItem(editText, editable, TRIGGER_AUTO_COMPLETE_MEMBERS, item.roomMemberSummary.toMatrixItem())
|
||||
true
|
||||
}
|
||||
is AutocompleteMemberItem.Everyone -> {
|
||||
insertMatrixItem(editText, editable, TRIGGER_AUTO_COMPLETE_MEMBERS, item.roomSummary.toEveryoneInRoomMatrixItem())
|
||||
true
|
||||
}
|
||||
}
|
||||
val matrixItem = when (item) {
|
||||
is AutocompleteMemberItem.Header -> null // do nothing header is not clickable
|
||||
is AutocompleteMemberItem.RoomMember -> item.roomMemberSummary.toMatrixItem()
|
||||
is AutocompleteMemberItem.Everyone -> item.roomSummary.toEveryoneInRoomMatrixItem()
|
||||
} ?: return false
|
||||
|
||||
insertMatrixItem(editText, editable, TRIGGER_AUTO_COMPLETE_MEMBERS, matrixItem)
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
override fun onPopupVisibilityChanged(shown: Boolean) {
|
||||
@ -148,7 +170,7 @@ class AutoCompleter @AssistedInject constructor(
|
||||
}
|
||||
|
||||
private fun setupRooms(backgroundDrawable: ColorDrawable, editText: EditText) {
|
||||
Autocomplete.on<RoomSummary>(editText)
|
||||
autocompletes += Autocomplete.on<RoomSummary>(editText)
|
||||
.with(CharPolicy(TRIGGER_AUTO_COMPLETE_ROOMS, true))
|
||||
.with(autocompleteRoomPresenter)
|
||||
.with(ELEVATION_DP)
|
||||
@ -166,7 +188,10 @@ class AutoCompleter @AssistedInject constructor(
|
||||
}
|
||||
|
||||
private fun setupEmojis(backgroundDrawable: Drawable, editText: EditText) {
|
||||
Autocomplete.on<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(autocompleteEmojiPresenter)
|
||||
.with(ELEVATION_DP)
|
||||
@ -197,7 +222,41 @@ class AutoCompleter @AssistedInject constructor(
|
||||
.build()
|
||||
}
|
||||
|
||||
private fun insertMatrixItem(editText: EditText, editable: Editable, firstChar: Char, matrixItem: MatrixItem) {
|
||||
private fun insertMatrixItem(editText: EditText, editable: Editable, firstChar: Char, matrixItem: MatrixItem) =
|
||||
if (editText is EditorEditText) {
|
||||
insertMatrixItemIntoRichTextEditor(editText, matrixItem)
|
||||
} else {
|
||||
insertMatrixItemIntoEditable(editText, editable, firstChar, matrixItem)
|
||||
}
|
||||
|
||||
private fun insertMatrixItemIntoRichTextEditor(editorEditText: EditorEditText, matrixItem: MatrixItem) {
|
||||
if (matrixItem is MatrixItem.EveryoneInRoomItem) {
|
||||
editorEditText.replaceTextSuggestion(matrixItem.displayName)
|
||||
return
|
||||
}
|
||||
|
||||
val permalink = permalinkService.createPermalink(matrixItem.id)
|
||||
|
||||
if (permalink == null) {
|
||||
Timber.e(NullPointerException("Cannot autocomplete as permalink is null"))
|
||||
return
|
||||
}
|
||||
|
||||
val linkText = when (matrixItem) {
|
||||
is MatrixItem.RoomAliasItem,
|
||||
is MatrixItem.RoomItem,
|
||||
is MatrixItem.SpaceItem ->
|
||||
matrixItem.id
|
||||
is MatrixItem.EveryoneInRoomItem,
|
||||
is MatrixItem.UserItem,
|
||||
is MatrixItem.EventItem ->
|
||||
matrixItem.getBestName()
|
||||
}
|
||||
|
||||
editorEditText.setLinkSuggestion(url = permalink, text = linkText)
|
||||
}
|
||||
|
||||
private fun insertMatrixItemIntoEditable(editText: EditText, editable: Editable, firstChar: Char, matrixItem: MatrixItem) {
|
||||
// Detect last firstChar and remove it
|
||||
var startIndex = editable.lastIndexOf(firstChar)
|
||||
if (startIndex == -1) {
|
||||
|
@ -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
|
||||
|
@ -83,6 +83,7 @@ import im.vector.app.features.home.room.detail.TimelineViewModel
|
||||
import im.vector.app.features.home.room.detail.composer.link.SetLinkFragment
|
||||
import im.vector.app.features.home.room.detail.composer.link.SetLinkSharedAction
|
||||
import im.vector.app.features.home.room.detail.composer.link.SetLinkSharedActionViewModel
|
||||
import im.vector.app.features.home.room.detail.composer.mentions.PillDisplayHandler
|
||||
import im.vector.app.features.home.room.detail.composer.voice.VoiceMessageRecorderView
|
||||
import im.vector.app.features.home.room.detail.timeline.action.MessageSharedActionViewModel
|
||||
import im.vector.app.features.home.room.detail.upgrade.MigrateRoomBottomSheet
|
||||
@ -100,6 +101,7 @@ import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.onEach
|
||||
import org.matrix.android.sdk.api.session.Session
|
||||
import org.matrix.android.sdk.api.session.content.ContentAttachmentData
|
||||
import org.matrix.android.sdk.api.session.permalinks.PermalinkService
|
||||
import org.matrix.android.sdk.api.util.MatrixItem
|
||||
import reactivecircus.flowbinding.android.view.focusChanges
|
||||
import reactivecircus.flowbinding.android.widget.textChanges
|
||||
@ -122,11 +124,12 @@ class MessageComposerFragment : VectorBaseFragment<FragmentComposerBinding>(), A
|
||||
@Inject lateinit var session: Session
|
||||
@Inject lateinit var errorTracker: ErrorTracker
|
||||
|
||||
private val permalinkService: PermalinkService
|
||||
get() = session.permalinkService()
|
||||
|
||||
private val roomId: String get() = withState(timelineViewModel) { it.roomId }
|
||||
|
||||
private val autoCompleter: AutoCompleter by lazy {
|
||||
autoCompleterFactory.create(roomId, isThreadTimeLine())
|
||||
}
|
||||
private val autoCompleters: MutableMap<EditText, AutoCompleter> = hashMapOf()
|
||||
|
||||
private val emojiPopup: EmojiPopup by lifecycleAwareLazy {
|
||||
createEmojiPopup()
|
||||
@ -261,9 +264,8 @@ class MessageComposerFragment : VectorBaseFragment<FragmentComposerBinding>(), A
|
||||
override fun onDestroyView() {
|
||||
super.onDestroyView()
|
||||
|
||||
if (!vectorPreferences.isRichTextEditorEnabled()) {
|
||||
autoCompleter.clear()
|
||||
}
|
||||
autoCompleters.values.forEach(AutoCompleter::clear)
|
||||
autoCompleters.clear()
|
||||
messageComposerViewModel.endAllVoiceActions()
|
||||
}
|
||||
|
||||
@ -274,7 +276,12 @@ class MessageComposerFragment : VectorBaseFragment<FragmentComposerBinding>(), A
|
||||
|
||||
(composer as? View)?.isVisible = messageComposerState.isComposerVisible
|
||||
composer.sendButton.isInvisible = !messageComposerState.isSendButtonVisible
|
||||
(composer as? RichTextComposerLayout)?.isTextFormattingEnabled = attachmentState.isTextFormattingEnabled
|
||||
(composer as? RichTextComposerLayout)?.also {
|
||||
val isTextFormattingEnabled = attachmentState.isTextFormattingEnabled
|
||||
it.isTextFormattingEnabled = isTextFormattingEnabled
|
||||
autoCompleters[it.richTextEditText]?.setEnabled(isTextFormattingEnabled)
|
||||
autoCompleters[it.plainTextEditText]?.setEnabled(!isTextFormattingEnabled)
|
||||
}
|
||||
}
|
||||
|
||||
private fun setupBottomSheet() {
|
||||
@ -315,8 +322,11 @@ class MessageComposerFragment : VectorBaseFragment<FragmentComposerBinding>(), A
|
||||
val composerEditText = composer.editText
|
||||
composerEditText.setHint(R.string.room_message_placeholder)
|
||||
|
||||
if (!vectorPreferences.isRichTextEditorEnabled()) {
|
||||
autoCompleter.setup(composerEditText)
|
||||
(composer as? RichTextComposerLayout)?.let {
|
||||
initAutoCompleter(it.richTextEditText)
|
||||
initAutoCompleter(it.plainTextEditText)
|
||||
} ?: run {
|
||||
initAutoCompleter(composer.editText)
|
||||
}
|
||||
|
||||
observerUserTyping()
|
||||
@ -404,6 +414,21 @@ class MessageComposerFragment : VectorBaseFragment<FragmentComposerBinding>(), A
|
||||
SetLinkFragment.show(isTextSupported, initialLink, childFragmentManager)
|
||||
}
|
||||
}
|
||||
(composer as? RichTextComposerLayout)?.pillDisplayHandler = PillDisplayHandler(
|
||||
roomId = roomId,
|
||||
getRoom = timelineViewModel::getRoom,
|
||||
getMember = timelineViewModel::getMember,
|
||||
) { matrixItem: MatrixItem ->
|
||||
PillImageSpan(glideRequests, avatarRenderer, requireContext(), matrixItem)
|
||||
}
|
||||
}
|
||||
|
||||
private fun initAutoCompleter(editText: EditText) {
|
||||
if (autoCompleters.containsKey(editText)) return
|
||||
|
||||
autoCompleters[editText] =
|
||||
autoCompleterFactory.create(roomId, isThreadTimeLine())
|
||||
.also { it.setup(editText) }
|
||||
}
|
||||
|
||||
private fun sendTextMessage(text: CharSequence, formattedText: String? = null) {
|
||||
@ -435,12 +460,12 @@ class MessageComposerFragment : VectorBaseFragment<FragmentComposerBinding>(), A
|
||||
}
|
||||
|
||||
private fun renderRegularMode(content: CharSequence) {
|
||||
autoCompleter.exitSpecialMode()
|
||||
autoCompleters.values.forEach(AutoCompleter::exitSpecialMode)
|
||||
composer.renderComposerMode(MessageComposerMode.Normal(content))
|
||||
}
|
||||
|
||||
private fun renderSpecialMode(mode: MessageComposerMode.Special) {
|
||||
autoCompleter.enterSpecialMode()
|
||||
autoCompleters.values.forEach(AutoCompleter::enterSpecialMode)
|
||||
composer.renderComposerMode(mode)
|
||||
}
|
||||
|
||||
@ -771,6 +796,13 @@ class MessageComposerFragment : VectorBaseFragment<FragmentComposerBinding>(), A
|
||||
} else {
|
||||
val roomMember = timelineViewModel.getMember(userId)
|
||||
val displayName = sanitizeDisplayName(roomMember?.displayName ?: userId)
|
||||
if ((composer as? RichTextComposerLayout)?.isTextFormattingEnabled == true) {
|
||||
// Rich text editor is enabled so we need to use its APIs
|
||||
permalinkService.createPermalink(userId)?.let { url ->
|
||||
(composer as RichTextComposerLayout).insertMention(url, displayName)
|
||||
composer.editText.append(" ")
|
||||
}
|
||||
} else {
|
||||
val pill = buildSpannedString {
|
||||
append(displayName)
|
||||
setSpan(
|
||||
@ -778,7 +810,7 @@ class MessageComposerFragment : VectorBaseFragment<FragmentComposerBinding>(), A
|
||||
glideRequests,
|
||||
avatarRenderer,
|
||||
requireContext(),
|
||||
MatrixItem.UserItem(userId, displayName, roomMember?.avatarUrl)
|
||||
MatrixItem.UserItem(userId, displayName, roomMember?.avatarUrl),
|
||||
)
|
||||
.also { it.bind(composer.editText) },
|
||||
0,
|
||||
@ -787,14 +819,14 @@ class MessageComposerFragment : VectorBaseFragment<FragmentComposerBinding>(), A
|
||||
)
|
||||
append(if (startToCompose) ": " else " ")
|
||||
}
|
||||
if (startToCompose) {
|
||||
if (displayName.startsWith("/")) {
|
||||
if (startToCompose && displayName.startsWith("/")) {
|
||||
// Ensure displayName will not be interpreted as a Slash command
|
||||
composer.editText.append("\\")
|
||||
}
|
||||
composer.editText.append(pill)
|
||||
} else {
|
||||
composer.editText.text?.insert(composer.editText.selectionStart, pill)
|
||||
// Always use EditText.getText().insert for adding pills as TextView.append doesn't appear
|
||||
// to upgrade to BufferType.Spannable as hinted at in the docs:
|
||||
// https://developer.android.com/reference/android/widget/TextView#append(java.lang.CharSequence)
|
||||
composer.editText.text.insert(composer.editText.selectionStart, pill)
|
||||
}
|
||||
}
|
||||
focusComposerAndShowKeyboard()
|
||||
|
@ -49,7 +49,11 @@ import im.vector.app.core.utils.DimensionConverter
|
||||
import im.vector.app.databinding.ComposerRichTextLayoutBinding
|
||||
import im.vector.app.databinding.ViewRichTextMenuButtonBinding
|
||||
import im.vector.app.features.home.room.detail.composer.images.UriContentListener
|
||||
import im.vector.app.features.home.room.detail.composer.mentions.PillDisplayHandler
|
||||
import io.element.android.wysiwyg.EditorEditText
|
||||
import io.element.android.wysiwyg.display.KeywordDisplayHandler
|
||||
import io.element.android.wysiwyg.display.LinkDisplayHandler
|
||||
import io.element.android.wysiwyg.display.TextDisplay
|
||||
import io.element.android.wysiwyg.utils.RustErrorCollector
|
||||
import io.element.android.wysiwyg.view.models.InlineFormat
|
||||
import io.element.android.wysiwyg.view.models.LinkAction
|
||||
@ -102,6 +106,13 @@ internal class RichTextComposerLayout @JvmOverloads constructor(
|
||||
override val attachmentButton: ImageButton
|
||||
get() = views.attachmentButton
|
||||
|
||||
val richTextEditText: EditText get() =
|
||||
views.richTextComposerEditText
|
||||
val plainTextEditText: EditText get() =
|
||||
views.plainTextComposerEditText
|
||||
|
||||
var pillDisplayHandler: PillDisplayHandler? = null
|
||||
|
||||
// Border of the EditText
|
||||
private val borderShapeDrawable: MaterialShapeDrawable by lazy {
|
||||
MaterialShapeDrawable().apply {
|
||||
@ -227,6 +238,16 @@ internal class RichTextComposerLayout @JvmOverloads constructor(
|
||||
views.composerEditTextOuterBorder.background = borderShapeDrawable
|
||||
|
||||
setupRichTextMenu()
|
||||
views.richTextComposerEditText.linkDisplayHandler = LinkDisplayHandler { text, url ->
|
||||
pillDisplayHandler?.resolveLinkDisplay(text, url) ?: TextDisplay.Plain
|
||||
}
|
||||
views.richTextComposerEditText.keywordDisplayHandler = object : KeywordDisplayHandler {
|
||||
override val keywords: List<String>
|
||||
get() = pillDisplayHandler?.keywords.orEmpty()
|
||||
|
||||
override fun resolveKeywordDisplay(text: String): TextDisplay =
|
||||
pillDisplayHandler?.resolveKeywordDisplay(text) ?: TextDisplay.Plain
|
||||
}
|
||||
|
||||
updateTextFieldBorder(isFullScreen)
|
||||
}
|
||||
@ -284,6 +305,10 @@ internal class RichTextComposerLayout @JvmOverloads constructor(
|
||||
fun removeLink() =
|
||||
views.richTextComposerEditText.removeLink()
|
||||
|
||||
// Update the API to insertMention when available
|
||||
fun insertMention(url: String, displayText: String) =
|
||||
views.richTextComposerEditText.insertLink(url, displayText)
|
||||
|
||||
@SuppressLint("ClickableViewAccessibility")
|
||||
private fun disallowParentInterceptTouchEvent(view: View) {
|
||||
view.setOnTouchListener { v, event ->
|
||||
|
@ -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
|
||||
}
|
||||
}
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
Loading…
Reference in New Issue
Block a user