mirror of
https://github.com/vector-im/element-android.git
synced 2024-11-26 15:18:19 +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",
|
'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
|
||||||
|
@ -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) {
|
||||||
|
@ -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
|
||||||
@ -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()
|
||||||
|
@ -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 ->
|
||||||
|
@ -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