mirror of
https://github.com/SchildiChat/SchildiChat-android.git
synced 2025-02-16 12:00:03 +03:00
Add mentions to rich text editor
This commit is contained in:
parent
e2afa0ccd3
commit
2d1dcd34c0
12 changed files with 375 additions and 24 deletions
|
@ -101,7 +101,7 @@ ext.libs = [
|
||||||
],
|
],
|
||||||
element : [
|
element : [
|
||||||
'opusencoder' : "io.element.android:opusencoder:1.1.0",
|
'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 : [
|
squareup : [
|
||||||
'moshi' : "com.squareup.moshi:moshi:$moshi",
|
'moshi' : "com.squareup.moshi:moshi:$moshi",
|
||||||
|
@ -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",
|
||||||
]
|
]
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
|
@ -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',
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -23,7 +23,7 @@ import android.text.Spanned
|
||||||
import android.text.style.StrikethroughSpan
|
import android.text.style.StrikethroughSpan
|
||||||
import androidx.core.text.getSpans
|
import androidx.core.text.getSpans
|
||||||
import im.vector.app.features.html.HtmlCodeSpan
|
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.justRun
|
||||||
import io.mockk.mockk
|
import io.mockk.mockk
|
||||||
import io.mockk.slot
|
import io.mockk.slot
|
||||||
|
|
|
@ -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
|
||||||
|
@ -99,6 +107,9 @@ class AutoCompleter @AssistedInject constructor(
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun setupCommands(backgroundDrawable: Drawable, editText: EditText) {
|
private fun setupCommands(backgroundDrawable: Drawable, editText: EditText) {
|
||||||
|
// Rich text editor is not yet supported
|
||||||
|
if (editText is EditorEditText) return
|
||||||
|
|
||||||
Autocomplete.on<Command>(editText)
|
Autocomplete.on<Command>(editText)
|
||||||
.with(commandAutocompletePolicy)
|
.with(commandAutocompletePolicy)
|
||||||
.with(autocompleteCommandPresenter)
|
.with(autocompleteCommandPresenter)
|
||||||
|
@ -128,17 +139,15 @@ class AutoCompleter @AssistedInject constructor(
|
||||||
.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) {
|
||||||
|
@ -166,6 +175,9 @@ class AutoCompleter @AssistedInject constructor(
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun setupEmojis(backgroundDrawable: Drawable, editText: EditText) {
|
private fun setupEmojis(backgroundDrawable: Drawable, editText: EditText) {
|
||||||
|
// Rich text editor is not yet supported
|
||||||
|
if (editText is EditorEditText) return
|
||||||
|
|
||||||
Autocomplete.on<String>(editText)
|
Autocomplete.on<String>(editText)
|
||||||
.with(CharPolicy(TRIGGER_AUTO_COMPLETE_EMOJIS, false))
|
.with(CharPolicy(TRIGGER_AUTO_COMPLETE_EMOJIS, false))
|
||||||
.with(autocompleteEmojiPresenter)
|
.with(autocompleteEmojiPresenter)
|
||||||
|
@ -197,7 +209,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) {
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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
|
||||||
|
@ -315,9 +316,7 @@ 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()) {
|
autoCompleter.setup(composerEditText)
|
||||||
autoCompleter.setup(composerEditText)
|
|
||||||
}
|
|
||||||
|
|
||||||
observerUserTyping()
|
observerUserTyping()
|
||||||
|
|
||||||
|
@ -404,6 +403,13 @@ 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 sendTextMessage(text: CharSequence, formattedText: String? = null) {
|
private fun sendTextMessage(text: CharSequence, formattedText: String? = null) {
|
||||||
|
|
|
@ -49,10 +49,14 @@ 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.inputhandlers.models.InlineFormat
|
import io.element.android.wysiwyg.display.KeywordDisplayHandler
|
||||||
import io.element.android.wysiwyg.inputhandlers.models.LinkAction
|
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.LinkAction
|
||||||
import uniffi.wysiwyg_composer.ActionState
|
import uniffi.wysiwyg_composer.ActionState
|
||||||
import uniffi.wysiwyg_composer.ComposerAction
|
import uniffi.wysiwyg_composer.ComposerAction
|
||||||
|
|
||||||
|
@ -102,6 +106,8 @@ internal class RichTextComposerLayout @JvmOverloads constructor(
|
||||||
override val attachmentButton: ImageButton
|
override val attachmentButton: ImageButton
|
||||||
get() = views.attachmentButton
|
get() = views.attachmentButton
|
||||||
|
|
||||||
|
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 +233,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)
|
||||||
}
|
}
|
||||||
|
@ -269,7 +285,7 @@ internal class RichTextComposerLayout @JvmOverloads constructor(
|
||||||
views.richTextComposerEditText.getLinkAction()?.let {
|
views.richTextComposerEditText.getLinkAction()?.let {
|
||||||
when (it) {
|
when (it) {
|
||||||
LinkAction.InsertLink -> callback?.onSetLink(isTextSupported = true, initialLink = null)
|
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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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<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
|
||||||
|
}
|
||||||
|
}
|
|
@ -43,7 +43,7 @@ import im.vector.app.core.di.ActiveSessionHolder
|
||||||
import im.vector.app.core.resources.ColorProvider
|
import im.vector.app.core.resources.ColorProvider
|
||||||
import im.vector.app.core.utils.DimensionConverter
|
import im.vector.app.core.utils.DimensionConverter
|
||||||
import im.vector.app.features.settings.VectorPreferences
|
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.AbstractMarkwonPlugin
|
||||||
import io.noties.markwon.Markwon
|
import io.noties.markwon.Markwon
|
||||||
import io.noties.markwon.MarkwonPlugin
|
import io.noties.markwon.MarkwonPlugin
|
||||||
|
|
|
@ -18,8 +18,8 @@ package im.vector.app.features.html
|
||||||
|
|
||||||
import im.vector.app.core.utils.DimensionConverter
|
import im.vector.app.core.utils.DimensionConverter
|
||||||
import im.vector.app.features.settings.VectorPreferences
|
import im.vector.app.features.settings.VectorPreferences
|
||||||
import io.element.android.wysiwyg.spans.CodeBlockSpan
|
import io.element.android.wysiwyg.view.spans.CodeBlockSpan
|
||||||
import io.element.android.wysiwyg.spans.InlineCodeSpan
|
import io.element.android.wysiwyg.view.spans.InlineCodeSpan
|
||||||
import io.noties.markwon.MarkwonVisitor
|
import io.noties.markwon.MarkwonVisitor
|
||||||
import io.noties.markwon.SpannableBuilder
|
import io.noties.markwon.SpannableBuilder
|
||||||
import io.noties.markwon.core.MarkwonTheme
|
import io.noties.markwon.core.MarkwonTheme
|
||||||
|
|
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
Loading…
Add table
Reference in a new issue