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, | ||||
| 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( | ||||
|  | ||||
| @ -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 | ||||
|                 } | ||||
|         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) { | ||||
|  | ||||
| @ -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…
	
	
			
			x
			
			
		
	
		Reference in New Issue
	
	Block a user