Send mention pills from composer

This commit is contained in:
Valere 2019-11-15 00:14:28 +01:00 committed by Benoit Marty
parent bf9ce4f690
commit 6bd7257cf2
13 changed files with 141 additions and 42 deletions

View file

@ -72,7 +72,7 @@ interface RelationService {
*/
fun editTextMessage(targetEventId: String,
msgType: String,
newBodyText: String,
newBodyText: CharSequence,
newBodyAutoMarkdown: Boolean,
compatibilityBodyText: String = "* $newBodyText"): Cancelable
@ -97,12 +97,14 @@ interface RelationService {
/**
* Reply to an event in the timeline (must be in same room)
* https://matrix.org/docs/spec/client_server/r0.4.0.html#id350
* The replyText can be a Spannable and contains special spans (UserMentionSpan) that will be translated
* by the sdk into pills.
* @param eventReplied the event referenced by the reply
* @param replyText the reply text
* @param autoMarkdown If true, the SDK will generate a formatted HTML message from the body text if markdown syntax is present
*/
fun replyToMessage(eventReplied: TimelineEvent,
replyText: String,
replyText: CharSequence,
autoMarkdown: Boolean = false): Cancelable?
fun getEventSummaryLive(eventId: String): LiveData<Optional<EventAnnotationsSummary>>

View file

@ -29,12 +29,14 @@ interface SendService {
/**
* Method to send a text message asynchronously.
* The text to send can be a Spannable and contains special spans (UserMentionSpan) that will be translated
* by the sdk into pills.
* @param text the text message to send
* @param msgType the message type: MessageType.MSGTYPE_TEXT (default) or MessageType.MSGTYPE_EMOTE
* @param autoMarkdown If true, the SDK will generate a formatted HTML message from the body text if markdown syntax is present
* @return a [Cancelable]
*/
fun sendTextMessage(text: String, msgType: String = MessageType.MSGTYPE_TEXT, autoMarkdown: Boolean = false): Cancelable
fun sendTextMessage(text: CharSequence, msgType: String = MessageType.MSGTYPE_TEXT, autoMarkdown: Boolean = false): Cancelable
/**
* Method to send a text message with a formatted body.
@ -42,7 +44,7 @@ interface SendService {
* @param formattedText The formatted body using MessageType#FORMAT_MATRIX_HTML
* @return a [Cancelable]
*/
fun sendFormattedTextMessage(text: String, formattedText: String): Cancelable
fun sendFormattedTextMessage(text: String, formattedText: String, msgType: String = MessageType.MSGTYPE_TEXT): Cancelable
/**
* Method to send a media asynchronously.

View file

@ -0,0 +1,25 @@
/*
* Copyright 2019 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.matrix.android.api.session.room.send
/**
* Tag class for spans that should mention a user.
* These Spans will be transformed into pills when detected in message to send
*/
interface UserMentionSpan {
abstract val displayName: String
abstract val userId: String
}

View file

@ -115,7 +115,7 @@ internal class DefaultRelationService @AssistedInject constructor(@Assisted priv
override fun editTextMessage(targetEventId: String,
msgType: String,
newBodyText: String,
newBodyText: CharSequence,
newBodyAutoMarkdown: Boolean,
compatibilityBodyText: String): Cancelable {
val event = eventFactory
@ -164,7 +164,7 @@ internal class DefaultRelationService @AssistedInject constructor(@Assisted priv
.executeBy(taskExecutor)
}
override fun replyToMessage(eventReplied: TimelineEvent, replyText: String, autoMarkdown: Boolean): Cancelable? {
override fun replyToMessage(eventReplied: TimelineEvent, replyText: CharSequence, autoMarkdown: Boolean): Cancelable? {
val event = eventFactory.createReplyTextEvent(roomId, eventReplied, replyText, autoMarkdown)
?.also { saveLocalEcho(it) }
?: return null

View file

@ -68,7 +68,7 @@ internal class DefaultSendService @AssistedInject constructor(@Assisted private
private val workerFutureListenerExecutor = Executors.newSingleThreadExecutor()
override fun sendTextMessage(text: String, msgType: String, autoMarkdown: Boolean): Cancelable {
override fun sendTextMessage(text: CharSequence, msgType: String, autoMarkdown: Boolean): Cancelable {
val event = localEchoEventFactory.createTextEvent(roomId, msgType, text, autoMarkdown).also {
saveLocalEcho(it)
}
@ -76,8 +76,8 @@ internal class DefaultSendService @AssistedInject constructor(@Assisted private
return sendEvent(event)
}
override fun sendFormattedTextMessage(text: String, formattedText: String): Cancelable {
val event = localEchoEventFactory.createFormattedTextEvent(roomId, TextContent(text, formattedText)).also {
override fun sendFormattedTextMessage(text: String, formattedText: String, msgType: String): Cancelable {
val event = localEchoEventFactory.createFormattedTextEvent(roomId, TextContent(text, formattedText), msgType).also {
saveLocalEcho(it)
}

View file

@ -17,6 +17,7 @@
package im.vector.matrix.android.internal.session.room.send
import android.media.MediaMetadataRetriever
import android.text.SpannableString
import androidx.exifinterface.media.ExifInterface
import com.zhuinden.monarchy.Monarchy
import im.vector.matrix.android.R
@ -28,6 +29,7 @@ import im.vector.matrix.android.api.session.room.model.relation.ReactionContent
import im.vector.matrix.android.api.session.room.model.relation.ReactionInfo
import im.vector.matrix.android.api.session.room.model.relation.RelationDefaultContent
import im.vector.matrix.android.api.session.room.model.relation.ReplyToContent
import im.vector.matrix.android.api.session.room.send.UserMentionSpan
import im.vector.matrix.android.api.session.room.timeline.TimelineEvent
import im.vector.matrix.android.api.session.room.timeline.getLastMessageContent
import im.vector.matrix.android.internal.database.helper.addSendingEvent
@ -58,37 +60,67 @@ internal class LocalEchoEventFactory @Inject constructor(@UserId private val use
// TODO Inject
private val renderer = HtmlRenderer.builder().build()
fun createTextEvent(roomId: String, msgType: String, text: String, autoMarkdown: Boolean): Event {
if (msgType == MessageType.MSGTYPE_TEXT) {
return createFormattedTextEvent(roomId, createTextContent(text, autoMarkdown))
fun createTextEvent(roomId: String, msgType: String, text: CharSequence, autoMarkdown: Boolean): Event {
if (msgType == MessageType.MSGTYPE_TEXT || msgType == MessageType.MSGTYPE_EMOTE) {
return createFormattedTextEvent(roomId, createTextContent(text, autoMarkdown), msgType)
}
val content = MessageTextContent(type = msgType, body = text)
val content = MessageTextContent(type = msgType, body = text.toString())
return createEvent(roomId, content)
}
private fun createTextContent(text: String, autoMarkdown: Boolean): TextContent {
private fun createTextContent(text: CharSequence, autoMarkdown: Boolean): TextContent {
if (autoMarkdown) {
val document = parser.parse(text)
val source = transformPills(text,"[%2\$s](https://matrix.to/#/%1\$s)") ?: text.toString()
val document = parser.parse(source)
val htmlText = renderer.render(document)
if (isFormattedTextPertinent(text, htmlText)) {
return TextContent(text, htmlText)
if (isFormattedTextPertinent(source, htmlText)) {
return TextContent(source, htmlText)
}
} else {
//Try to detect pills
transformPills(text, "<a href=\"https://matrix.to/#/%1\$s\">%2\$s</a>")?.let {
return TextContent(text.toString(),it)
}
}
return TextContent(text)
return TextContent(text.toString())
}
private fun transformPills(text: CharSequence,
template : String)
: String? {
val bufSB = StringBuffer()
var currIndex = 0
SpannableString.valueOf(text).let {
val pills = it.getSpans(0, text.length, UserMentionSpan::class.java)
if (pills.isNotEmpty()) {
pills.forEachIndexed { _, urlSpan ->
val start = it.getSpanStart(urlSpan)
val end = it.getSpanEnd(urlSpan)
//We want to replace with the pill with a html link
bufSB.append(text, currIndex, start)
bufSB.append(String.format(template,urlSpan.userId,urlSpan.displayName))
currIndex = end
}
bufSB.append(text, currIndex, text.length)
return bufSB.toString()
} else {
return null
}
}
}
private fun isFormattedTextPertinent(text: String, htmlText: String?) =
text != htmlText && htmlText != "<p>${text.trim()}</p>\n"
fun createFormattedTextEvent(roomId: String, textContent: TextContent): Event {
return createEvent(roomId, textContent.toMessageTextContent())
fun createFormattedTextEvent(roomId: String, textContent: TextContent, msgType: String): Event {
return createEvent(roomId, textContent.toMessageTextContent(msgType))
}
fun createReplaceTextEvent(roomId: String,
targetEventId: String,
newBodyText: String,
newBodyText: CharSequence,
newBodyAutoMarkdown: Boolean,
msgType: String,
compatibilityText: String): Event {
@ -279,7 +311,7 @@ internal class LocalEchoEventFactory @Inject constructor(@UserId private val use
return System.currentTimeMillis()
}
fun createReplyTextEvent(roomId: String, eventReplied: TimelineEvent, replyText: String, autoMarkdown: Boolean): Event? {
fun createReplyTextEvent(roomId: String, eventReplied: TimelineEvent, replyText: CharSequence, autoMarkdown: Boolean): Event? {
// Fallbacks and event representation
// TODO Add error/warning logs when any of this is null
val permalink = PermalinkFactory.createPermalink(eventReplied.root) ?: return null
@ -298,7 +330,7 @@ internal class LocalEchoEventFactory @Inject constructor(@UserId private val use
//
// > <@alice:example.org> This is the original body
//
val replyFallback = buildReplyFallback(body, userId, replyText)
val replyFallback = buildReplyFallback(body, userId, replyText.toString())
val eventId = eventReplied.root.eventId ?: return null
val content = MessageTextContent(

View file

@ -27,7 +27,7 @@ object CommandParser {
* @param textMessage the text message
* @return a parsed slash command (ok or error)
*/
fun parseSplashCommand(textMessage: String): ParsedCommand {
fun parseSplashCommand(textMessage: CharSequence): ParsedCommand {
// check if it has the Slash marker
if (!textMessage.startsWith("/")) {
return ParsedCommand.ErrorNotACommand
@ -76,7 +76,7 @@ object CommandParser {
}
}
Command.EMOTE.command -> {
val message = textMessage.substring(Command.EMOTE.command.length).trim()
val message = textMessage.subSequence(Command.EMOTE.command.length, textMessage.length).trim()
ParsedCommand.SendEmote(message)
}

View file

@ -33,7 +33,7 @@ sealed class ParsedCommand {
// Valid commands:
class SendEmote(val message: String) : ParsedCommand()
class SendEmote(val message: CharSequence) : ParsedCommand()
class BanUser(val userId: String, val reason: String) : ParsedCommand()
class UnbanUser(val userId: String) : ParsedCommand()
class SetUserPowerLevel(val userId: String, val powerLevel: Int) : ParsedCommand()

View file

@ -25,7 +25,7 @@ import im.vector.riotx.core.platform.VectorViewModelAction
sealed class RoomDetailAction : VectorViewModelAction {
data class SaveDraft(val draft: String) : RoomDetailAction()
data class SendMessage(val text: String, val autoMarkdown: Boolean) : RoomDetailAction()
data class SendMessage(val text: CharSequence, val autoMarkdown: Boolean) : RoomDetailAction()
data class SendMedia(val attachments: List<ContentAttachmentData>) : RoomDetailAction()
data class TimelineEventTurnsVisible(val event: TimelineEvent) : RoomDetailAction()
data class TimelineEventTurnsInvisible(val event: TimelineEvent) : RoomDetailAction()

View file

@ -28,6 +28,7 @@ import android.os.Bundle
import android.os.Parcelable
import android.text.Editable
import android.text.Spannable
import android.text.SpannableStringBuilder
import android.view.*
import android.view.inputmethod.InputMethodManager
import android.widget.TextView
@ -609,7 +610,7 @@ class RoomDetailFragment @Inject constructor(
attachmentTypeSelector.show(composerLayout.attachmentButton, keyboardStateUtils.isKeyboardShowing)
}
override fun onSendMessage(text: String) {
override fun onSendMessage(text: CharSequence) {
if (lockSendButton) {
Timber.w("Send button is locked")
return
@ -977,7 +978,9 @@ class RoomDetailFragment @Inject constructor(
@SuppressLint("SetTextI18n")
override fun onMemberNameClicked(informationData: MessageInformationData) {
insertUserDisplayNameInTextEditor(informationData.memberName?.toString())
session.getUser(informationData.senderId)?.let {
insertUserDisplayNameInTextEditor(it)
}
}
override fun onClickOnReactionPill(informationData: MessageInformationData, reaction: String, on: Boolean) {
@ -1166,8 +1169,9 @@ class RoomDetailFragment @Inject constructor(
* @param text the text to insert.
*/
// TODO legacy, refactor
private fun insertUserDisplayNameInTextEditor(text: String?) {
private fun insertUserDisplayNameInTextEditor(member: User) {
// TODO move logic outside of fragment
val text = member.displayName
if (null != text) {
// var vibrate = false
@ -1176,19 +1180,44 @@ class RoomDetailFragment @Inject constructor(
// current user
if (composerLayout.composerEditText.text.isNullOrBlank()) {
composerLayout.composerEditText.append(Command.EMOTE.command + " ")
composerLayout.composerEditText.setSelection(composerLayout.composerEditText.text?.length ?: 0)
composerLayout.composerEditText.setSelection(composerLayout.composerEditText.text?.length
?: 0)
// vibrate = true
}
} else {
// another user
val sanitizeDisplayName = sanitizeDisplayName(text)
if (composerLayout.composerEditText.text.isNullOrBlank()) {
// Ensure displayName will not be interpreted as a Slash command
if (text.startsWith("/")) {
composerLayout.composerEditText.append("\\")
}
composerLayout.composerEditText.append(sanitizeDisplayName(text) + ": ")
SpannableStringBuilder().apply {
append(sanitizeDisplayName)
setSpan(
PillImageSpan(glideRequests, avatarRenderer, requireContext(), member.userId, member),
0,
sanitizeDisplayName.length,
Spannable.SPAN_EXCLUSIVE_EXCLUSIVE
)
append(": ")
}.let {
composerLayout.composerEditText.append(it)
}
} else {
composerLayout.composerEditText.text?.insert(composerLayout.composerEditText.selectionStart, sanitizeDisplayName(text) + " ")
SpannableStringBuilder().apply {
append(sanitizeDisplayName)
setSpan(
PillImageSpan(glideRequests, avatarRenderer, requireContext(), member.userId, member),
0,
sanitizeDisplayName.length,
Spannable.SPAN_EXCLUSIVE_EXCLUSIVE
)
append(" ")
}.let {
composerLayout.composerEditText.text?.insert(composerLayout.composerEditText.selectionStart, it)
}
// composerLayout.composerEditText.text?.insert(composerLayout.composerEditText.selectionStart, sanitizeDisplayName + " ")
}
// vibrate = true

View file

@ -34,6 +34,7 @@ import im.vector.matrix.android.api.session.events.model.toModel
import im.vector.matrix.android.api.session.file.FileService
import im.vector.matrix.android.api.session.homeserver.HomeServerCapabilities
import im.vector.matrix.android.api.session.room.model.Membership
import im.vector.matrix.android.api.session.room.model.RoomMember
import im.vector.matrix.android.api.session.room.model.message.MessageContent
import im.vector.matrix.android.api.session.room.model.message.MessageType
import im.vector.matrix.android.api.session.room.model.message.getFileUrl
@ -165,6 +166,9 @@ class RoomDetailViewModel @AssistedInject constructor(@Assisted initialState: Ro
invisibleEventsObservable.accept(action)
}
fun getMember(userId: String) : RoomMember? {
return room.getRoomMember(userId)
}
/**
* Convert a send mode to a draft and save the draft
*/
@ -355,7 +359,7 @@ class RoomDetailViewModel @AssistedInject constructor(@Assisted initialState: Ro
if (inReplyTo != null) {
// TODO check if same content?
room.getTimeLineEvent(inReplyTo)?.let {
room.editReply(state.sendMode.timelineEvent, it, action.text)
room.editReply(state.sendMode.timelineEvent, it, action.text.toString())
}
} else {
val messageContent: MessageContent? =
@ -380,7 +384,9 @@ class RoomDetailViewModel @AssistedInject constructor(@Assisted initialState: Ro
?: state.sendMode.timelineEvent.root.getClearContent().toModel()
val textMsg = messageContent?.body
val finalText = legacyRiotQuoteText(textMsg, action.text)
val finalText = legacyRiotQuoteText(textMsg, action.text.toString())
//TODO check for pills?
// TODO Refactor this, just temporary for quotes
val parser = Parser.builder().build()
@ -397,7 +403,7 @@ class RoomDetailViewModel @AssistedInject constructor(@Assisted initialState: Ro
}
is SendMode.REPLY -> {
state.sendMode.timelineEvent.let {
room.replyToMessage(it, action.text, action.autoMarkdown)
room.replyToMessage(it, action.text.toString(), action.autoMarkdown)
_sendMessageResultLiveData.postLiveEvent(SendMessageResult.MessageSent)
popDraft()
}

View file

@ -26,6 +26,7 @@ import android.widget.ImageView
import android.widget.TextView
import androidx.constraintlayout.widget.ConstraintLayout
import androidx.constraintlayout.widget.ConstraintSet
import androidx.core.text.toSpannable
import androidx.transition.AutoTransition
import androidx.transition.Transition
import androidx.transition.TransitionManager
@ -43,7 +44,7 @@ class TextComposerView @JvmOverloads constructor(context: Context, attrs: Attrib
interface Callback : ComposerEditText.Callback {
fun onCloseRelatedMessage()
fun onSendMessage(text: String)
fun onSendMessage(text: CharSequence)
fun onAddAttachment()
}
@ -86,8 +87,8 @@ class TextComposerView @JvmOverloads constructor(context: Context, attrs: Attrib
}
sendButton.setOnClickListener {
val textMessage = text?.toString() ?: ""
callback?.onSendMessage(textMessage)
val textMessage = text?.toSpannable()
callback?.onSendMessage(textMessage ?: "")
}
attachmentButton.setOnClickListener {

View file

@ -28,6 +28,7 @@ import androidx.annotation.UiThread
import com.bumptech.glide.request.target.SimpleTarget
import com.bumptech.glide.request.transition.Transition
import com.google.android.material.chip.ChipDrawable
import im.vector.matrix.android.api.session.room.send.UserMentionSpan
import im.vector.matrix.android.api.session.user.model.User
import im.vector.riotx.R
import im.vector.riotx.core.glide.GlideRequests
@ -37,14 +38,15 @@ import java.lang.ref.WeakReference
/**
* This span is able to replace a text by a [ChipDrawable]
* It's needed to call [bind] method to start requesting avatar, otherwise only the placeholder icon will be displayed if not already cached.
* Implements UserMentionSpan so that it could be automatically transformed in matrix links and displayed as pills.
*/
class PillImageSpan(private val glideRequests: GlideRequests,
private val avatarRenderer: AvatarRenderer,
private val context: Context,
private val userId: String,
private val user: User?) : ReplacementSpan() {
override val userId: String,
private val user: User?) : ReplacementSpan(), UserMentionSpan {
private val displayName by lazy {
override val displayName by lazy {
if (user?.displayName.isNullOrEmpty()) userId else user?.displayName!!
}