diff --git a/CHANGES.md b/CHANGES.md index 0f1c76203d..27619f7d28 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -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. diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/session/room/model/relation/RelationService.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/session/room/model/relation/RelationService.kt index 5af5183dfa..385699b4db 100644 --- a/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/session/room/model/relation/RelationService.kt +++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/session/room/model/relation/RelationService.kt @@ -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> diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/session/room/send/SendService.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/session/room/send/SendService.kt index 8c783837a2..bdae5eaaa6 100644 --- a/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/session/room/send/SendService.kt +++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/session/room/send/SendService.kt @@ -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. diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/session/room/send/UserMentionSpan.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/session/room/send/UserMentionSpan.kt new file mode 100644 index 0000000000..4cd8080dc3 --- /dev/null +++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/session/room/send/UserMentionSpan.kt @@ -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 +} diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/relation/DefaultRelationService.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/relation/DefaultRelationService.kt index 11be821d7e..db3b6100a0 100644 --- a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/relation/DefaultRelationService.kt +++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/relation/DefaultRelationService.kt @@ -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 diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/send/DefaultSendService.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/send/DefaultSendService.kt index 7c720e56a7..8fad03b588 100644 --- a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/send/DefaultSendService.kt +++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/send/DefaultSendService.kt @@ -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) } diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/send/LocalEchoEventFactory.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/send/LocalEchoEventFactory.kt index 3fa0dcdca1..b773d1f892 100644 --- a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/send/LocalEchoEventFactory.kt +++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/send/LocalEchoEventFactory.kt @@ -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 != "

${text.trim()}

