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 🙌:
-
- Send mention Pills from composer
Other changes:
- Fix a small grammatical error when an empty room list is shown.

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,20 +29,23 @@ 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.
* @param text the text message to send
* @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]
*/
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,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,
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

@ -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.session.content.ThumbnailExtractor
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 org.commonmark.parser.Parser
import org.commonmark.renderer.html.HtmlRenderer
@ -50,45 +51,55 @@ import javax.inject.Inject
*
* The transactionID is used as loc
*/
internal class LocalEchoEventFactory @Inject constructor(@UserId private val userId: String,
internal class LocalEchoEventFactory @Inject constructor(
@UserId private val userId: String,
private val stringProvider: StringProvider,
private val roomSummaryUpdater: RoomSummaryUpdater) {
private val roomSummaryUpdater: RoomSummaryUpdater,
private val textPillsUtils: TextPillsUtils
) {
// TODO Inject
private val parser = Parser.builder().build()
// 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 = textPillsUtils.processSpecialSpansToMarkdown(text)
?: 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
textPillsUtils.processSpecialSpansToHtml(text)?.let {
return TextContent(text.toString(), it)
}
}
return TextContent(text)
return TextContent(text.toString())
}
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 +290,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 +309,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

@ -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"
inline fun <T> T.ooi(block: (T) -> Unit): T = also(block)
/**
* Apply argument to a Fragment
*/

View File

