Merge pull request #687 from vector-im/feature/dat_pill

Send mention pills from composer
This commit is contained in:
Valere 2019-11-29 16:28:34 +01:00 committed by GitHub
commit ebf21fe9d8
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
23 changed files with 383 additions and 106 deletions

View File

@ -5,7 +5,7 @@ Features ✨:
- -
Improvements 🙌: Improvements 🙌:
- - Send mention Pills from composer
Other changes: Other changes:
- Fix a small grammatical error when an empty room list is shown. - Fix a small grammatical error when an empty room list is shown.

View File

@ -72,7 +72,7 @@ interface RelationService {
*/ */
fun editTextMessage(targetEventId: String, fun editTextMessage(targetEventId: String,
msgType: String, msgType: String,
newBodyText: String, newBodyText: CharSequence,
newBodyAutoMarkdown: Boolean, newBodyAutoMarkdown: Boolean,
compatibilityBodyText: String = "* $newBodyText"): Cancelable compatibilityBodyText: String = "* $newBodyText"): Cancelable
@ -97,12 +97,14 @@ interface RelationService {
/** /**
* Reply to an event in the timeline (must be in same room) * Reply to an event in the timeline (must be in same room)
* https://matrix.org/docs/spec/client_server/r0.4.0.html#id350 * 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 eventReplied the event referenced by the reply
* @param replyText the reply text * @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 * @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, fun replyToMessage(eventReplied: TimelineEvent,
replyText: String, replyText: CharSequence,
autoMarkdown: Boolean = false): Cancelable? autoMarkdown: Boolean = false): Cancelable?
fun getEventSummaryLive(eventId: String): LiveData<Optional<EventAnnotationsSummary>> fun getEventSummaryLive(eventId: String): LiveData<Optional<EventAnnotationsSummary>>

View File

@ -29,20 +29,23 @@ interface SendService {
/** /**
* Method to send a text message asynchronously. * 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 text the text message to send
* @param msgType the message type: MessageType.MSGTYPE_TEXT (default) or MessageType.MSGTYPE_EMOTE * @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 * @param autoMarkdown If true, the SDK will generate a formatted HTML message from the body text if markdown syntax is present
* @return a [Cancelable] * @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. * Method to send a text message with a formatted body.
* @param text the text message to send * @param text the text message to send
* @param formattedText The formatted body using MessageType#FORMAT_MATRIX_HTML * @param formattedText The formatted body using MessageType#FORMAT_MATRIX_HTML
* @param msgType the message type: MessageType.MSGTYPE_TEXT (default) or MessageType.MSGTYPE_EMOTE
* @return a [Cancelable] * @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. * Method to send a media asynchronously.

View File

@ -0,0 +1,26 @@
/*
* 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 {
val displayName: String
val userId: String
}

View File

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

View File

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

View File

@ -36,6 +36,7 @@ import im.vector.matrix.android.internal.database.query.where
import im.vector.matrix.android.internal.di.UserId import im.vector.matrix.android.internal.di.UserId
import im.vector.matrix.android.internal.session.content.ThumbnailExtractor import im.vector.matrix.android.internal.session.content.ThumbnailExtractor
import im.vector.matrix.android.internal.session.room.RoomSummaryUpdater import im.vector.matrix.android.internal.session.room.RoomSummaryUpdater
import im.vector.matrix.android.internal.session.room.send.pills.TextPillsUtils
import im.vector.matrix.android.internal.util.StringProvider import im.vector.matrix.android.internal.util.StringProvider
import org.commonmark.parser.Parser import org.commonmark.parser.Parser
import org.commonmark.renderer.html.HtmlRenderer import org.commonmark.renderer.html.HtmlRenderer
@ -50,45 +51,55 @@ import javax.inject.Inject
* *
* The transactionID is used as loc * The transactionID is used as loc
*/ */
internal class LocalEchoEventFactory @Inject constructor(@UserId private val userId: String, internal class LocalEchoEventFactory @Inject constructor(
private val stringProvider: StringProvider, @UserId private val userId: String,
private val roomSummaryUpdater: RoomSummaryUpdater) { private val stringProvider: StringProvider,
private val roomSummaryUpdater: RoomSummaryUpdater,
private val textPillsUtils: TextPillsUtils
) {
// TODO Inject // TODO Inject
private val parser = Parser.builder().build() private val parser = Parser.builder().build()
// TODO Inject // TODO Inject
private val renderer = HtmlRenderer.builder().build() private val renderer = HtmlRenderer.builder().build()
fun createTextEvent(roomId: String, msgType: String, text: String, autoMarkdown: Boolean): Event { fun createTextEvent(roomId: String, msgType: String, text: CharSequence, autoMarkdown: Boolean): Event {
if (msgType == MessageType.MSGTYPE_TEXT) { if (msgType == MessageType.MSGTYPE_TEXT || msgType == MessageType.MSGTYPE_EMOTE) {
return createFormattedTextEvent(roomId, createTextContent(text, autoMarkdown)) 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) return createEvent(roomId, content)
} }
private fun createTextContent(text: String, autoMarkdown: Boolean): TextContent { private fun createTextContent(text: CharSequence, autoMarkdown: Boolean): TextContent {
if (autoMarkdown) { if (autoMarkdown) {
val document = parser.parse(text) val source = textPillsUtils.processSpecialSpansToMarkdown(text)
?: text.toString()
val document = parser.parse(source)
val htmlText = renderer.render(document) val htmlText = renderer.render(document)
if (isFormattedTextPertinent(text, htmlText)) { if (isFormattedTextPertinent(source, htmlText)) {
return TextContent(text, htmlText) return TextContent(source, htmlText)
}
} else {
// Try to detect pills
textPillsUtils.processSpecialSpansToHtml(text)?.let {
return TextContent(text.toString(), it)
} }
} }
return TextContent(text) return TextContent(text.toString())
} }
private fun isFormattedTextPertinent(text: String, htmlText: String?) = private fun isFormattedTextPertinent(text: String, htmlText: String?) =
text != htmlText && htmlText != "<p>${text.trim()}</p>\n" text != htmlText && htmlText != "<p>${text.trim()}</p>\n"
fun createFormattedTextEvent(roomId: String, textContent: TextContent): Event { fun createFormattedTextEvent(roomId: String, textContent: TextContent, msgType: String): Event {
return createEvent(roomId, textContent.toMessageTextContent()) return createEvent(roomId, textContent.toMessageTextContent(msgType))
} }
fun createReplaceTextEvent(roomId: String, fun createReplaceTextEvent(roomId: String,
targetEventId: String, targetEventId: String,
newBodyText: String, newBodyText: CharSequence,
newBodyAutoMarkdown: Boolean, newBodyAutoMarkdown: Boolean,
msgType: String, msgType: String,
compatibilityText: String): Event { compatibilityText: String): Event {
@ -279,7 +290,7 @@ internal class LocalEchoEventFactory @Inject constructor(@UserId private val use
return System.currentTimeMillis() 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 // Fallbacks and event representation
// TODO Add error/warning logs when any of this is null // TODO Add error/warning logs when any of this is null
val permalink = PermalinkFactory.createPermalink(eventReplied.root) ?: return null val permalink = PermalinkFactory.createPermalink(eventReplied.root) ?: return null
@ -298,7 +309,7 @@ internal class LocalEchoEventFactory @Inject constructor(@UserId private val use
// //
// > <@alice:example.org> This is the original body // > <@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 eventId = eventReplied.root.eventId ?: return null
val content = MessageTextContent( val content = MessageTextContent(

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.internal.session.room.send.pills
import im.vector.matrix.android.api.session.room.send.UserMentionSpan
internal data class MentionLinkSpec(
val span: UserMentionSpan,
val start: Int,
val end: Int
)

View File

@ -0,0 +1,32 @@
/*
* 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.internal.session.room.send.pills
import javax.inject.Inject
internal class MentionLinkSpecComparator @Inject constructor() : Comparator<MentionLinkSpec> {
override fun compare(o1: MentionLinkSpec, o2: MentionLinkSpec): Int {
return when {
o1.start < o2.start -> -1
o1.start > o2.start -> 1
o1.end < o2.end -> 1
o1.end > o2.end -> -1
else -> 0
}
}
}

View File

@ -0,0 +1,110 @@
/*
* 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.internal.session.room.send.pills
import android.text.SpannableString
import im.vector.matrix.android.api.session.room.send.UserMentionSpan
import java.util.*
import javax.inject.Inject
/**
* Utility class to detect special span in CharSequence and turn them into
* formatted text to send them as a Matrix messages.
*
* For now only support UserMentionSpans (TODO rooms, room aliases, etc...)
*/
internal class TextPillsUtils @Inject constructor(
private val mentionLinkSpecComparator: MentionLinkSpecComparator
) {
/**
* Detects if transformable spans are present in the text.
* @return the transformed String or null if no Span found
*/
fun processSpecialSpansToHtml(text: CharSequence): String? {
return transformPills(text, MENTION_SPAN_TO_HTML_TEMPLATE)
}
/**
* Detects if transformable spans are present in the text.
* @return the transformed String or null if no Span found
*/
fun processSpecialSpansToMarkdown(text: CharSequence): String? {
return transformPills(text, MENTION_SPAN_TO_MD_TEMPLATE)
}
private fun transformPills(text: CharSequence, template: String): String? {
val spannableString = SpannableString.valueOf(text)
val pills = spannableString
?.getSpans(0, text.length, UserMentionSpan::class.java)
?.map { MentionLinkSpec(it, spannableString.getSpanStart(it), spannableString.getSpanEnd(it)) }
?.toMutableList()
?.takeIf { it.isNotEmpty() }
?: return null
// we need to prune overlaps!
pruneOverlaps(pills)
return buildString {
var currIndex = 0
pills.forEachIndexed { _, (urlSpan, start, end) ->
// We want to replace with the pill with a html link
append(text, currIndex, start)
append(String.format(template, urlSpan.userId, urlSpan.displayName))
currIndex = end
}
}
}
private fun pruneOverlaps(links: MutableList<MentionLinkSpec>) {
Collections.sort(links, mentionLinkSpecComparator)
var len = links.size
var i = 0
while (i < len - 1) {
val a = links[i]
val b = links[i + 1]
var remove = -1
// test if there is an overlap
if (b.start in a.start until a.end) {
when {
b.end <= a.end ->
// b is inside a -> b should be removed
remove = i + 1
a.end - a.start > b.end - b.start ->
// overlap and a is bigger -> b should be removed
remove = i + 1
a.end - a.start < b.end - b.start ->
// overlap and a is smaller -> a should be removed
remove = i
}
if (remove != -1) {
links.removeAt(remove)
len--
continue
}
}
i++
}
}
companion object {
private const val MENTION_SPAN_TO_HTML_TEMPLATE = "<a href=\"https://matrix.to/#/%1\$s\">%2\$s</a>"
private const val MENTION_SPAN_TO_MD_TEMPLATE = "[%2\$s](https://matrix.to/#/%1\$s)"
}
}

View File

@ -21,6 +21,8 @@ import androidx.fragment.app.Fragment
fun Boolean.toOnOff() = if (this) "ON" else "OFF" fun Boolean.toOnOff() = if (this) "ON" else "OFF"
inline fun <T> T.ooi(block: (T) -> Unit): T = also(block)
/** /**
* Apply argument to a Fragment * Apply argument to a Fragment
*/ */

View File

@ -21,6 +21,14 @@ import android.view.View
import android.view.inputmethod.InputMethodManager import android.view.inputmethod.InputMethodManager
fun View.hideKeyboard() { fun View.hideKeyboard() {
val imm = context.getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager val imm = context?.getSystemService(Context.INPUT_METHOD_SERVICE) as? InputMethodManager
imm.hideSoftInputFromWindow(windowToken, 0) imm?.hideSoftInputFromWindow(windowToken, 0)
}
fun View.showKeyboard(andRequestFocus: Boolean = false) {
if (andRequestFocus) {
requestFocus()
}
val imm = context?.getSystemService(Context.INPUT_METHOD_SERVICE) as? InputMethodManager
imm?.showSoftInput(this, InputMethodManager.SHOW_IMPLICIT)
} }

View File

@ -27,7 +27,7 @@ object CommandParser {
* @param textMessage the text message * @param textMessage the text message
* @return a parsed slash command (ok or error) * @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 // check if it has the Slash marker
if (!textMessage.startsWith("/")) { if (!textMessage.startsWith("/")) {
return ParsedCommand.ErrorNotACommand return ParsedCommand.ErrorNotACommand
@ -76,7 +76,7 @@ object CommandParser {
} }
} }
Command.EMOTE.command -> { 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) ParsedCommand.SendEmote(message)
} }

View File

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

View File

@ -16,10 +16,8 @@
package im.vector.riotx.features.home.createdirect package im.vector.riotx.features.home.createdirect
import android.content.Context
import android.os.Bundle import android.os.Bundle
import android.view.View import android.view.View
import android.view.inputmethod.InputMethodManager
import com.airbnb.mvrx.activityViewModel import com.airbnb.mvrx.activityViewModel
import com.airbnb.mvrx.withState import com.airbnb.mvrx.withState
import com.jakewharton.rxbinding3.widget.textChanges import com.jakewharton.rxbinding3.widget.textChanges
@ -27,6 +25,7 @@ import im.vector.matrix.android.api.session.user.model.User
import im.vector.riotx.R import im.vector.riotx.R
import im.vector.riotx.core.extensions.hideKeyboard import im.vector.riotx.core.extensions.hideKeyboard
import im.vector.riotx.core.extensions.setupAsSearch import im.vector.riotx.core.extensions.setupAsSearch
import im.vector.riotx.core.extensions.showKeyboard
import im.vector.riotx.core.platform.VectorBaseFragment import im.vector.riotx.core.platform.VectorBaseFragment
import kotlinx.android.synthetic.main.fragment_create_direct_room_directory_users.* import kotlinx.android.synthetic.main.fragment_create_direct_room_directory_users.*
import javax.inject.Inject import javax.inject.Inject
@ -63,9 +62,7 @@ class CreateDirectRoomDirectoryUsersFragment @Inject constructor(
viewModel.handle(CreateDirectRoomAction.SearchDirectoryUsers(it.toString())) viewModel.handle(CreateDirectRoomAction.SearchDirectoryUsers(it.toString()))
} }
.disposeOnDestroyView() .disposeOnDestroyView()
createDirectRoomSearchById.requestFocus() createDirectRoomSearchById.showKeyboard(andRequestFocus = true)
val imm = context?.getSystemService(Context.INPUT_METHOD_SERVICE) as? InputMethodManager
imm?.showSoftInput(createDirectRoomSearchById, InputMethodManager.SHOW_IMPLICIT)
} }
private fun setupCloseView() { private fun setupCloseView() {

View File

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

View File

@ -18,7 +18,6 @@ package im.vector.riotx.features.home.room.detail
import android.annotation.SuppressLint import android.annotation.SuppressLint
import android.app.Activity.RESULT_OK import android.app.Activity.RESULT_OK
import android.content.Context
import android.content.DialogInterface import android.content.DialogInterface
import android.content.Intent import android.content.Intent
import android.graphics.drawable.ColorDrawable import android.graphics.drawable.ColorDrawable
@ -29,7 +28,6 @@ import android.os.Parcelable
import android.text.Editable import android.text.Editable
import android.text.Spannable import android.text.Spannable
import android.view.* import android.view.*
import android.view.inputmethod.InputMethodManager
import android.widget.TextView import android.widget.TextView
import android.widget.Toast import android.widget.Toast
import androidx.annotation.DrawableRes import androidx.annotation.DrawableRes
@ -37,6 +35,7 @@ import androidx.annotation.StringRes
import androidx.appcompat.app.AlertDialog import androidx.appcompat.app.AlertDialog
import androidx.core.app.ActivityOptionsCompat import androidx.core.app.ActivityOptionsCompat
import androidx.core.content.ContextCompat import androidx.core.content.ContextCompat
import androidx.core.text.buildSpannedString
import androidx.core.util.Pair import androidx.core.util.Pair
import androidx.core.view.ViewCompat import androidx.core.view.ViewCompat
import androidx.core.view.forEach import androidx.core.view.forEach
@ -73,6 +72,7 @@ import im.vector.riotx.core.error.ErrorFormatter
import im.vector.riotx.core.extensions.hideKeyboard import im.vector.riotx.core.extensions.hideKeyboard
import im.vector.riotx.core.extensions.observeEvent import im.vector.riotx.core.extensions.observeEvent
import im.vector.riotx.core.extensions.setTextOrHide import im.vector.riotx.core.extensions.setTextOrHide
import im.vector.riotx.core.extensions.showKeyboard
import im.vector.riotx.core.files.addEntryToDownloadManager import im.vector.riotx.core.files.addEntryToDownloadManager
import im.vector.riotx.core.glide.GlideApp import im.vector.riotx.core.glide.GlideApp
import im.vector.riotx.core.platform.VectorBaseFragment import im.vector.riotx.core.platform.VectorBaseFragment
@ -158,7 +158,7 @@ class RoomDetailFragment @Inject constructor(
companion object { companion object {
/**x /**
* Sanitize the display name. * Sanitize the display name.
* *
* @param displayName the display name to sanitize * @param displayName the display name to sanitize
@ -405,7 +405,12 @@ class RoomDetailFragment @Inject constructor(
composerLayout.composerRelatedMessageActionIcon.setImageDrawable(ContextCompat.getDrawable(requireContext(), iconRes)) composerLayout.composerRelatedMessageActionIcon.setImageDrawable(ContextCompat.getDrawable(requireContext(), iconRes))
composerLayout.sendButton.setContentDescription(getString(descriptionRes)) composerLayout.sendButton.setContentDescription(getString(descriptionRes))
avatarRenderer.render(event.senderAvatar, event.root.senderId ?: "", event.getDisambiguatedDisplayName(), composerLayout.composerRelatedMessageAvatar) avatarRenderer.render(
event.senderAvatar,
event.root.senderId ?: "",
event.getDisambiguatedDisplayName(),
composerLayout.composerRelatedMessageAvatar
)
composerLayout.expand { composerLayout.expand {
// need to do it here also when not using quick reply // need to do it here also when not using quick reply
focusComposerAndShowKeyboard() focusComposerAndShowKeyboard()
@ -588,7 +593,13 @@ class RoomDetailFragment @Inject constructor(
// Add the span // Add the span
val user = session.getUser(item.userId) val user = session.getUser(item.userId)
val span = PillImageSpan(glideRequests, avatarRenderer, requireContext(), item.userId, user) val span = PillImageSpan(
glideRequests,
avatarRenderer,
requireContext(),
item.userId,
user?.displayName ?: item.userId,
user?.avatarUrl)
span.bind(composerLayout.composerEditText) span.bind(composerLayout.composerEditText)
editable.setSpan(span, startIndex, startIndex + displayName.length, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE) editable.setSpan(span, startIndex, startIndex + displayName.length, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE)
@ -609,7 +620,7 @@ class RoomDetailFragment @Inject constructor(
attachmentTypeSelector.show(composerLayout.attachmentButton, keyboardStateUtils.isKeyboardShowing) attachmentTypeSelector.show(composerLayout.attachmentButton, keyboardStateUtils.isKeyboardShowing)
} }
override fun onSendMessage(text: String) { override fun onSendMessage(text: CharSequence) {
if (lockSendButton) { if (lockSendButton) {
Timber.w("Send button is locked") Timber.w("Send button is locked")
return return
@ -975,9 +986,8 @@ class RoomDetailFragment @Inject constructor(
vectorBaseActivity.notImplemented("Click on user avatar") vectorBaseActivity.notImplemented("Click on user avatar")
} }
@SuppressLint("SetTextI18n")
override fun onMemberNameClicked(informationData: MessageInformationData) { override fun onMemberNameClicked(informationData: MessageInformationData) {
insertUserDisplayNameInTextEditor(informationData.memberName?.toString()) insertUserDisplayNameInTextEditor(informationData.senderId)
} }
override fun onClickOnReactionPill(informationData: MessageInformationData, reaction: String, on: Boolean) { override fun onClickOnReactionPill(informationData: MessageInformationData, reaction: String, on: Boolean) {
@ -1159,55 +1169,61 @@ class RoomDetailFragment @Inject constructor(
} }
} }
// utils
/** /**
* Insert an user displayname in the message editor. * Insert a user displayName in the message editor.
* *
* @param text the text to insert. * @param userId the userId.
*/ */
// TODO legacy, refactor @SuppressLint("SetTextI18n")
private fun insertUserDisplayNameInTextEditor(text: String?) { private fun insertUserDisplayNameInTextEditor(userId: String) {
// TODO move logic outside of fragment val startToCompose = composerLayout.composerEditText.text.isNullOrBlank()
if (null != text) {
// var vibrate = false
val myDisplayName = session.getUser(session.myUserId)?.displayName if (startToCompose
if (myDisplayName == text) { && userId == session.myUserId) {
// current user // Empty composer, current user: start an emote
if (composerLayout.composerEditText.text.isNullOrBlank()) { composerLayout.composerEditText.setText(Command.EMOTE.command + " ")
composerLayout.composerEditText.append(Command.EMOTE.command + " ") composerLayout.composerEditText.setSelection(Command.EMOTE.command.length + 1)
composerLayout.composerEditText.setSelection(composerLayout.composerEditText.text?.length ?: 0) } else {
// vibrate = true val roomMember = roomDetailViewModel.getMember(userId)
} // TODO move logic outside of fragment
} else { (roomMember?.displayName ?: userId)
// another user .let { sanitizeDisplayName(it) }
if (composerLayout.composerEditText.text.isNullOrBlank()) { .let { displayName ->
// Ensure displayName will not be interpreted as a Slash command buildSpannedString {
if (text.startsWith("/")) { append(displayName)
composerLayout.composerEditText.append("\\") setSpan(
PillImageSpan(
glideRequests,
avatarRenderer,
requireContext(),
userId,
displayName,
roomMember?.avatarUrl)
.also { it.bind(composerLayout.composerEditText) },
0,
displayName.length,
Spannable.SPAN_EXCLUSIVE_EXCLUSIVE
)
append(if (startToCompose) ": " else " ")
}.let { pill ->
if (startToCompose) {
if (displayName.startsWith("/")) {
// Ensure displayName will not be interpreted as a Slash command
composerLayout.composerEditText.append("\\")
}
composerLayout.composerEditText.append(pill)
} else {
composerLayout.composerEditText.text?.insert(composerLayout.composerEditText.selectionStart, pill)
}
}
} }
composerLayout.composerEditText.append(sanitizeDisplayName(text) + ": ")
} else {
composerLayout.composerEditText.text?.insert(composerLayout.composerEditText.selectionStart, sanitizeDisplayName(text) + " ")
}
// vibrate = true
}
// if (vibrate && vectorPreferences.vibrateWhenMentioning()) {
// val v= context.getSystemService(Context.VIBRATOR_SERVICE) as? Vibrator
// if (v?.hasVibrator() == true) {
// v.vibrate(100)
// }
// }
focusComposerAndShowKeyboard()
} }
focusComposerAndShowKeyboard()
} }
private fun focusComposerAndShowKeyboard() { private fun focusComposerAndShowKeyboard() {
composerLayout.composerEditText.requestFocus() composerLayout.composerEditText.showKeyboard(andRequestFocus = true)
val imm = context?.getSystemService(Context.INPUT_METHOD_SERVICE) as? InputMethodManager
imm?.showSoftInput(composerLayout.composerEditText, InputMethodManager.SHOW_IMPLICIT)
} }
private fun showSnackWithMessage(message: String, duration: Int = Snackbar.LENGTH_SHORT) { private fun showSnackWithMessage(message: String, duration: Int = Snackbar.LENGTH_SHORT) {

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

View File

@ -20,12 +20,17 @@ package im.vector.riotx.features.home.room.detail.composer
import android.content.Context import android.content.Context
import android.net.Uri import android.net.Uri
import android.os.Build import android.os.Build
import android.text.Editable
import android.util.AttributeSet import android.util.AttributeSet
import android.view.inputmethod.EditorInfo import android.view.inputmethod.EditorInfo
import android.view.inputmethod.InputConnection import android.view.inputmethod.InputConnection
import androidx.appcompat.widget.AppCompatEditText import androidx.appcompat.widget.AppCompatEditText
import androidx.core.view.inputmethod.EditorInfoCompat import androidx.core.view.inputmethod.EditorInfoCompat
import androidx.core.view.inputmethod.InputConnectionCompat import androidx.core.view.inputmethod.InputConnectionCompat
import im.vector.riotx.core.extensions.ooi
import im.vector.riotx.core.platform.SimpleTextWatcher
import im.vector.riotx.features.html.PillImageSpan
import timber.log.Timber
class ComposerEditText @JvmOverloads constructor(context: Context, attrs: AttributeSet? = null, defStyleAttr: Int = android.R.attr.editTextStyle) class ComposerEditText @JvmOverloads constructor(context: Context, attrs: AttributeSet? = null, defStyleAttr: Int = android.R.attr.editTextStyle)
: AppCompatEditText(context, attrs, defStyleAttr) { : AppCompatEditText(context, attrs, defStyleAttr) {
@ -55,4 +60,41 @@ class ComposerEditText @JvmOverloads constructor(context: Context, attrs: Attrib
} }
return InputConnectionCompat.createWrapper(ic, editorInfo, callback) return InputConnectionCompat.createWrapper(ic, editorInfo, callback)
} }
init {
addTextChangedListener(
object : SimpleTextWatcher() {
var spanToRemove: PillImageSpan? = null
override fun beforeTextChanged(s: CharSequence, start: Int, count: Int, after: Int) {
Timber.v("Pills: beforeTextChanged: start:$start count:$count after:$after")
if (count > after) {
// A char has been deleted
val deleteCharPosition = start + count
Timber.v("Pills: beforeTextChanged: deleted char at $deleteCharPosition")
// Get the first span at this position
spanToRemove = editableText.getSpans(deleteCharPosition, deleteCharPosition, PillImageSpan::class.java)
.ooi { Timber.v("Pills: beforeTextChanged: found ${it.size} span(s)") }
.firstOrNull()
}
}
override fun afterTextChanged(s: Editable) {
if (spanToRemove != null) {
val start = editableText.getSpanStart(spanToRemove)
val end = editableText.getSpanEnd(spanToRemove)
Timber.v("Pills: afterTextChanged Removing the span start:$start end:$end")
// Must be done before text replacement
editableText.removeSpan(spanToRemove)
if (start != -1 && end != -1) {
editableText.replace(start, end, "")
}
spanToRemove = null
}
}
}
)
}
} }

View File

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

View File

@ -41,7 +41,8 @@ class MxLinkTagHandler(private val glideRequests: GlideRequests,
when (permalinkData) { when (permalinkData) {
is PermalinkData.UserLink -> { is PermalinkData.UserLink -> {
val user = sessionHolder.getSafeActiveSession()?.getUser(permalinkData.userId) val user = sessionHolder.getSafeActiveSession()?.getUser(permalinkData.userId)
val span = PillImageSpan(glideRequests, avatarRenderer, context, permalinkData.userId, user) val span = PillImageSpan(glideRequests, avatarRenderer, context, permalinkData.userId, user?.displayName
?: permalinkData.userId, user?.avatarUrl)
SpannableBuilder.setSpans( SpannableBuilder.setSpans(
visitor.builder(), visitor.builder(),
span, span,

View File

@ -28,7 +28,7 @@ import androidx.annotation.UiThread
import com.bumptech.glide.request.target.SimpleTarget import com.bumptech.glide.request.target.SimpleTarget
import com.bumptech.glide.request.transition.Transition import com.bumptech.glide.request.transition.Transition
import com.google.android.material.chip.ChipDrawable import com.google.android.material.chip.ChipDrawable
import im.vector.matrix.android.api.session.user.model.User import im.vector.matrix.android.api.session.room.send.UserMentionSpan
import im.vector.riotx.R import im.vector.riotx.R
import im.vector.riotx.core.glide.GlideRequests import im.vector.riotx.core.glide.GlideRequests
import im.vector.riotx.features.home.AvatarRenderer import im.vector.riotx.features.home.AvatarRenderer
@ -37,16 +37,14 @@ import java.lang.ref.WeakReference
/** /**
* This span is able to replace a text by a [ChipDrawable] * 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. * 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, class PillImageSpan(private val glideRequests: GlideRequests,
private val avatarRenderer: AvatarRenderer, private val avatarRenderer: AvatarRenderer,
private val context: Context, private val context: Context,
private val userId: String, override val userId: String,
private val user: User?) : ReplacementSpan() { override val displayName: String,
private val avatarUrl: String?) : ReplacementSpan(), UserMentionSpan {
private val displayName by lazy {
if (user?.displayName.isNullOrEmpty()) userId else user?.displayName!!
}
private val pillDrawable = createChipDrawable() private val pillDrawable = createChipDrawable()
private val target = PillImageSpanTarget(this) private val target = PillImageSpanTarget(this)
@ -55,7 +53,7 @@ class PillImageSpan(private val glideRequests: GlideRequests,
@UiThread @UiThread
fun bind(textView: TextView) { fun bind(textView: TextView) {
tv = WeakReference(textView) tv = WeakReference(textView)
avatarRenderer.render(context, glideRequests, user?.avatarUrl, userId, displayName, target) avatarRenderer.render(context, glideRequests, avatarUrl, userId, displayName, target)
} }
// ReplacementSpan ***************************************************************************** // ReplacementSpan *****************************************************************************

View File

@ -19,13 +19,11 @@
package im.vector.riotx.features.settings package im.vector.riotx.features.settings
import android.app.Activity import android.app.Activity
import android.content.Context
import android.content.Intent import android.content.Intent
import android.text.Editable import android.text.Editable
import android.util.Patterns import android.util.Patterns
import android.view.View import android.view.View
import android.view.ViewGroup import android.view.ViewGroup
import android.view.inputmethod.InputMethodManager
import android.widget.ImageView import android.widget.ImageView
import androidx.appcompat.app.AlertDialog import androidx.appcompat.app.AlertDialog
import androidx.core.content.ContextCompat import androidx.core.content.ContextCompat
@ -38,6 +36,7 @@ import com.bumptech.glide.load.engine.cache.DiskCache
import com.google.android.material.textfield.TextInputEditText import com.google.android.material.textfield.TextInputEditText
import com.google.android.material.textfield.TextInputLayout import com.google.android.material.textfield.TextInputLayout
import im.vector.riotx.R import im.vector.riotx.R
import im.vector.riotx.core.extensions.hideKeyboard
import im.vector.riotx.core.extensions.showPassword import im.vector.riotx.core.extensions.showPassword
import im.vector.riotx.core.platform.SimpleTextWatcher import im.vector.riotx.core.platform.SimpleTextWatcher
import im.vector.riotx.core.preference.UserAvatarPreference import im.vector.riotx.core.preference.UserAvatarPreference
@ -696,8 +695,7 @@ class VectorSettingsGeneralFragment : VectorSettingsBaseFragment() {
.setPositiveButton(R.string.settings_change_password_submit, null) .setPositiveButton(R.string.settings_change_password_submit, null)
.setNegativeButton(R.string.cancel, null) .setNegativeButton(R.string.cancel, null)
.setOnDismissListener { .setOnDismissListener {
val imm = activity.getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager view.hideKeyboard()
imm.hideSoftInputFromWindow(view.applicationWindowToken, 0)
} }
.create() .create()
@ -762,8 +760,7 @@ class VectorSettingsGeneralFragment : VectorSettingsBaseFragment() {
showPassword.performClick() showPassword.performClick()
} }
val imm = activity.getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager view.hideKeyboard()
imm.hideSoftInputFromWindow(view.applicationWindowToken, 0)
val oldPwd = oldPasswordText.text.toString().trim() val oldPwd = oldPasswordText.text.toString().trim()
val newPwd = newPasswordText.text.toString().trim() val newPwd = newPasswordText.text.toString().trim()