\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( diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/send/pills/MentionLinkSpec.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/send/pills/MentionLinkSpec.kt new file mode 100644 index 0000000000..5ad61b5441 --- /dev/null +++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/send/pills/MentionLinkSpec.kt @@ -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 +) diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/send/pills/MentionLinkSpecComparator.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/send/pills/MentionLinkSpecComparator.kt new file mode 100644 index 0000000000..76fd8336cc --- /dev/null +++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/send/pills/MentionLinkSpecComparator.kt @@ -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 { + + 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 + } + } +} diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/send/pills/TextPillsUtils.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/send/pills/TextPillsUtils.kt new file mode 100644 index 0000000000..02f48e5800 --- /dev/null +++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/send/pills/TextPillsUtils.kt @@ -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) { + 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 = "%2\$s" + + private const val MENTION_SPAN_TO_MD_TEMPLATE = "[%2\$s](https://matrix.to/#/%1\$s)" + } +} diff --git a/vector/src/main/java/im/vector/riotx/core/extensions/BasicExtensions.kt b/vector/src/main/java/im/vector/riotx/core/extensions/BasicExtensions.kt index 2dc75c5fa2..1e3da7f878 100644 --- a/vector/src/main/java/im/vector/riotx/core/extensions/BasicExtensions.kt +++ b/vector/src/main/java/im/vector/riotx/core/extensions/BasicExtensions.kt @@ -21,6 +21,8 @@ import androidx.fragment.app.Fragment fun Boolean.toOnOff() = if (this) "ON" else "OFF" +inline fun T.ooi(block: (T) -> Unit): T = also(block) + /** * Apply argument to a Fragment */ diff --git a/vector/src/main/java/im/vector/riotx/core/extensions/View.kt b/vector/src/main/java/im/vector/riotx/core/extensions/View.kt index bcbab97360..41f98ed264 100644 --- a/vector/src/main/java/im/vector/riotx/core/extensions/View.kt +++ b/vector/src/main/java/im/vector/riotx/core/extensions/View.kt @@ -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) } diff --git a/vector/src/main/java/im/vector/riotx/features/command/CommandParser.kt b/vector/src/main/java/im/vector/riotx/features/command/CommandParser.kt index 3f5808949b..bc451f8e84 100644 --- a/vector/src/main/java/im/vector/riotx/features/command/CommandParser.kt +++ b/vector/src/main/java/im/vector/riotx/features/command/CommandParser.kt @@ -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) } diff --git a/vector/src/main/java/im/vector/riotx/features/command/ParsedCommand.kt b/vector/src/main/java/im/vector/riotx/features/command/ParsedCommand.kt index 02f5abe540..89438c8a9d 100644 --- a/vector/src/main/java/im/vector/riotx/features/command/ParsedCommand.kt +++ b/vector/src/main/java/im/vector/riotx/features/command/ParsedCommand.kt @@ -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() diff --git a/vector/src/main/java/im/vector/riotx/features/home/createdirect/CreateDirectRoomDirectoryUsersFragment.kt b/vector/src/main/java/im/vector/riotx/features/home/createdirect/CreateDirectRoomDirectoryUsersFragment.kt index cf6abf12e9..59f31ec2ee 100644 --- a/vector/src/main/java/im/vector/riotx/features/home/createdirect/CreateDirectRoomDirectoryUsersFragment.kt +++ b/vector/src/main/java/im/vector/riotx/features/home/createdirect/CreateDirectRoomDirectoryUsersFragment.kt @@ -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() { diff --git a/vector/src/main/java/im/vector/riotx/features/home/room/detail/RoomDetailAction.kt b/vector/src/main/java/im/vector/riotx/features/home/room/detail/RoomDetailAction.kt index 2e59e70d08..0a6321dd57 100644 --- a/vector/src/main/java/im/vector/riotx/features/home/room/detail/RoomDetailAction.kt +++ b/vector/src/main/java/im/vector/riotx/features/home/room/detail/RoomDetailAction.kt @@ -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) : RoomDetailAction() data class TimelineEventTurnsVisible(val event: TimelineEvent) : RoomDetailAction() data class TimelineEventTurnsInvisible(val event: TimelineEvent) : RoomDetailAction() diff --git a/vector/src/main/java/im/vector/riotx/features/home/room/detail/RoomDetailFragment.kt b/vector/src/main/java/im/vector/riotx/features/home/room/detail/RoomDetailFragment.kt index 6fdbf94590..59437273d6 100644 --- a/vector/src/main/java/im/vector/riotx/features/home/room/detail/RoomDetailFragment.kt +++ b/vector/src/main/java/im/vector/riotx/features/home/room/detail/RoomDetailFragment.kt @@ -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) { diff --git a/vector/src/main/java/im/vector/riotx/features/home/room/detail/RoomDetailViewModel.kt b/vector/src/main/java/im/vector/riotx/features/home/room/detail/RoomDetailViewModel.kt index d2c2c7fdde..a264e0d06c 100644 --- a/vector/src/main/java/im/vector/riotx/features/home/room/detail/RoomDetailViewModel.kt +++ b/vector/src/main/java/im/vector/riotx/features/home/room/detail/RoomDetailViewModel.kt @@ -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() } diff --git a/vector/src/main/java/im/vector/riotx/features/home/room/detail/composer/ComposerEditText.kt b/vector/src/main/java/im/vector/riotx/features/home/room/detail/composer/ComposerEditText.kt index 273aeecbfa..ab37431103 100644 --- a/vector/src/main/java/im/vector/riotx/features/home/room/detail/composer/ComposerEditText.kt +++ b/vector/src/main/java/im/vector/riotx/features/home/room/detail/composer/ComposerEditText.kt @@ -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 + } + } + } + ) + } } diff --git a/vector/src/main/java/im/vector/riotx/features/home/room/detail/composer/TextComposerView.kt b/vector/src/main/java/im/vector/riotx/features/home/room/detail/composer/TextComposerView.kt index 32307dc3d4..593ce1a8f6 100644 --- a/vector/src/main/java/im/vector/riotx/features/home/room/detail/composer/TextComposerView.kt +++ b/vector/src/main/java/im/vector/riotx/features/home/room/detail/composer/TextComposerView.kt @@ -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) } diff --git a/vector/src/main/java/im/vector/riotx/features/html/MxLinkTagHandler.kt b/vector/src/main/java/im/vector/riotx/features/html/MxLinkTagHandler.kt index fdcbb12cd7..ecbf0da415 100644 --- a/vector/src/main/java/im/vector/riotx/features/html/MxLinkTagHandler.kt +++ b/vector/src/main/java/im/vector/riotx/features/html/MxLinkTagHandler.kt @@ -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, diff --git a/vector/src/main/java/im/vector/riotx/features/html/PillImageSpan.kt b/vector/src/main/java/im/vector/riotx/features/html/PillImageSpan.kt index bc954204c0..a192c71961 100644 --- a/vector/src/main/java/im/vector/riotx/features/html/PillImageSpan.kt +++ b/vector/src/main/java/im/vector/riotx/features/html/PillImageSpan.kt @@ -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 ***************************************************************************** diff --git a/vector/src/main/java/im/vector/riotx/features/settings/VectorSettingsGeneralFragment.kt b/vector/src/main/java/im/vector/riotx/features/settings/VectorSettingsGeneralFragment.kt index ff76c61754..ca994db62c 100644 --- a/vector/src/main/java/im/vector/riotx/features/settings/VectorSettingsGeneralFragment.kt +++ b/vector/src/main/java/im/vector/riotx/features/settings/VectorSettingsGeneralFragment.kt @@ -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()