Merge pull request #687 from vector-im/feature/dat_pill
Send mention pills from composer
This commit is contained in:
commit
ebf21fe9d8
|
@ -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.
|
||||
|
|
|
@ -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>>
|
||||
|
|
|
@ -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.
|
||||
|
|
|
@ -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
|
||||
}
|
|
@ -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
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
|
||||
|
|
|
@ -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,
|
||||
private val stringProvider: StringProvider,
|
||||
private val roomSummaryUpdater: RoomSummaryUpdater) {
|
||||
internal class LocalEchoEventFactory @Inject constructor(
|
||||
@UserId private val userId: String,
|
||||
private val stringProvider: StringProvider,
|
||||
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(
|
||||
|
|
|
@ -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
|
||||
)
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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)"
|
||||
}
|
||||
}
|
|
@ -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
|
||||
*/
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
|
|
|
@ -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()
|
||||
|
|
|
@ -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() {
|
||||
|
|
|
@ -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()
|
||||
|
|
|
@ -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
|
||||
}
|
||||
} else {
|
||||
// another user
|
||||
if (composerLayout.composerEditText.text.isNullOrBlank()) {
|
||||
// Ensure displayName will not be interpreted as a Slash command
|
||||
if (text.startsWith("/")) {
|
||||
composerLayout.composerEditText.append("\\")
|
||||
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 {
|
||||
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
|
||||
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() {
|
||||
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) {
|
||||
|
|
|
@ -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()
|
||||
}
|
||||
|
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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 *****************************************************************************
|
||||
|
|
|
@ -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()
|
||||
|
|
Loading…
Reference in New Issue