@ -21,6 +21,14 @@ import android.view.View
import android.view.inputmethod.InputMethodManager
fun View.hideKeyboard() {
val imm = context.getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager
imm.hideSoftInputFromWindow(windowToken, 0)
val imm = context?.getSystemService(Context.INPUT_METHOD_SERVICE) as? InputMethodManager
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
* @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

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

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

@ -18,7 +18,6 @@ package im.vector.riotx.features.home.room.detail
import android.annotation.SuppressLint
import android.app.Activity.RESULT_OK
import android.content.Context
import android.content.DialogInterface
import android.content.Intent
import android.graphics.drawable.ColorDrawable
@ -29,7 +28,6 @@ import android.os.Parcelable
import android.text.Editable
import android.text.Spannable
import android.view.*
import android.view.inputmethod.InputMethodManager
import android.widget.TextView
import android.widget.Toast
import androidx.annotation.DrawableRes
@ -37,6 +35,7 @@ import androidx.annotation.StringRes
import androidx.appcompat.app.AlertDialog
import androidx.core.app.ActivityOptionsCompat
import androidx.core.content.ContextCompat
import androidx.core.text.buildSpannedString
import androidx.core.util.Pair
import androidx.core.view.ViewCompat
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.observeEvent
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.glide.GlideApp
import im.vector.riotx.core.platform.VectorBaseFragment
@ -158,7 +158,7 @@ class RoomDetailFragment @Inject constructor(
companion object {
/**x
/**
* Sanitize the display name.
*
* @param displayName the display name to sanitize
@ -405,7 +405,12 @@ class RoomDetailFragment @Inject constructor(
composerLayout.composerRelatedMessageActionIcon.setImageDrawable(ContextCompat.getDrawable(requireContext(), iconRes))
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 {
// need to do it here also when not using quick reply
focusComposerAndShowKeyboard()
@ -588,7 +593,13 @@ class RoomDetailFragment @Inject constructor(
// Add the span
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)
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)
}
override fun onSendMessage(text: String) {
override fun onSendMessage(text: CharSequence) {
if (lockSendButton) {
Timber.w("Send button is locked")
return
@ -975,9 +986,8 @@ class RoomDetailFragment @Inject constructor(
vectorBaseActivity.notImplemented("Click on user avatar")
}
@SuppressLint("SetTextI18n")
override fun onMemberNameClicked(informationData: MessageInformationData) {
insertUserDisplayNameInTextEditor(informationData.memberName?.toString())
insertUserDisplayNameInTextEditor(informationData.senderId)
}
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
private fun insertUserDisplayNameInTextEditor(text: String?) {
// TODO move logic outside of fragment
if (null != text) {
// var vibrate = false
@SuppressLint("SetTextI18n")
private fun insertUserDisplayNameInTextEditor(userId: String) {
val startToCompose = composerLayout.composerEditText.text.isNullOrBlank()
val myDisplayName = session.getUser(session.myUserId)?.displayName
if (myDisplayName == text) {
// current user
if (composerLayout.composerEditText.text.isNullOrBlank()) {
composerLayout.composerEditText.append(Command.EMOTE.command + " ")
composerLayout.composerEditText.setSelection(composerLayout.composerEditText.text?.length ?: 0)
// vibrate = true
}
if (startToCompose
&& userId == session.myUserId) {
// Empty composer, current user: start an emote
composerLayout.composerEditText.setText(Command.EMOTE.command + " ")
composerLayout.composerEditText.setSelection(Command.EMOTE.command.length + 1)
} else {
// another user
if (composerLayout.composerEditText.text.isNullOrBlank()) {
val roomMember = roomDetailViewModel.getMember(userId)
// TODO move logic outside of fragment
(roomMember?.displayName ?: userId)
.let { sanitizeDisplayName(it) }
.let { displayName ->
buildSpannedString {
append(displayName)
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
if (text.startsWith("/")) {
composerLayout.composerEditText.append("\\")
}
composerLayout.composerEditText.append(sanitizeDisplayName(text) + ": ")
composerLayout.composerEditText.append(pill)
} else {
composerLayout.composerEditText.text?.insert(composerLayout.composerEditText.selectionStart, sanitizeDisplayName(text) + " ")
composerLayout.composerEditText.text?.insert(composerLayout.composerEditText.selectionStart, pill)
}
}
}
}
// vibrate = true
}
// if (vibrate && vectorPreferences.vibrateWhenMentioning()) {
// val v= context.getSystemService(Context.VIBRATOR_SERVICE) as? Vibrator
// if (v?.hasVibrator() == true) {
// v.vibrate(100)
// }
// }
focusComposerAndShowKeyboard()
}
}
private fun focusComposerAndShowKeyboard() {
composerLayout.composerEditText.requestFocus()
val imm = context?.getSystemService(Context.INPUT_METHOD_SERVICE) as? InputMethodManager
imm?.showSoftInput(composerLayout.composerEditText, InputMethodManager.SHOW_IMPLICIT)
composerLayout.composerEditText.showKeyboard(andRequestFocus = true)
}
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.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

@ -20,12 +20,17 @@ package im.vector.riotx.features.home.room.detail.composer
import android.content.Context
import android.net.Uri
import android.os.Build
import android.text.Editable
import android.util.AttributeSet
import android.view.inputmethod.EditorInfo
import android.view.inputmethod.InputConnection
import androidx.appcompat.widget.AppCompatEditText
import androidx.core.view.inputmethod.EditorInfoCompat
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)
: AppCompatEditText(context, attrs, defStyleAttr) {
@ -55,4 +60,41 @@ class ComposerEditText @JvmOverloads constructor(context: Context, attrs: Attrib
}
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 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,7 +87,7 @@ class TextComposerView @JvmOverloads constructor(context: Context, attrs: Attrib
}
sendButton.setOnClickListener {
val textMessage = text?.toString() ?: ""
val textMessage = text?.toSpannable() ?: ""
callback?.onSendMessage(textMessage)
}

View File

@ -41,7 +41,8 @@ class MxLinkTagHandler(private val glideRequests: GlideRequests,
when (permalinkData) {
is PermalinkData.UserLink -> {
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(
visitor.builder(),
span,

View File

@ -28,7 +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.user.model.User
import im.vector.matrix.android.api.session.room.send.UserMentionSpan
import im.vector.riotx.R
import im.vector.riotx.core.glide.GlideRequests
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]
* 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() {
private val displayName by lazy {
if (user?.displayName.isNullOrEmpty()) userId else user?.displayName!!
}
override val userId: String,
override val displayName: String,
private val avatarUrl: String?) : ReplacementSpan(), UserMentionSpan {
private val pillDrawable = createChipDrawable()
private val target = PillImageSpanTarget(this)
@ -55,7 +53,7 @@ class PillImageSpan(private val glideRequests: GlideRequests,
@UiThread
fun bind(textView: TextView) {
tv = WeakReference(textView)
avatarRenderer.render(context, glideRequests, user?.avatarUrl, userId, displayName, target)
avatarRenderer.render(context, glideRequests, avatarUrl, userId, displayName, target)
}
// ReplacementSpan *****************************************************************************

View File

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