Skip to content

Commit a065cd3

Browse files
authored
Merge pull request #8440 from vector-im/jonny/feat/rich-text-mentions
[Rich text editor] Add mentions and slash commands
2 parents 4af2f70 + cfae6e9 commit a065cd3

File tree

10 files changed

+505
-53
lines changed

10 files changed

+505
-53
lines changed

changelog.d/8440.feature

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
[Rich text editor] Add mentions and slash commands

dependencies.gradle

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -172,6 +172,7 @@ ext.libs = [
172172
'kluent' : "org.amshove.kluent:kluent-android:1.73",
173173
'timberJunitRule' : "net.lachlanmckee:timber-junit-rule:1.0.1",
174174
'junit' : "junit:junit:4.13.2",
175+
'robolectric' : "org.robolectric:robolectric:4.9",
175176
]
176177
]
177178

dependencies_groups.gradle

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -189,6 +189,7 @@ ext.groups = [
189189
'org.codehaus.groovy',
190190
'org.codehaus.mojo',
191191
'org.codehaus.woodstox',
192+
'org.conscrypt',
192193
'org.eclipse.ee4j',
193194
'org.ec4j.core',
194195
'org.freemarker',
@@ -221,6 +222,7 @@ ext.groups = [
221222
'org.ow2.asm',
222223
'org.ow2.asm',
223224
'org.reactivestreams',
225+
'org.robolectric',
224226
'org.slf4j',
225227
'org.sonatype.oss',
226228
'org.testng',

vector/build.gradle

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -299,6 +299,7 @@ dependencies {
299299
testImplementation libs.tests.kluent
300300
testImplementation libs.mockk.mockk
301301
testImplementation libs.androidx.coreTesting
302+
testImplementation libs.tests.robolectric
302303
// Plant Timber tree for test
303304
testImplementation libs.tests.timberJunitRule
304305
testImplementation libs.airbnb.mavericksTesting

vector/src/main/java/im/vector/app/features/home/room/detail/AutoCompleter.kt

Lines changed: 80 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -40,23 +40,31 @@ import im.vector.app.features.displayname.getBestName
4040
import im.vector.app.features.home.AvatarRenderer
4141
import im.vector.app.features.html.PillImageSpan
4242
import im.vector.app.features.themes.ThemeUtils
43+
import io.element.android.wysiwyg.EditorEditText
44+
import org.matrix.android.sdk.api.session.Session
45+
import org.matrix.android.sdk.api.session.permalinks.PermalinkService
4346
import org.matrix.android.sdk.api.session.room.model.RoomSummary
4447
import org.matrix.android.sdk.api.util.MatrixItem
4548
import org.matrix.android.sdk.api.util.toEveryoneInRoomMatrixItem
4649
import org.matrix.android.sdk.api.util.toMatrixItem
4750
import org.matrix.android.sdk.api.util.toRoomAliasMatrixItem
51+
import timber.log.Timber
4852

4953
class AutoCompleter @AssistedInject constructor(
5054
@Assisted val roomId: String,
5155
@Assisted val isInThreadTimeline: Boolean,
56+
private val session: Session,
5257
private val avatarRenderer: AvatarRenderer,
5358
private val commandAutocompletePolicy: CommandAutocompletePolicy,
5459
autocompleteCommandPresenterFactory: AutocompleteCommandPresenter.Factory,
5560
private val autocompleteMemberPresenterFactory: AutocompleteMemberPresenter.Factory,
5661
private val autocompleteRoomPresenter: AutocompleteRoomPresenter,
57-
private val autocompleteEmojiPresenter: AutocompleteEmojiPresenter
62+
private val autocompleteEmojiPresenter: AutocompleteEmojiPresenter,
5863
) {
5964

65+
private val permalinkService: PermalinkService
66+
get() = session.permalinkService()
67+
6068
private lateinit var autocompleteMemberPresenter: AutocompleteMemberPresenter
6169

6270
@AssistedFactory
@@ -79,6 +87,7 @@ class AutoCompleter @AssistedInject constructor(
7987
}
8088

8189
private lateinit var glideRequests: GlideRequests
90+
private val autocompletes: MutableSet<Autocomplete<*>> = hashSetOf()
8291

8392
fun setup(editText: EditText) {
8493
this.editText = editText
@@ -90,26 +99,41 @@ class AutoCompleter @AssistedInject constructor(
9099
setupRooms(backgroundDrawable, editText)
91100
}
92101

102+
fun setEnabled(isEnabled: Boolean) =
103+
autocompletes.forEach {
104+
if (!isEnabled) { it.dismissPopup() }
105+
it.setEnabled(isEnabled)
106+
}
107+
93108
fun clear() {
94109
this.editText = null
95110
autocompleteEmojiPresenter.clear()
96111
autocompleteRoomPresenter.clear()
97112
autocompleteCommandPresenter.clear()
98113
autocompleteMemberPresenter.clear()
114+
autocompletes.forEach {
115+
it.setEnabled(false)
116+
it.dismissPopup()
117+
}
118+
autocompletes.clear()
99119
}
100120

101121
private fun setupCommands(backgroundDrawable: Drawable, editText: EditText) {
102-
Autocomplete.on<Command>(editText)
122+
autocompletes += Autocomplete.on<Command>(editText)
103123
.with(commandAutocompletePolicy)
104124
.with(autocompleteCommandPresenter)
105125
.with(ELEVATION_DP)
106126
.with(backgroundDrawable)
107127
.with(object : AutocompleteCallback<Command> {
108128
override fun onPopupItemClicked(editable: Editable, item: Command): Boolean {
109-
editable.clear()
110-
editable
111-
.append(item.command)
112-
.append(" ")
129+
if (editText is EditorEditText) {
130+
editText.replaceTextSuggestion(item.command)
131+
} else {
132+
editable.clear()
133+
editable
134+
.append(item.command)
135+
.append(" ")
136+
}
113137
return true
114138
}
115139

@@ -121,24 +145,22 @@ class AutoCompleter @AssistedInject constructor(
121145

122146
private fun setupMembers(backgroundDrawable: ColorDrawable, editText: EditText) {
123147
autocompleteMemberPresenter = autocompleteMemberPresenterFactory.create(roomId)
124-
Autocomplete.on<AutocompleteMemberItem>(editText)
148+
autocompletes += Autocomplete.on<AutocompleteMemberItem>(editText)
125149
.with(CharPolicy(TRIGGER_AUTO_COMPLETE_MEMBERS, true))
126150
.with(autocompleteMemberPresenter)
127151
.with(ELEVATION_DP)
128152
.with(backgroundDrawable)
129153
.with(object : AutocompleteCallback<AutocompleteMemberItem> {
130154
override fun onPopupItemClicked(editable: Editable, item: AutocompleteMemberItem): Boolean {
131-
return when (item) {
132-
is AutocompleteMemberItem.Header -> false // do nothing header is not clickable
133-
is AutocompleteMemberItem.RoomMember -> {
134-
insertMatrixItem(editText, editable, TRIGGER_AUTO_COMPLETE_MEMBERS, item.roomMemberSummary.toMatrixItem())
135-
true
136-
}
137-
is AutocompleteMemberItem.Everyone -> {
138-
insertMatrixItem(editText, editable, TRIGGER_AUTO_COMPLETE_MEMBERS, item.roomSummary.toEveryoneInRoomMatrixItem())
139-
true
140-
}
141-
}
155+
val matrixItem = when (item) {
156+
is AutocompleteMemberItem.Header -> null // do nothing header is not clickable
157+
is AutocompleteMemberItem.RoomMember -> item.roomMemberSummary.toMatrixItem()
158+
is AutocompleteMemberItem.Everyone -> item.roomSummary.toEveryoneInRoomMatrixItem()
159+
} ?: return false
160+
161+
insertMatrixItem(editText, editable, TRIGGER_AUTO_COMPLETE_MEMBERS, matrixItem)
162+
163+
return true
142164
}
143165

144166
override fun onPopupVisibilityChanged(shown: Boolean) {
@@ -148,7 +170,7 @@ class AutoCompleter @AssistedInject constructor(
148170
}
149171

150172
private fun setupRooms(backgroundDrawable: ColorDrawable, editText: EditText) {
151-
Autocomplete.on<RoomSummary>(editText)
173+
autocompletes += Autocomplete.on<RoomSummary>(editText)
152174
.with(CharPolicy(TRIGGER_AUTO_COMPLETE_ROOMS, true))
153175
.with(autocompleteRoomPresenter)
154176
.with(ELEVATION_DP)
@@ -166,7 +188,10 @@ class AutoCompleter @AssistedInject constructor(
166188
}
167189

168190
private fun setupEmojis(backgroundDrawable: Drawable, editText: EditText) {
169-
Autocomplete.on<String>(editText)
191+
// Rich text editor is not yet supported
192+
if (editText is EditorEditText) return
193+
194+
autocompletes += Autocomplete.on<String>(editText)
170195
.with(CharPolicy(TRIGGER_AUTO_COMPLETE_EMOJIS, false))
171196
.with(autocompleteEmojiPresenter)
172197
.with(ELEVATION_DP)
@@ -197,7 +222,41 @@ class AutoCompleter @AssistedInject constructor(
197222
.build()
198223
}
199224

200-
private fun insertMatrixItem(editText: EditText, editable: Editable, firstChar: Char, matrixItem: MatrixItem) {
225+
private fun insertMatrixItem(editText: EditText, editable: Editable, firstChar: Char, matrixItem: MatrixItem) =
226+
if (editText is EditorEditText) {
227+
insertMatrixItemIntoRichTextEditor(editText, matrixItem)
228+
} else {
229+
insertMatrixItemIntoEditable(editText, editable, firstChar, matrixItem)
230+
}
231+
232+
private fun insertMatrixItemIntoRichTextEditor(editorEditText: EditorEditText, matrixItem: MatrixItem) {
233+
if (matrixItem is MatrixItem.EveryoneInRoomItem) {
234+
editorEditText.replaceTextSuggestion(matrixItem.displayName)
235+
return
236+
}
237+
238+
val permalink = permalinkService.createPermalink(matrixItem.id)
239+
240+
if (permalink == null) {
241+
Timber.e(NullPointerException("Cannot autocomplete as permalink is null"))
242+
return
243+
}
244+
245+
val linkText = when (matrixItem) {
246+
is MatrixItem.RoomAliasItem,
247+
is MatrixItem.RoomItem,
248+
is MatrixItem.SpaceItem ->
249+
matrixItem.id
250+
is MatrixItem.EveryoneInRoomItem,
251+
is MatrixItem.UserItem,
252+
is MatrixItem.EventItem ->
253+
matrixItem.getBestName()
254+
}
255+
256+
editorEditText.setLinkSuggestion(url = permalink, text = linkText)
257+
}
258+
259+
private fun insertMatrixItemIntoEditable(editText: EditText, editable: Editable, firstChar: Char, matrixItem: MatrixItem) {
201260
// Detect last firstChar and remove it
202261
var startIndex = editable.lastIndexOf(firstChar)
203262
if (startIndex == -1) {

vector/src/main/java/im/vector/app/features/home/room/detail/TimelineViewModel.kt

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -765,6 +765,9 @@ class TimelineViewModel @AssistedInject constructor(
765765
return room?.membershipService()?.getRoomMember(userId)
766766
}
767767

768+
fun getRoom(roomId: String): RoomSummary? =
769+
session.roomService().getRoomSummary(roomId)
770+
768771
private fun handleComposerFocusChange(action: RoomDetailAction.ComposerFocusChange) {
769772
if (room == null) return
770773
// Ensure outbound session keys

vector/src/main/java/im/vector/app/features/home/room/detail/composer/MessageComposerFragment.kt

Lines changed: 64 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,7 @@ import im.vector.app.features.home.room.detail.TimelineViewModel
8383
import im.vector.app.features.home.room.detail.composer.link.SetLinkFragment
8484
import im.vector.app.features.home.room.detail.composer.link.SetLinkSharedAction
8585
import im.vector.app.features.home.room.detail.composer.link.SetLinkSharedActionViewModel
86+
import im.vector.app.features.home.room.detail.composer.mentions.PillDisplayHandler
8687
import im.vector.app.features.home.room.detail.composer.voice.VoiceMessageRecorderView
8788
import im.vector.app.features.home.room.detail.timeline.action.MessageSharedActionViewModel
8889
import im.vector.app.features.home.room.detail.upgrade.MigrateRoomBottomSheet
@@ -100,6 +101,7 @@ import kotlinx.coroutines.flow.map
100101
import kotlinx.coroutines.flow.onEach
101102
import org.matrix.android.sdk.api.session.Session
102103
import org.matrix.android.sdk.api.session.content.ContentAttachmentData
104+
import org.matrix.android.sdk.api.session.permalinks.PermalinkService
103105
import org.matrix.android.sdk.api.util.MatrixItem
104106
import reactivecircus.flowbinding.android.view.focusChanges
105107
import reactivecircus.flowbinding.android.widget.textChanges
@@ -122,11 +124,12 @@ class MessageComposerFragment : VectorBaseFragment<FragmentComposerBinding>(), A
122124
@Inject lateinit var session: Session
123125
@Inject lateinit var errorTracker: ErrorTracker
124126

127+
private val permalinkService: PermalinkService
128+
get() = session.permalinkService()
129+
125130
private val roomId: String get() = withState(timelineViewModel) { it.roomId }
126131

127-
private val autoCompleter: AutoCompleter by lazy {
128-
autoCompleterFactory.create(roomId, isThreadTimeLine())
129-
}
132+
private val autoCompleters: MutableMap<EditText, AutoCompleter> = hashMapOf()
130133

131134
private val emojiPopup: EmojiPopup by lifecycleAwareLazy {
132135
createEmojiPopup()
@@ -261,9 +264,8 @@ class MessageComposerFragment : VectorBaseFragment<FragmentComposerBinding>(), A
261264
override fun onDestroyView() {
262265
super.onDestroyView()
263266

264-
if (!vectorPreferences.isRichTextEditorEnabled()) {
265-
autoCompleter.clear()
266-
}
267+
autoCompleters.values.forEach(AutoCompleter::clear)
268+
autoCompleters.clear()
267269
messageComposerViewModel.endAllVoiceActions()
268270
}
269271

@@ -274,7 +276,12 @@ class MessageComposerFragment : VectorBaseFragment<FragmentComposerBinding>(), A
274276

275277
(composer as? View)?.isVisible = messageComposerState.isComposerVisible
276278
composer.sendButton.isInvisible = !messageComposerState.isSendButtonVisible
277-
(composer as? RichTextComposerLayout)?.isTextFormattingEnabled = attachmentState.isTextFormattingEnabled
279+
(composer as? RichTextComposerLayout)?.also {
280+
val isTextFormattingEnabled = attachmentState.isTextFormattingEnabled
281+
it.isTextFormattingEnabled = isTextFormattingEnabled
282+
autoCompleters[it.richTextEditText]?.setEnabled(isTextFormattingEnabled)
283+
autoCompleters[it.plainTextEditText]?.setEnabled(!isTextFormattingEnabled)
284+
}
278285
}
279286

280287
private fun setupBottomSheet() {
@@ -315,8 +322,11 @@ class MessageComposerFragment : VectorBaseFragment<FragmentComposerBinding>(), A
315322
val composerEditText = composer.editText
316323
composerEditText.setHint(R.string.room_message_placeholder)
317324

318-
if (!vectorPreferences.isRichTextEditorEnabled()) {
319-
autoCompleter.setup(composerEditText)
325+
(composer as? RichTextComposerLayout)?.let {
326+
initAutoCompleter(it.richTextEditText)
327+
initAutoCompleter(it.plainTextEditText)
328+
} ?: run {
329+
initAutoCompleter(composer.editText)
320330
}
321331

322332
observerUserTyping()
@@ -404,6 +414,21 @@ class MessageComposerFragment : VectorBaseFragment<FragmentComposerBinding>(), A
404414
SetLinkFragment.show(isTextSupported, initialLink, childFragmentManager)
405415
}
406416
}
417+
(composer as? RichTextComposerLayout)?.pillDisplayHandler = PillDisplayHandler(
418+
roomId = roomId,
419+
getRoom = timelineViewModel::getRoom,
420+
getMember = timelineViewModel::getMember,
421+
) { matrixItem: MatrixItem ->
422+
PillImageSpan(glideRequests, avatarRenderer, requireContext(), matrixItem)
423+
}
424+
}
425+
426+
private fun initAutoCompleter(editText: EditText) {
427+
if (autoCompleters.containsKey(editText)) return
428+
429+
autoCompleters[editText] =
430+
autoCompleterFactory.create(roomId, isThreadTimeLine())
431+
.also { it.setup(editText) }
407432
}
408433

409434
private fun sendTextMessage(text: CharSequence, formattedText: String? = null) {
@@ -435,12 +460,12 @@ class MessageComposerFragment : VectorBaseFragment<FragmentComposerBinding>(), A
435460
}
436461

437462
private fun renderRegularMode(content: CharSequence) {
438-
autoCompleter.exitSpecialMode()
463+
autoCompleters.values.forEach(AutoCompleter::exitSpecialMode)
439464
composer.renderComposerMode(MessageComposerMode.Normal(content))
440465
}
441466

442467
private fun renderSpecialMode(mode: MessageComposerMode.Special) {
443-
autoCompleter.enterSpecialMode()
468+
autoCompleters.values.forEach(AutoCompleter::enterSpecialMode)
444469
composer.renderComposerMode(mode)
445470
}
446471

@@ -771,30 +796,37 @@ class MessageComposerFragment : VectorBaseFragment<FragmentComposerBinding>(), A
771796
} else {
772797
val roomMember = timelineViewModel.getMember(userId)
773798
val displayName = sanitizeDisplayName(roomMember?.displayName ?: userId)
774-
val pill = buildSpannedString {
775-
append(displayName)
776-
setSpan(
777-
PillImageSpan(
778-
glideRequests,
779-
avatarRenderer,
780-
requireContext(),
781-
MatrixItem.UserItem(userId, displayName, roomMember?.avatarUrl)
782-
)
783-
.also { it.bind(composer.editText) },
784-
0,
785-
displayName.length,
786-
Spannable.SPAN_EXCLUSIVE_EXCLUSIVE
787-
)
788-
append(if (startToCompose) ": " else " ")
789-
}
790-
if (startToCompose) {
791-
if (displayName.startsWith("/")) {
799+
if ((composer as? RichTextComposerLayout)?.isTextFormattingEnabled == true) {
800+
// Rich text editor is enabled so we need to use its APIs
801+
permalinkService.createPermalink(userId)?.let { url ->
802+
(composer as RichTextComposerLayout).insertMention(url, displayName)
803+
composer.editText.append(" ")
804+
}
805+
} else {
806+
val pill = buildSpannedString {
807+
append(displayName)
808+
setSpan(
809+
PillImageSpan(
810+
glideRequests,
811+
avatarRenderer,
812+
requireContext(),
813+
MatrixItem.UserItem(userId, displayName, roomMember?.avatarUrl),
814+
)
815+
.also { it.bind(composer.editText) },
816+
0,
817+
displayName.length,
818+
Spannable.SPAN_EXCLUSIVE_EXCLUSIVE
819+
)
820+
append(if (startToCompose) ": " else " ")
821+
}
822+
if (startToCompose && displayName.startsWith("/")) {
792823
// Ensure displayName will not be interpreted as a Slash command
793824
composer.editText.append("\\")
794825
}
795-
composer.editText.append(pill)
796-
} else {
797-
composer.editText.text?.insert(composer.editText.selectionStart, pill)
826+
// Always use EditText.getText().insert for adding pills as TextView.append doesn't appear
827+
// to upgrade to BufferType.Spannable as hinted at in the docs:
828+
// https://developer.android.com/reference/android/widget/TextView#append(java.lang.CharSequence)
829+
composer.editText.text.insert(composer.editText.selectionStart, pill)
798830
}
799831
}
800832
focusComposerAndShowKeyboard()

0 commit comments

Comments
 (0)