diff --git a/build.gradle b/build.gradle index 9e0b3d1282..d38d430b25 100644 --- a/build.gradle +++ b/build.gradle @@ -148,6 +148,9 @@ allprojects { // To have XML report for Danger reporter(org.jlleitschuh.gradle.ktlint.reporter.ReporterType.CHECKSTYLE) } + filter { + exclude { element -> element.file.path.contains("$buildDir/generated/") } + } disabledRules = [ // TODO Re-enable these 4 rules after reformatting project "indent", diff --git a/changelog.d/7288.feature b/changelog.d/7288.feature new file mode 100644 index 0000000000..be00e26179 --- /dev/null +++ b/changelog.d/7288.feature @@ -0,0 +1 @@ +Add WYSIWYG editor. diff --git a/changelog.d/7288.sdk b/changelog.d/7288.sdk new file mode 100644 index 0000000000..9c4a33ad22 --- /dev/null +++ b/changelog.d/7288.sdk @@ -0,0 +1,10 @@ +Add `formattedText` or similar optional parameters in several methods: + +* RelationService: + * editTextMessage + * editReply + * replyToMessage +* SendService: + * sendQuotedTextMessage + +This allows us to send any HTML formatted text message without needing to rely on automatic Markdown > HTML translation. All these new parameters have a `null` value by default, so previous calls to these API methods remain compatible. diff --git a/dependencies.gradle b/dependencies.gradle index a5cfa18791..59e64ee4dc 100644 --- a/dependencies.gradle +++ b/dependencies.gradle @@ -102,6 +102,7 @@ ext.libs = [ ], element : [ 'opusencoder' : "io.element.android:opusencoder:1.1.0", + 'wysiwyg' : "io.element.android:wysiwyg:0.1.0" ], squareup : [ 'moshi' : "com.squareup.moshi:moshi:$moshi", diff --git a/dependencies_groups.gradle b/dependencies_groups.gradle index 991d54d9af..e614bf1329 100644 --- a/dependencies_groups.gradle +++ b/dependencies_groups.gradle @@ -178,6 +178,7 @@ ext.groups = [ 'org.apache.httpcomponents', 'org.apache.sanselan', 'org.bouncycastle', + 'org.ccil.cowan.tagsoup', 'org.checkerframework', 'org.codehaus', 'org.codehaus.groovy', diff --git a/library/ui-strings/src/main/res/values/strings.xml b/library/ui-strings/src/main/res/values/strings.xml index 63c1f8a8bb..7f9a4f7687 100644 --- a/library/ui-strings/src/main/res/values/strings.xml +++ b/library/ui-strings/src/main/res/values/strings.xml @@ -446,6 +446,9 @@ Enable deferred DMs Create DM only on first message + Enable rich text editor + Use a rich text editor to send formatted messages + Invites Low priority diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/model/relation/RelationService.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/model/relation/RelationService.kt index d34ea3c7d3..e7fcabf386 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/model/relation/RelationService.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/model/relation/RelationService.kt @@ -91,7 +91,8 @@ interface RelationService { * Edit a text message body. Limited to "m.text" contentType. * @param targetEvent The event to edit * @param msgType the message type - * @param newBodyText The edited body + * @param newBodyText The edited body in plain text + * @param newFormattedBodyText The edited body with format * @param newBodyAutoMarkdown true to parse markdown on the new body * @param compatibilityBodyText The text that will appear on clients that don't support yet edition */ @@ -99,6 +100,7 @@ interface RelationService { targetEvent: TimelineEvent, msgType: String, newBodyText: CharSequence, + newFormattedBodyText: CharSequence? = null, newBodyAutoMarkdown: Boolean, compatibilityBodyText: String = "* $newBodyText" ): Cancelable @@ -108,13 +110,15 @@ interface RelationService { * This method will take the new body (stripped from fallbacks) and re-add them before sending. * @param replyToEdit The event to edit * @param originalTimelineEvent the message that this reply (being edited) is relating to - * @param newBodyText The edited body (stripped from in reply to content) + * @param newBodyText The plain text edited body (stripped from in reply to content) + * @param newFormattedBodyText The formatted edited body (stripped from in reply to content) * @param compatibilityBodyText The text that will appear on clients that don't support yet edition */ fun editReply( replyToEdit: TimelineEvent, originalTimelineEvent: TimelineEvent, newBodyText: String, + newFormattedBodyText: String? = null, compatibilityBodyText: String = "* $newBodyText" ): Cancelable @@ -133,6 +137,7 @@ interface RelationService { * by the sdk into pills. * @param eventReplied the event referenced by the reply * @param replyText the reply text + * @param replyFormattedText the reply text, formatted * @param autoMarkdown If true, the SDK will generate a formatted HTML message from the body text if markdown syntax is present * @param showInThread If true, relation will be added to the reply in order to be visible from within threads * @param rootThreadEventId If show in thread is true then we need the rootThreadEventId to generate the relation @@ -140,6 +145,7 @@ interface RelationService { fun replyToMessage( eventReplied: TimelineEvent, replyText: CharSequence, + replyFormattedText: CharSequence? = null, autoMarkdown: Boolean = false, showInThread: Boolean = false, rootThreadEventId: String? = null diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/send/SendService.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/send/SendService.kt index 9cf062356f..de9bcfbf0d 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/send/SendService.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/send/SendService.kt @@ -60,12 +60,19 @@ interface SendService { /** * Method to quote an events content. * @param quotedEvent The event to which we will quote it's content. - * @param text the text message to send + * @param text the plain text message to send + * @param formattedText the formatted text message to send * @param autoMarkdown If true, the SDK will generate a formatted HTML message from the body text if markdown syntax is present * @param rootThreadEventId when this param is not null, the message will be sent in this specific thread * @return a [Cancelable] */ - fun sendQuotedTextMessage(quotedEvent: TimelineEvent, text: String, autoMarkdown: Boolean, rootThreadEventId: String? = null): Cancelable + fun sendQuotedTextMessage( + quotedEvent: TimelineEvent, + text: String, + formattedText: String? = null, + autoMarkdown: Boolean, + rootThreadEventId: String? = null + ): Cancelable /** * Method to send a media asynchronously. diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/timeline/TimelineEvent.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/timeline/TimelineEvent.kt index d391abf1e6..7341fd922e 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/timeline/TimelineEvent.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/timeline/TimelineEvent.kt @@ -33,6 +33,7 @@ import org.matrix.android.sdk.api.session.room.model.ReadReceipt import org.matrix.android.sdk.api.session.room.model.message.MessageBeaconInfoContent import org.matrix.android.sdk.api.session.room.model.message.MessageBeaconLocationDataContent import org.matrix.android.sdk.api.session.room.model.message.MessageContent +import org.matrix.android.sdk.api.session.room.model.message.MessageContentWithFormattedBody import org.matrix.android.sdk.api.session.room.model.message.MessagePollContent import org.matrix.android.sdk.api.session.room.model.message.MessageStickerContent import org.matrix.android.sdk.api.session.room.model.message.MessageTextContent @@ -181,7 +182,8 @@ fun TimelineEvent.isRootThread(): Boolean { * Get the latest message body, after a possible edition, stripping the reply prefix if necessary. */ fun TimelineEvent.getTextEditableContent(): String { - val lastContentBody = getLastMessageContent()?.body ?: return "" + val lastMessageContent = getLastMessageContent() + val lastContentBody = lastMessageContent.getFormattedBody() ?: return "" return if (isReply()) { extractUsefulTextFromReply(lastContentBody) } else { @@ -199,3 +201,11 @@ fun MessageContent.getTextDisplayableContent(): String { ?: (this as MessageTextContent?)?.matrixFormattedBody?.let { ContentUtils.formatSpoilerTextFromHtml(it) } ?: body } + +fun MessageContent?.getFormattedBody(): String? { + return if (this is MessageContentWithFormattedBody) { + formattedBody + } else { + this?.body + } +} diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/relation/DefaultRelationService.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/relation/DefaultRelationService.kt index 9839a44427..ddf3e41dff 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/relation/DefaultRelationService.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/relation/DefaultRelationService.kt @@ -105,19 +105,21 @@ internal class DefaultRelationService @AssistedInject constructor( targetEvent: TimelineEvent, msgType: String, newBodyText: CharSequence, + newFormattedBodyText: CharSequence?, newBodyAutoMarkdown: Boolean, compatibilityBodyText: String ): Cancelable { - return eventEditor.editTextMessage(targetEvent, msgType, newBodyText, newBodyAutoMarkdown, compatibilityBodyText) + return eventEditor.editTextMessage(targetEvent, msgType, newBodyText, newFormattedBodyText, newBodyAutoMarkdown, compatibilityBodyText) } override fun editReply( replyToEdit: TimelineEvent, originalTimelineEvent: TimelineEvent, newBodyText: String, + newFormattedBodyText: String?, compatibilityBodyText: String ): Cancelable { - return eventEditor.editReply(replyToEdit, originalTimelineEvent, newBodyText, compatibilityBodyText) + return eventEditor.editReply(replyToEdit, originalTimelineEvent, newBodyText, newFormattedBodyText, compatibilityBodyText) } override suspend fun fetchEditHistory(eventId: String): List { @@ -127,6 +129,7 @@ internal class DefaultRelationService @AssistedInject constructor( override fun replyToMessage( eventReplied: TimelineEvent, replyText: CharSequence, + replyFormattedText: CharSequence?, autoMarkdown: Boolean, showInThread: Boolean, rootThreadEventId: String? @@ -135,6 +138,7 @@ internal class DefaultRelationService @AssistedInject constructor( roomId = roomId, eventReplied = eventReplied, replyText = replyText, + replyTextFormatted = replyFormattedText, autoMarkdown = autoMarkdown, rootThreadEventId = rootThreadEventId, showInThread = showInThread @@ -178,6 +182,7 @@ internal class DefaultRelationService @AssistedInject constructor( roomId = roomId, eventReplied = eventReplied, replyText = replyInThreadText, + replyTextFormatted = formattedText, autoMarkdown = autoMarkdown, rootThreadEventId = rootThreadEventId, showInThread = false diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/relation/EventEditor.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/relation/EventEditor.kt index 795e9003ce..c83539c8fd 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/relation/EventEditor.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/relation/EventEditor.kt @@ -23,6 +23,7 @@ import org.matrix.android.sdk.api.session.room.send.SendState import org.matrix.android.sdk.api.session.room.timeline.TimelineEvent import org.matrix.android.sdk.api.util.Cancelable import org.matrix.android.sdk.api.util.NoOpCancellable +import org.matrix.android.sdk.api.util.TextContent import org.matrix.android.sdk.internal.database.mapper.toEntity import org.matrix.android.sdk.internal.session.room.send.LocalEchoEventFactory import org.matrix.android.sdk.internal.session.room.send.LocalEchoRepository @@ -42,19 +43,25 @@ internal class EventEditor @Inject constructor( targetEvent: TimelineEvent, msgType: String, newBodyText: CharSequence, + newBodyFormattedText: CharSequence?, newBodyAutoMarkdown: Boolean, compatibilityBodyText: String ): Cancelable { val roomId = targetEvent.roomId if (targetEvent.root.sendState.hasFailed()) { // We create a new in memory event for the EventSenderProcessor but we keep the eventId of the failed event. - val editedEvent = eventFactory.createTextEvent(roomId, msgType, newBodyText, newBodyAutoMarkdown).copy( + val editedEvent = if (newBodyFormattedText != null) { + val content = TextContent(newBodyText.toString(), newBodyFormattedText.toString()) + eventFactory.createFormattedTextEvent(roomId, content, msgType) + } else { + eventFactory.createTextEvent(roomId, msgType, newBodyText, newBodyAutoMarkdown) + }.copy( eventId = targetEvent.eventId ) return sendFailedEvent(targetEvent, editedEvent) } else if (targetEvent.root.sendState.isSent()) { val event = eventFactory - .createReplaceTextEvent(roomId, targetEvent.eventId, newBodyText, newBodyAutoMarkdown, msgType, compatibilityBodyText) + .createReplaceTextEvent(roomId, targetEvent.eventId, newBodyText, newBodyFormattedText, newBodyAutoMarkdown, msgType, compatibilityBodyText) return sendReplaceEvent(event) } else { // Should we throw? @@ -100,6 +107,7 @@ internal class EventEditor @Inject constructor( replyToEdit: TimelineEvent, originalTimelineEvent: TimelineEvent, newBodyText: String, + newBodyFormattedText: String?, compatibilityBodyText: String ): Cancelable { val roomId = replyToEdit.roomId @@ -109,6 +117,7 @@ internal class EventEditor @Inject constructor( roomId = roomId, eventReplied = originalTimelineEvent, replyText = newBodyText, + replyTextFormatted = newBodyFormattedText, autoMarkdown = false, showInThread = false )?.copy( diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/send/DefaultSendService.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/send/DefaultSendService.kt index 418000abed..a3f2825a0c 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/send/DefaultSendService.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/send/DefaultSendService.kt @@ -99,11 +99,18 @@ internal class DefaultSendService @AssistedInject constructor( .let { sendEvent(it) } } - override fun sendQuotedTextMessage(quotedEvent: TimelineEvent, text: String, autoMarkdown: Boolean, rootThreadEventId: String?): Cancelable { + override fun sendQuotedTextMessage( + quotedEvent: TimelineEvent, + text: String, + formattedText: String?, + autoMarkdown: Boolean, + rootThreadEventId: String? + ): Cancelable { return localEchoEventFactory.createQuotedTextEvent( roomId = roomId, quotedEvent = quotedEvent, text = text, + formattedText = formattedText, autoMarkdown = autoMarkdown, rootThreadEventId = rootThreadEventId ) diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/send/LocalEchoEventFactory.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/send/LocalEchoEventFactory.kt index 4fbc91e9ec..4d5e574592 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/send/LocalEchoEventFactory.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/send/LocalEchoEventFactory.kt @@ -124,19 +124,23 @@ internal class LocalEchoEventFactory @Inject constructor( roomId: String, targetEventId: String, newBodyText: CharSequence, + newBodyFormattedText: CharSequence?, newBodyAutoMarkdown: Boolean, msgType: String, compatibilityText: String ): Event { + val content = if (newBodyFormattedText != null) { + TextContent(newBodyText.toString(), newBodyFormattedText.toString()).toMessageTextContent(msgType) + } else { + createTextContent(newBodyText, newBodyAutoMarkdown).toMessageTextContent(msgType) + }.toContent() return createMessageEvent( roomId, MessageTextContent( msgType = msgType, body = compatibilityText, relatesTo = RelationDefaultContent(RelationType.REPLACE, targetEventId), - newContent = createTextContent(newBodyText, newBodyAutoMarkdown) - .toMessageTextContent(msgType) - .toContent() + newContent = content, ) ) } @@ -581,6 +585,7 @@ internal class LocalEchoEventFactory @Inject constructor( roomId: String, eventReplied: TimelineEvent, replyText: CharSequence, + replyTextFormatted: CharSequence?, autoMarkdown: Boolean, rootThreadEventId: String? = null, showInThread: Boolean @@ -594,7 +599,7 @@ internal class LocalEchoEventFactory @Inject constructor( val body = bodyForReply(eventReplied.getLastMessageContent(), eventReplied.isReply()) // As we always supply formatted body for replies we should force the MarkdownParser to produce html. - val replyTextFormatted = markdownParser.parse(replyText, force = true, advanced = autoMarkdown).takeFormatted() + val finalReplyTextFormatted = replyTextFormatted?.toString() ?: markdownParser.parse(replyText, force = true, advanced = autoMarkdown).takeFormatted() // Body of the original message may not have formatted version, so may also have to convert to html. val bodyFormatted = body.formattedText ?: markdownParser.parse(body.text, force = true, advanced = autoMarkdown).takeFormatted() val replyFormatted = buildFormattedReply( @@ -602,7 +607,7 @@ internal class LocalEchoEventFactory @Inject constructor( userLink, userId, bodyFormatted, - replyTextFormatted + finalReplyTextFormatted ) // // > <@alice:example.org> This is the original body @@ -765,18 +770,20 @@ internal class LocalEchoEventFactory @Inject constructor( roomId: String, quotedEvent: TimelineEvent, text: String, + formattedText: String?, autoMarkdown: Boolean, rootThreadEventId: String? ): Event { val messageContent = quotedEvent.getLastMessageContent() - val textMsg = messageContent?.body + val textMsg = if (messageContent is MessageContentWithFormattedBody) { messageContent.formattedBody } else { messageContent?.body } val quoteText = legacyRiotQuoteText(textMsg, text) + val quoteFormattedText = "
$textMsg
$formattedText" return if (rootThreadEventId != null) { createMessageEvent( roomId, markdownParser - .parse(quoteText, force = true, advanced = autoMarkdown) + .parse(quoteText, force = true, advanced = autoMarkdown).copy(formattedText = quoteFormattedText) .toThreadTextContent( rootThreadEventId = rootThreadEventId, latestThreadEventId = localEchoRepository.getLatestThreadEvent(rootThreadEventId), @@ -786,7 +793,7 @@ internal class LocalEchoEventFactory @Inject constructor( } else { createFormattedTextEvent( roomId, - markdownParser.parse(quoteText, force = true, advanced = autoMarkdown), + markdownParser.parse(quoteText, force = true, advanced = autoMarkdown).copy(formattedText = quoteFormattedText), MessageType.MSGTYPE_TEXT ) } diff --git a/vector-config/src/main/res/values/config-settings.xml b/vector-config/src/main/res/values/config-settings.xml index c69452e3d0..11fbb2f147 100755 --- a/vector-config/src/main/res/values/config-settings.xml +++ b/vector-config/src/main/res/values/config-settings.xml @@ -43,6 +43,8 @@ true true false + true + false diff --git a/vector/build.gradle b/vector/build.gradle index 37a98d8242..833d06f6d6 100644 --- a/vector/build.gradle +++ b/vector/build.gradle @@ -104,6 +104,7 @@ android { } } dependencies { + implementation project(":vector-config") api project(":matrix-sdk-android") implementation project(":matrix-sdk-android-flow") @@ -143,6 +144,9 @@ dependencies { // Opus Encoder implementation libs.element.opusencoder + // WYSIWYG Editor + implementation libs.element.wysiwyg + // Log api libs.jakewharton.timber diff --git a/vector/src/main/java/im/vector/app/features/command/CommandParser.kt b/vector/src/main/java/im/vector/app/features/command/CommandParser.kt index 81950fe86c..e08bc9fb64 100644 --- a/vector/src/main/java/im/vector/app/features/command/CommandParser.kt +++ b/vector/src/main/java/im/vector/app/features/command/CommandParser.kt @@ -18,6 +18,7 @@ package im.vector.app.features.command import im.vector.app.core.extensions.isEmail import im.vector.app.core.extensions.isMsisdn +import im.vector.app.core.extensions.orEmpty import im.vector.app.features.home.room.detail.ChatEffect import org.matrix.android.sdk.api.MatrixPatterns import org.matrix.android.sdk.api.MatrixUrls.isMxcUrl @@ -30,39 +31,30 @@ class CommandParser @Inject constructor() { /** * Convert the text message into a Slash command. * - * @param textMessage the text message + * @param textMessage the text message in plain text + * @param formattedMessage the text messaged in HTML format * @param isInThreadTimeline true if the user is currently typing in a thread * @return a parsed slash command (ok or error) */ - fun parseSlashCommand(textMessage: CharSequence, isInThreadTimeline: Boolean): ParsedCommand { + @Suppress("NAME_SHADOWING") + fun parseSlashCommand(textMessage: CharSequence, formattedMessage: String?, isInThreadTimeline: Boolean): ParsedCommand { // check if it has the Slash marker - return if (!textMessage.startsWith("/")) { + val message = formattedMessage ?: textMessage + return if (!message.startsWith("/")) { ParsedCommand.ErrorNotACommand } else { // "/" only - if (textMessage.length == 1) { + if (message.length == 1) { return ParsedCommand.ErrorEmptySlashCommand } // Exclude "//" - if ("/" == textMessage.substring(1, 2)) { + if ("/" == message.substring(1, 2)) { return ParsedCommand.ErrorNotACommand } - val messageParts = try { - textMessage.split("\\s+".toRegex()).dropLastWhile { it.isEmpty() } - } catch (e: Exception) { - Timber.e(e, "## parseSlashCommand() : split failed") - null - } - - // test if the string cut fails - if (messageParts.isNullOrEmpty()) { - return ParsedCommand.ErrorEmptySlashCommand - } - + val (messageParts, message) = extractMessage(message.toString()) ?: return ParsedCommand.ErrorEmptySlashCommand val slashCommand = messageParts.first() - val message = textMessage.substring(slashCommand.length).trim() getNotSupportedByThreads(isInThreadTimeline, slashCommand)?.let { return ParsedCommand.ErrorCommandNotSupportedInThreads(it) @@ -71,7 +63,12 @@ class CommandParser @Inject constructor() { when { Command.PLAIN.matches(slashCommand) -> { if (message.isNotEmpty()) { - ParsedCommand.SendPlainText(message = message) + if (formattedMessage != null) { + val trimmedPlainTextMessage = extractMessage(textMessage.toString())?.second.orEmpty() + ParsedCommand.SendFormattedText(message = trimmedPlainTextMessage, formattedMessage = message) + } else { + ParsedCommand.SendPlainText(message = message) + } } else { ParsedCommand.ErrorSyntax(Command.PLAIN) } @@ -415,6 +412,25 @@ class CommandParser @Inject constructor() { } } + private fun extractMessage(message: String): Pair, String>? { + val messageParts = try { + message.split("\\s+".toRegex()).dropLastWhile { it.isEmpty() } + } catch (e: Exception) { + Timber.e(e, "## parseSlashCommand() : split failed") + null + } + + // test if the string cut fails + if (messageParts.isNullOrEmpty()) { + return null + } + + val slashCommand = messageParts.first() + val trimmedMessage = message.substring(slashCommand.length).trim() + + return messageParts to trimmedMessage + } + private val notSupportedThreadsCommands: List by lazy { Command.values().filter { !it.isThreadCommand diff --git a/vector/src/main/java/im/vector/app/features/command/ParsedCommand.kt b/vector/src/main/java/im/vector/app/features/command/ParsedCommand.kt index eee786253b..670eddefda 100644 --- a/vector/src/main/java/im/vector/app/features/command/ParsedCommand.kt +++ b/vector/src/main/java/im/vector/app/features/command/ParsedCommand.kt @@ -39,6 +39,7 @@ sealed interface ParsedCommand { // Valid commands: data class SendPlainText(val message: CharSequence) : ParsedCommand + data class SendFormattedText(val message: CharSequence, val formattedMessage: String) : ParsedCommand data class SendEmote(val message: CharSequence) : ParsedCommand data class SendRainbow(val message: CharSequence) : ParsedCommand data class SendRainbowEmote(val message: CharSequence) : ParsedCommand diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/composer/MessageComposerAction.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/composer/MessageComposerAction.kt index 97e6657fc2..82adcd014a 100644 --- a/vector/src/main/java/im/vector/app/features/home/room/detail/composer/MessageComposerAction.kt +++ b/vector/src/main/java/im/vector/app/features/home/room/detail/composer/MessageComposerAction.kt @@ -23,7 +23,7 @@ import org.matrix.android.sdk.api.session.content.ContentAttachmentData import org.matrix.android.sdk.api.session.room.model.message.MessageAudioContent sealed class MessageComposerAction : VectorViewModelAction { - data class SendMessage(val text: CharSequence, val autoMarkdown: Boolean) : MessageComposerAction() + data class SendMessage(val text: CharSequence, val formattedText: String?, val autoMarkdown: Boolean) : MessageComposerAction() data class EnterEditMode(val eventId: String) : MessageComposerAction() data class EnterQuoteMode(val eventId: String) : MessageComposerAction() data class EnterReplyMode(val eventId: String) : MessageComposerAction() diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/composer/MessageComposerFragment.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/composer/MessageComposerFragment.kt index 4573dc25c1..b3abfa480e 100644 --- a/vector/src/main/java/im/vector/app/features/home/room/detail/composer/MessageComposerFragment.kt +++ b/vector/src/main/java/im/vector/app/features/home/room/detail/composer/MessageComposerFragment.kt @@ -37,6 +37,7 @@ import androidx.annotation.RequiresApi import androidx.annotation.StringRes import androidx.core.content.ContextCompat import androidx.core.text.buildSpannedString +import androidx.core.view.isGone import androidx.core.view.isInvisible import androidx.core.view.isVisible import androidx.lifecycle.Lifecycle @@ -161,6 +162,14 @@ class MessageComposerFragment : VectorBaseFragment(), A private val messageComposerViewModel: MessageComposerViewModel by parentFragmentViewModel() private lateinit var sharedActionViewModel: MessageSharedActionViewModel + private val composer: MessageComposerView get() { + return if (vectorPreferences.isRichTextEditorEnabled()) { + views.richTextComposerLayout + } else { + views.composerLayout + } + } + override fun getBinding(inflater: LayoutInflater, container: ViewGroup?): FragmentComposerBinding { return FragmentComposerBinding.inflate(inflater, container, false) } @@ -175,6 +184,9 @@ class MessageComposerFragment : VectorBaseFragment(), A setupComposer() setupEmojiButton() + views.composerLayout.isGone = vectorPreferences.isRichTextEditorEnabled() + views.richTextComposerLayout.isVisible = vectorPreferences.isRichTextEditorEnabled() + messageComposerViewModel.observeViewEvents { when (it) { is MessageComposerViewEvents.JoinRoomCommandSuccess -> handleJoinedToAnotherRoom(it) @@ -218,29 +230,33 @@ class MessageComposerFragment : VectorBaseFragment(), A if (withState(messageComposerViewModel) { it.isVoiceRecording } && requireActivity().isChangingConfigurations) { // we're rotating, maintain any active recordings } else { - messageComposerViewModel.handle(MessageComposerAction.OnEntersBackground(views.composerLayout.text.toString())) + messageComposerViewModel.handle(MessageComposerAction.OnEntersBackground(composer.text.toString())) } } override fun onDestroyView() { super.onDestroyView() - autoCompleter.clear() + if (!vectorPreferences.isRichTextEditorEnabled()) { + autoCompleter.clear() + } messageComposerViewModel.endAllVoiceActions() } override fun invalidate() = withState(timelineViewModel, messageComposerViewModel) { mainState, messageComposerState -> if (mainState.tombstoneEvent != null) return@withState - views.root.isInvisible = !messageComposerState.isComposerVisible - views.composerLayout.views.sendButton.isInvisible = !messageComposerState.isSendButtonVisible + composer.setInvisible(!messageComposerState.isComposerVisible) + composer.sendButton.isInvisible = !messageComposerState.isSendButtonVisible } private fun setupComposer() { - val composerEditText = views.composerLayout.views.composerEditText + val composerEditText = composer.editText composerEditText.setHint(R.string.room_message_placeholder) - autoCompleter.setup(composerEditText) + if (!vectorPreferences.isRichTextEditorEnabled()) { + autoCompleter.setup(composerEditText) + } observerUserTyping() @@ -257,20 +273,22 @@ class MessageComposerFragment : VectorBaseFragment(), A !keyEvent.isShiftPressed && keyEvent.keyCode == KeyEvent.KEYCODE_ENTER && resources.configuration.keyboard != Configuration.KEYBOARD_NOKEYS - if (isSendAction || externalKeyboardPressedEnter) { + val result = if (isSendAction || externalKeyboardPressedEnter) { sendTextMessage(v.text) true } else false + composer.setTextIfDifferent(null) + result } - views.composerLayout.views.composerEmojiButton.isVisible = vectorPreferences.showEmojiKeyboard() + composer.emojiButton?.isVisible = vectorPreferences.showEmojiKeyboard() val showKeyboard = withState(timelineViewModel) { it.showKeyboardWhenPresented } if (isThreadTimeLine() && showKeyboard) { // Show keyboard when the user started a thread - views.composerLayout.views.composerEditText.showKeyboard(andRequestFocus = true) + composerEditText.showKeyboard(andRequestFocus = true) } - views.composerLayout.callback = object : MessageComposerView.Callback { + composer.callback = object : PlainTextComposerLayout.Callback { override fun onAddAttachment() { if (!::attachmentTypeSelector.isInitialized) { attachmentTypeSelector = AttachmentTypeSelectorView(vectorBaseActivity, vectorBaseActivity.layoutInflater, this@MessageComposerFragment) @@ -286,15 +304,15 @@ class MessageComposerFragment : VectorBaseFragment(), A vectorFeatures.isVoiceBroadcastEnabled(), // TODO check user permission ) } - attachmentTypeSelector.show(views.composerLayout.views.attachmentButton) + attachmentTypeSelector.show(composer.attachmentButton) } override fun onExpandOrCompactChange() { - views.composerLayout.views.composerEmojiButton.isVisible = isEmojiKeyboardVisible + composer.emojiButton?.isVisible = isEmojiKeyboardVisible } override fun onSendMessage(text: CharSequence) { - sendTextMessage(text) + sendTextMessage(text, composer.formattedText) } override fun onCloseRelatedMessage() { @@ -311,16 +329,20 @@ class MessageComposerFragment : VectorBaseFragment(), A } } - private fun sendTextMessage(text: CharSequence) { + private fun sendTextMessage(text: CharSequence, formattedText: String? = null) { if (lockSendButton) { Timber.w("Send button is locked") return } if (text.isNotBlank()) { // We collapse ASAP, if not there will be a slight annoying delay - views.composerLayout.collapse(true) + composer.collapse(true) lockSendButton = true - messageComposerViewModel.handle(MessageComposerAction.SendMessage(text, vectorPreferences.isMarkdownEnabled())) + if (formattedText != null) { + messageComposerViewModel.handle(MessageComposerAction.SendMessage(text, formattedText, false)) + } else { + messageComposerViewModel.handle(MessageComposerAction.SendMessage(text, null, vectorPreferences.isMarkdownEnabled())) + } emojiPopup.dismiss() } } @@ -336,22 +358,22 @@ class MessageComposerFragment : VectorBaseFragment(), A return isHandled } - private fun renderRegularMode(content: String) { + private fun renderRegularMode(content: CharSequence) { autoCompleter.exitSpecialMode() - views.composerLayout.collapse() - views.composerLayout.setTextIfDifferent(content) - views.composerLayout.views.sendButton.contentDescription = getString(R.string.action_send) + composer.collapse() + composer.setTextIfDifferent(content) + composer.sendButton.contentDescription = getString(R.string.action_send) } private fun renderSpecialMode( event: TimelineEvent, @DrawableRes iconRes: Int, @StringRes descriptionRes: Int, - defaultContent: String + defaultContent: CharSequence, ) { autoCompleter.enterSpecialMode() // switch to expanded bar - views.composerLayout.views.composerRelatedMessageTitle.apply { + composer.composerRelatedMessageTitle.apply { text = event.senderInfo.disambiguatedDisplayName setTextColor(matrixItemColorProvider.getColor(MatrixItem.UserItem(event.root.senderId ?: "@"))) } @@ -369,32 +391,32 @@ class MessageComposerFragment : VectorBaseFragment(), A val document = parser.parse(messageContent.formattedBody ?: messageContent.body) formattedBody = eventHtmlRenderer.render(document, pillsPostProcessor) } - views.composerLayout.views.composerRelatedMessageContent.text = (formattedBody ?: nonFormattedBody) + composer.composerRelatedMessageContent.text = (formattedBody ?: nonFormattedBody) // Image Event val data = event.buildImageContentRendererData(dimensionConverter.dpToPx(66)) val isImageVisible = if (data != null) { - imageContentRenderer.render(data, ImageContentRenderer.Mode.THUMBNAIL, views.composerLayout.views.composerRelatedMessageImage) + imageContentRenderer.render(data, ImageContentRenderer.Mode.THUMBNAIL, composer.composerRelatedMessageImage) true } else { - imageContentRenderer.clear(views.composerLayout.views.composerRelatedMessageImage) + imageContentRenderer.clear(composer.composerRelatedMessageImage) false } - views.composerLayout.views.composerRelatedMessageImage.isVisible = isImageVisible + composer.composerRelatedMessageImage.isVisible = isImageVisible - views.composerLayout.setTextIfDifferent(defaultContent) + composer.replaceFormattedContent(defaultContent) - views.composerLayout.views.composerRelatedMessageActionIcon.setImageDrawable(ContextCompat.getDrawable(requireContext(), iconRes)) - views.composerLayout.views.sendButton.contentDescription = getString(descriptionRes) + composer.composerRelatedMessageActionIcon.setImageDrawable(ContextCompat.getDrawable(requireContext(), iconRes)) + composer.sendButton.contentDescription = getString(descriptionRes) - avatarRenderer.render(event.senderInfo.toMatrixItem(), views.composerLayout.views.composerRelatedMessageAvatar) + avatarRenderer.render(event.senderInfo.toMatrixItem(), composer.composerRelatedMessageAvatar) - views.composerLayout.expand { + composer.expand { if (isAdded) { // need to do it here also when not using quick reply focusComposerAndShowKeyboard() - views.composerLayout.views.composerRelatedMessageImage.isVisible = isImageVisible + composer.composerRelatedMessageImage.isVisible = isImageVisible } } focusComposerAndShowKeyboard() @@ -402,7 +424,7 @@ class MessageComposerFragment : VectorBaseFragment(), A private fun observerUserTyping() { if (isThreadTimeLine()) return - views.composerLayout.views.composerEditText.textChanges() + composer.editText.textChanges() .skipInitialValue() .debounce(300) .map { it.isNotEmpty() } @@ -412,7 +434,7 @@ class MessageComposerFragment : VectorBaseFragment(), A } .launchIn(viewLifecycleOwner.lifecycleScope) - views.composerLayout.views.composerEditText.focusChanges() + composer.editText.focusChanges() .onEach { timelineViewModel.handle(RoomDetailAction.ComposerFocusChange(it)) } @@ -420,18 +442,18 @@ class MessageComposerFragment : VectorBaseFragment(), A } private fun focusComposerAndShowKeyboard() { - if (views.composerLayout.isVisible) { - views.composerLayout.views.composerEditText.showKeyboard(andRequestFocus = true) + if (composer.isVisible) { + composer.editText.showKeyboard(andRequestFocus = true) } } private fun handleSendButtonVisibilityChanged(event: MessageComposerViewEvents.AnimateSendButtonVisibility) { if (event.isVisible) { - views.root.views.sendButton.alpha = 0f - views.root.views.sendButton.isVisible = true - views.root.views.sendButton.animate().alpha(1f).setDuration(150).start() + composer.sendButton.alpha = 0f + composer.sendButton.isVisible = true + composer.sendButton.animate().alpha(1f).setDuration(150).start() } else { - views.root.views.sendButton.isInvisible = true + composer.sendButton.isInvisible = true } } @@ -455,18 +477,18 @@ class MessageComposerFragment : VectorBaseFragment(), A rootView = views.root, keyboardAnimationStyle = R.style.emoji_fade_animation_style, onEmojiPopupShownListener = { - views.composerLayout.views.composerEmojiButton.apply { + composer.emojiButton?.apply { contentDescription = getString(R.string.a11y_close_emoji_picker) setImageResource(R.drawable.ic_keyboard) } }, onEmojiPopupDismissListener = lifecycleAwareDismissAction { - views.composerLayout.views.composerEmojiButton.apply { + composer.emojiButton?.apply { contentDescription = getString(R.string.a11y_open_emoji_picker) setImageResource(R.drawable.ic_insert_emoji) } }, - editText = views.composerLayout.views.composerEditText + editText = composer.editText ) } @@ -483,7 +505,7 @@ class MessageComposerFragment : VectorBaseFragment(), A } private fun setupEmojiButton() { - views.composerLayout.views.composerEmojiButton.debouncedClicks { + composer.emojiButton?.debouncedClicks { emojiPopup.toggle() } } @@ -494,7 +516,7 @@ class MessageComposerFragment : VectorBaseFragment(), A } private fun handleJoinedToAnotherRoom(action: MessageComposerViewEvents.JoinRoomCommandSuccess) { - views.composerLayout.setTextIfDifferent("") + composer.setTextIfDifferent("") lockSendButton = false navigator.openRoom(vectorBaseActivity, action.roomId) } @@ -549,7 +571,7 @@ class MessageComposerFragment : VectorBaseFragment(), A private fun handleSlashCommandResultOk(parsedCommand: ParsedCommand) { dismissLoadingDialog() - views.composerLayout.setTextIfDifferent("") + composer.setTextIfDifferent("") when (parsedCommand) { is ParsedCommand.DevTools -> { navigator.openDevTools(requireContext(), roomId) @@ -608,7 +630,7 @@ class MessageComposerFragment : VectorBaseFragment(), A override fun onContactAttachmentReady(contactAttachment: ContactAttachment) { val formattedContact = contactAttachment.toHumanReadable() - messageComposerViewModel.handle(MessageComposerAction.SendMessage(formattedContact, false)) + messageComposerViewModel.handle(MessageComposerAction.SendMessage(formattedContact, null, false)) } override fun onAttachmentError(throwable: Throwable) { @@ -718,13 +740,13 @@ class MessageComposerFragment : VectorBaseFragment(), A @SuppressLint("SetTextI18n") private fun insertUserDisplayNameInTextEditor(userId: String) { - val startToCompose = views.composerLayout.text.isNullOrBlank() + val startToCompose = composer.text.isNullOrBlank() if (startToCompose && userId == session.myUserId) { // Empty composer, current user: start an emote - views.composerLayout.views.composerEditText.setText("${Command.EMOTE.command} ") - views.composerLayout.views.composerEditText.setSelection(Command.EMOTE.command.length + 1) + composer.editText.setText("${Command.EMOTE.command} ") + composer.editText.setSelection(Command.EMOTE.command.length + 1) } else { val roomMember = timelineViewModel.getMember(userId) val displayName = sanitizeDisplayName(roomMember?.displayName ?: userId) @@ -737,7 +759,7 @@ class MessageComposerFragment : VectorBaseFragment(), A requireContext(), MatrixItem.UserItem(userId, displayName, roomMember?.avatarUrl) ) - .also { it.bind(views.composerLayout.views.composerEditText) }, + .also { it.bind(composer.editText) }, 0, displayName.length, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE @@ -747,11 +769,11 @@ class MessageComposerFragment : VectorBaseFragment(), A if (startToCompose) { if (displayName.startsWith("/")) { // Ensure displayName will not be interpreted as a Slash command - views.composerLayout.views.composerEditText.append("\\") + composer.editText.append("\\") } - views.composerLayout.views.composerEditText.append(pill) + composer.editText.append(pill) } else { - views.composerLayout.views.composerEditText.text?.insert(views.composerLayout.views.composerEditText.selectionStart, pill) + composer.editText.text?.insert(composer.editText.selectionStart, pill) } } focusComposerAndShowKeyboard() diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/composer/MessageComposerView.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/composer/MessageComposerView.kt index 1935c9460b..09357191b4 100644 --- a/vector/src/main/java/im/vector/app/features/home/room/detail/composer/MessageComposerView.kt +++ b/vector/src/main/java/im/vector/app/features/home/room/detail/composer/MessageComposerView.kt @@ -1,11 +1,11 @@ /* - * Copyright 2019 New Vector Ltd + * Copyright (c) 2022 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 + * 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, @@ -16,137 +16,34 @@ package im.vector.app.features.home.room.detail.composer -import android.content.Context -import android.net.Uri import android.text.Editable -import android.util.AttributeSet -import android.view.ViewGroup -import androidx.constraintlayout.widget.ConstraintLayout -import androidx.constraintlayout.widget.ConstraintSet -import androidx.core.text.toSpannable -import androidx.transition.ChangeBounds -import androidx.transition.Fade -import androidx.transition.Transition -import androidx.transition.TransitionManager -import androidx.transition.TransitionSet -import im.vector.app.R -import im.vector.app.core.extensions.setTextIfDifferent -import im.vector.app.databinding.ComposerLayoutBinding +import android.widget.EditText +import android.widget.ImageButton +import android.widget.ImageView +import android.widget.TextView -/** - * Encapsulate the timeline composer UX. - */ -class MessageComposerView @JvmOverloads constructor( - context: Context, - attrs: AttributeSet? = null, - defStyleAttr: Int = 0 -) : ConstraintLayout(context, attrs, defStyleAttr) { - - interface Callback : ComposerEditText.Callback { - fun onCloseRelatedMessage() - fun onSendMessage(text: CharSequence) - fun onAddAttachment() - fun onExpandOrCompactChange() - } - - val views: ComposerLayoutBinding - - var callback: Callback? = null - - private var currentConstraintSetId: Int = -1 - - private val animationDuration = 100L +interface MessageComposerView { val text: Editable? - get() = views.composerEditText.text + val formattedText: String? + val editText: EditText + val emojiButton: ImageButton? + val sendButton: ImageButton + val attachmentButton: ImageButton + val composerRelatedMessageTitle: TextView + val composerRelatedMessageContent: TextView + val composerRelatedMessageImage: ImageView + val composerRelatedMessageActionIcon: ImageView + val composerRelatedMessageAvatar: ImageView - init { - inflate(context, R.layout.composer_layout, this) - views = ComposerLayoutBinding.bind(this) + var callback: PlainTextComposerLayout.Callback? - collapse(false) + var isVisible: Boolean - views.composerEditText.callback = object : ComposerEditText.Callback { - override fun onRichContentSelected(contentUri: Uri): Boolean { - return callback?.onRichContentSelected(contentUri) ?: false - } + fun collapse(animate: Boolean = true, transitionComplete: (() -> Unit)? = null) + fun expand(animate: Boolean = true, transitionComplete: (() -> Unit)? = null) + fun setTextIfDifferent(text: CharSequence?): Boolean + fun replaceFormattedContent(text: CharSequence) - override fun onTextChanged(text: CharSequence) { - callback?.onTextChanged(text) - } - } - views.composerRelatedMessageCloseButton.setOnClickListener { - collapse() - callback?.onCloseRelatedMessage() - } - - views.sendButton.setOnClickListener { - val textMessage = text?.toSpannable() ?: "" - callback?.onSendMessage(textMessage) - } - - views.attachmentButton.setOnClickListener { - callback?.onAddAttachment() - } - } - - fun collapse(animate: Boolean = true, transitionComplete: (() -> Unit)? = null) { - if (currentConstraintSetId == R.layout.composer_layout_constraint_set_compact) { - // ignore we good - return - } - currentConstraintSetId = R.layout.composer_layout_constraint_set_compact - applyNewConstraintSet(animate, transitionComplete) - callback?.onExpandOrCompactChange() - } - - fun expand(animate: Boolean = true, transitionComplete: (() -> Unit)? = null) { - if (currentConstraintSetId == R.layout.composer_layout_constraint_set_expanded) { - // ignore we good - return - } - currentConstraintSetId = R.layout.composer_layout_constraint_set_expanded - applyNewConstraintSet(animate, transitionComplete) - callback?.onExpandOrCompactChange() - } - - fun setTextIfDifferent(text: CharSequence?): Boolean { - return views.composerEditText.setTextIfDifferent(text) - } - - private fun applyNewConstraintSet(animate: Boolean, transitionComplete: (() -> Unit)?) { - // val wasSendButtonInvisible = views.sendButton.isInvisible - if (animate) { - configureAndBeginTransition(transitionComplete) - } - ConstraintSet().also { - it.clone(context, currentConstraintSetId) - it.applyTo(this) - } - // Might be updated by view state just after, but avoid blinks - // views.sendButton.isInvisible = wasSendButtonInvisible - } - - private fun configureAndBeginTransition(transitionComplete: (() -> Unit)? = null) { - val transition = TransitionSet().apply { - ordering = TransitionSet.ORDERING_SEQUENTIAL - addTransition(ChangeBounds()) - addTransition(Fade(Fade.IN)) - duration = animationDuration - addListener(object : Transition.TransitionListener { - override fun onTransitionEnd(transition: Transition) { - transitionComplete?.invoke() - } - - override fun onTransitionResume(transition: Transition) {} - - override fun onTransitionPause(transition: Transition) {} - - override fun onTransitionCancel(transition: Transition) {} - - override fun onTransitionStart(transition: Transition) {} - }) - } - TransitionManager.beginDelayedTransition((parent as? ViewGroup ?: this), transition) - } + fun setInvisible(isInvisible: Boolean) } diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/composer/MessageComposerViewModel.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/composer/MessageComposerViewModel.kt index c83f818ac8..b877c2979b 100644 --- a/vector/src/main/java/im/vector/app/features/home/room/detail/composer/MessageComposerViewModel.kt +++ b/vector/src/main/java/im/vector/app/features/home/room/detail/composer/MessageComposerViewModel.kt @@ -59,6 +59,7 @@ import org.matrix.android.sdk.api.session.room.model.PowerLevelsContent import org.matrix.android.sdk.api.session.room.model.RoomAvatarContent import org.matrix.android.sdk.api.session.room.model.RoomEncryptionAlgorithm import org.matrix.android.sdk.api.session.room.model.RoomMemberContent +import org.matrix.android.sdk.api.session.room.model.message.MessageContentWithFormattedBody import org.matrix.android.sdk.api.session.room.model.message.MessageType import org.matrix.android.sdk.api.session.room.model.relation.shouldRenderInThread import org.matrix.android.sdk.api.session.room.powerlevels.PowerLevelsHelper @@ -201,6 +202,7 @@ class MessageComposerViewModel @AssistedInject constructor( is SendMode.Regular -> { when (val parsedCommand = commandParser.parseSlashCommand( textMessage = action.text, + formattedMessage = action.formattedText, isInThreadTimeline = state.isInThreadTimeline() )) { is ParsedCommand.ErrorNotACommand -> { @@ -209,10 +211,15 @@ class MessageComposerViewModel @AssistedInject constructor( room.relationService().replyInThread( rootThreadEventId = state.rootThreadEventId, replyInThreadText = action.text, + formattedText = action.formattedText, autoMarkdown = action.autoMarkdown ) } else { - room.sendService().sendTextMessage(action.text, autoMarkdown = action.autoMarkdown) + if (action.formattedText != null) { + room.sendService().sendFormattedTextMessage(action.text.toString(), action.formattedText) + } else { + room.sendService().sendTextMessage(action.text, autoMarkdown = action.autoMarkdown) + } } _viewEvents.post(MessageComposerViewEvents.MessageSent) @@ -244,6 +251,24 @@ class MessageComposerViewModel @AssistedInject constructor( _viewEvents.post(MessageComposerViewEvents.MessageSent) popDraft() } + is ParsedCommand.SendFormattedText -> { + // Send the text message to the room, without markdown + if (state.rootThreadEventId != null) { + room.relationService().replyInThread( + rootThreadEventId = state.rootThreadEventId, + replyInThreadText = parsedCommand.message, + formattedText = parsedCommand.formattedMessage, + autoMarkdown = false + ) + } else { + room.sendService().sendFormattedTextMessage( + text = parsedCommand.message.toString(), + formattedText = parsedCommand.formattedMessage + ) + } + _viewEvents.post(MessageComposerViewEvents.MessageSent) + popDraft() + } is ParsedCommand.ChangeRoomName -> { handleChangeRoomNameSlashCommand(parsedCommand) } @@ -510,16 +535,24 @@ class MessageComposerViewModel @AssistedInject constructor( if (inReplyTo != null) { // TODO check if same content? room.getTimelineEvent(inReplyTo)?.let { - room.relationService().editReply(state.sendMode.timelineEvent, it, action.text.toString()) + room.relationService().editReply(state.sendMode.timelineEvent, it, action.text.toString(), action.formattedText) } } else { val messageContent = state.sendMode.timelineEvent.getVectorLastMessageContent() - val existingBody = messageContent?.body ?: "" - if (existingBody != action.text) { + val existingBody: String + val needsEdit = if (messageContent is MessageContentWithFormattedBody) { + existingBody = messageContent.formattedBody ?: "" + existingBody != action.formattedText + } else { + existingBody = messageContent?.body ?: "" + existingBody != action.text + } + if (needsEdit) { room.relationService().editTextMessage( state.sendMode.timelineEvent, messageContent?.msgType ?: MessageType.MSGTYPE_TEXT, action.text, + (messageContent as? MessageContentWithFormattedBody)?.formattedBody, action.autoMarkdown ) } else { @@ -533,6 +566,7 @@ class MessageComposerViewModel @AssistedInject constructor( room.sendService().sendQuotedTextMessage( quotedEvent = state.sendMode.timelineEvent, text = action.text.toString(), + formattedText = action.formattedText, autoMarkdown = action.autoMarkdown, rootThreadEventId = state.rootThreadEventId ) @@ -549,11 +583,13 @@ class MessageComposerViewModel @AssistedInject constructor( rootThreadEventId = it, replyInThreadText = action.text.toString(), autoMarkdown = action.autoMarkdown, + formattedText = action.formattedText, eventReplied = timelineEvent ) } ?: room.relationService().replyToMessage( eventReplied = timelineEvent, replyText = action.text.toString(), + replyFormattedText = action.formattedText, autoMarkdown = action.autoMarkdown, showInThread = showInThread, rootThreadEventId = rootThreadEventId diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/composer/PlainTextComposerLayout.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/composer/PlainTextComposerLayout.kt new file mode 100644 index 0000000000..acb5a1b42a --- /dev/null +++ b/vector/src/main/java/im/vector/app/features/home/room/detail/composer/PlainTextComposerLayout.kt @@ -0,0 +1,185 @@ +/* + * 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.app.features.home.room.detail.composer + +import android.content.Context +import android.net.Uri +import android.text.Editable +import android.util.AttributeSet +import android.view.ViewGroup +import android.widget.EditText +import android.widget.ImageButton +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.core.view.isInvisible +import androidx.core.view.isVisible +import androidx.transition.ChangeBounds +import androidx.transition.Fade +import androidx.transition.Transition +import androidx.transition.TransitionManager +import androidx.transition.TransitionSet +import im.vector.app.R +import im.vector.app.core.animations.SimpleTransitionListener +import im.vector.app.core.extensions.setTextIfDifferent +import im.vector.app.databinding.ComposerLayoutBinding + +/** + * Encapsulate the timeline composer UX. + */ +class PlainTextComposerLayout @JvmOverloads constructor( + context: Context, + attrs: AttributeSet? = null, + defStyleAttr: Int = 0 +) : ConstraintLayout(context, attrs, defStyleAttr), MessageComposerView { + + interface Callback : ComposerEditText.Callback { + fun onCloseRelatedMessage() + fun onSendMessage(text: CharSequence) + fun onAddAttachment() + fun onExpandOrCompactChange() + } + + private val views: ComposerLayoutBinding + + override var callback: Callback? = null + + private var currentConstraintSetId: Int = -1 + + private val animationDuration = 100L + + override val text: Editable? + get() = views.composerEditText.text + + override val formattedText: String? = null + + override val editText: EditText + get() = views.composerEditText + + override val emojiButton: ImageButton? + get() = views.composerEmojiButton + + override val sendButton: ImageButton + get() = views.sendButton + + override fun setInvisible(isInvisible: Boolean) { + this.isInvisible = isInvisible + } + override val attachmentButton: ImageButton + get() = views.attachmentButton + override val composerRelatedMessageActionIcon: ImageView + get() = views.composerRelatedMessageActionIcon + override val composerRelatedMessageAvatar: ImageView + get() = views.composerRelatedMessageAvatar + override val composerRelatedMessageContent: TextView + get() = views.composerRelatedMessageContent + override val composerRelatedMessageImage: ImageView + get() = views.composerRelatedMessageImage + override val composerRelatedMessageTitle: TextView + get() = views.composerRelatedMessageTitle + override var isVisible: Boolean + get() = views.root.isVisible + set(value) { views.root.isVisible = value } + + init { + inflate(context, R.layout.composer_layout, this) + views = ComposerLayoutBinding.bind(this) + + collapse(false) + + views.composerEditText.callback = object : ComposerEditText.Callback { + override fun onRichContentSelected(contentUri: Uri): Boolean { + return callback?.onRichContentSelected(contentUri) ?: false + } + + override fun onTextChanged(text: CharSequence) { + callback?.onTextChanged(text) + } + } + views.composerRelatedMessageCloseButton.setOnClickListener { + collapse() + callback?.onCloseRelatedMessage() + } + + views.sendButton.setOnClickListener { + val textMessage = text?.toSpannable() ?: "" + callback?.onSendMessage(textMessage) + } + + views.attachmentButton.setOnClickListener { + callback?.onAddAttachment() + } + } + + override fun replaceFormattedContent(text: CharSequence) { + setTextIfDifferent(text) + } + + override fun collapse(animate: Boolean, transitionComplete: (() -> Unit)?) { + if (currentConstraintSetId == R.layout.composer_layout_constraint_set_compact) { + // ignore we good + return + } + currentConstraintSetId = R.layout.composer_layout_constraint_set_compact + applyNewConstraintSet(animate, transitionComplete) + callback?.onExpandOrCompactChange() + } + + override fun expand(animate: Boolean, transitionComplete: (() -> Unit)?) { + if (currentConstraintSetId == R.layout.composer_layout_constraint_set_expanded) { + // ignore we good + return + } + currentConstraintSetId = R.layout.composer_layout_constraint_set_expanded + applyNewConstraintSet(animate, transitionComplete) + callback?.onExpandOrCompactChange() + } + + override fun setTextIfDifferent(text: CharSequence?): Boolean { + return views.composerEditText.setTextIfDifferent(text) + } + + private fun applyNewConstraintSet(animate: Boolean, transitionComplete: (() -> Unit)?) { + // val wasSendButtonInvisible = views.sendButton.isInvisible + if (animate) { + configureAndBeginTransition(transitionComplete) + } + ConstraintSet().also { + it.clone(context, currentConstraintSetId) + it.applyTo(this) + } + // Might be updated by view state just after, but avoid blinks + // views.sendButton.isInvisible = wasSendButtonInvisible + } + + private fun configureAndBeginTransition(transitionComplete: (() -> Unit)? = null) { + val transition = TransitionSet().apply { + ordering = TransitionSet.ORDERING_SEQUENTIAL + addTransition(ChangeBounds()) + addTransition(Fade(Fade.IN)) + duration = animationDuration + addListener(object : SimpleTransitionListener() { + override fun onTransitionEnd(transition: Transition) { + transitionComplete?.invoke() + } + }) + } + TransitionManager.beginDelayedTransition((parent as? ViewGroup ?: this), transition) + } +} diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/composer/RichTextComposerLayout.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/composer/RichTextComposerLayout.kt new file mode 100644 index 0000000000..76bdcfc9a8 --- /dev/null +++ b/vector/src/main/java/im/vector/app/features/home/room/detail/composer/RichTextComposerLayout.kt @@ -0,0 +1,202 @@ +/* + * Copyright (c) 2022 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.app.features.home.room.detail.composer + +import android.content.Context +import android.text.Editable +import android.text.TextWatcher +import android.util.AttributeSet +import android.view.LayoutInflater +import android.view.ViewGroup +import android.widget.EditText +import android.widget.ImageButton +import android.widget.ImageView +import android.widget.TextView +import androidx.annotation.DrawableRes +import androidx.constraintlayout.widget.ConstraintLayout +import androidx.constraintlayout.widget.ConstraintSet +import androidx.core.text.toSpannable +import androidx.core.view.isInvisible +import androidx.core.view.isVisible +import androidx.transition.ChangeBounds +import androidx.transition.Fade +import androidx.transition.Transition +import androidx.transition.TransitionManager +import androidx.transition.TransitionSet +import im.vector.app.R +import im.vector.app.core.animations.SimpleTransitionListener +import im.vector.app.core.extensions.setTextIfDifferent +import im.vector.app.databinding.ComposerRichTextLayoutBinding +import im.vector.app.databinding.ViewRichTextMenuButtonBinding +import io.element.android.wysiwyg.InlineFormat + +class RichTextComposerLayout @JvmOverloads constructor( + context: Context, + attrs: AttributeSet? = null, + defStyleAttr: Int = 0 +) : ConstraintLayout(context, attrs, defStyleAttr), MessageComposerView { + + private val views: ComposerRichTextLayoutBinding + + override var callback: PlainTextComposerLayout.Callback? = null + + private var currentConstraintSetId: Int = -1 + + private val animationDuration = 100L + + override val text: Editable? + get() = views.composerEditText.text + override val formattedText: String? + get() = views.composerEditText.getHtmlOutput() + override val editText: EditText + get() = views.composerEditText + override val emojiButton: ImageButton? + get() = null + override val sendButton: ImageButton + get() = views.sendButton + override val attachmentButton: ImageButton + get() = views.attachmentButton + override val composerRelatedMessageActionIcon: ImageView + get() = views.composerRelatedMessageActionIcon + override val composerRelatedMessageAvatar: ImageView + get() = views.composerRelatedMessageAvatar + override val composerRelatedMessageContent: TextView + get() = views.composerRelatedMessageContent + override val composerRelatedMessageImage: ImageView + get() = views.composerRelatedMessageImage + override val composerRelatedMessageTitle: TextView + get() = views.composerRelatedMessageTitle + override var isVisible: Boolean + get() = views.root.isVisible + set(value) { views.root.isVisible = value } + + init { + inflate(context, R.layout.composer_rich_text_layout, this) + views = ComposerRichTextLayoutBinding.bind(this) + + collapse(false) + + views.composerEditText.addTextChangedListener(object : TextWatcher { + override fun beforeTextChanged(s: CharSequence?, start: Int, count: Int, after: Int) {} + override fun onTextChanged(s: CharSequence?, start: Int, before: Int, count: Int) {} + override fun afterTextChanged(s: Editable) { + callback?.onTextChanged(s) + } + }) + + views.composerRelatedMessageCloseButton.setOnClickListener { + collapse() + callback?.onCloseRelatedMessage() + } + + views.sendButton.setOnClickListener { + val textMessage = text?.toSpannable() ?: "" + callback?.onSendMessage(textMessage) + } + + views.attachmentButton.setOnClickListener { + callback?.onAddAttachment() + } + + setupRichTextMenu() + } + + private fun setupRichTextMenu() { + addRichTextMenuItem(R.drawable.ic_composer_bold, "Bold") { + views.composerEditText.toggleInlineFormat(InlineFormat.Bold) + } + addRichTextMenuItem(R.drawable.ic_composer_italic, "Italic") { + views.composerEditText.toggleInlineFormat(InlineFormat.Italic) + } + addRichTextMenuItem(R.drawable.ic_composer_underlined, "Underline") { + views.composerEditText.toggleInlineFormat(InlineFormat.Underline) + } + addRichTextMenuItem(R.drawable.ic_composer_strikethrough, "Strikethrough") { + views.composerEditText.toggleInlineFormat(InlineFormat.StrikeThrough) + } + } + + private fun addRichTextMenuItem(@DrawableRes iconId: Int, description: String, action: () -> Unit) { + val inflater = LayoutInflater.from(context) + val button = ViewRichTextMenuButtonBinding.inflate(inflater, views.richTextMenu, true) + with(button.root) { + contentDescription = description + setImageResource(iconId) + setOnClickListener { + action() + } + } + } + + override fun replaceFormattedContent(text: CharSequence) { + views.composerEditText.setHtml(text.toString()) + } + + override fun collapse(animate: Boolean, transitionComplete: (() -> Unit)?) { + if (currentConstraintSetId == R.layout.composer_rich_text_layout_constraint_set_compact) { + // ignore we good + return + } + currentConstraintSetId = R.layout.composer_rich_text_layout_constraint_set_compact + applyNewConstraintSet(animate, transitionComplete) + } + + override fun expand(animate: Boolean, transitionComplete: (() -> Unit)?) { + if (currentConstraintSetId == R.layout.composer_rich_text_layout_constraint_set_expanded) { + // ignore we good + return + } + currentConstraintSetId = R.layout.composer_rich_text_layout_constraint_set_expanded + applyNewConstraintSet(animate, transitionComplete) + } + + override fun setTextIfDifferent(text: CharSequence?): Boolean { + return views.composerEditText.setTextIfDifferent(text) + } + + private fun applyNewConstraintSet(animate: Boolean, transitionComplete: (() -> Unit)?) { + // val wasSendButtonInvisible = views.sendButton.isInvisible + if (animate) { + configureAndBeginTransition(transitionComplete) + } + ConstraintSet().also { + it.clone(context, currentConstraintSetId) + it.applyTo(this) + } + // Might be updated by view state just after, but avoid blinks + // views.sendButton.isInvisible = wasSendButtonInvisible + } + + private fun configureAndBeginTransition(transitionComplete: (() -> Unit)? = null) { + val transition = TransitionSet().apply { + ordering = TransitionSet.ORDERING_SEQUENTIAL + addTransition(ChangeBounds()) + addTransition(Fade(Fade.IN)) + duration = animationDuration + addListener(object : SimpleTransitionListener() { + override fun onTransitionEnd(transition: Transition) { + transitionComplete?.invoke() + } + }) + } + TransitionManager.beginDelayedTransition((parent as? ViewGroup ?: this), transition) + } + + override fun setInvisible(isInvisible: Boolean) { + this.isInvisible = isInvisible + } +} diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/composer/voice/VoiceRecorderFragment.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/composer/voice/VoiceRecorderFragment.kt index 4a4f025688..25764f3654 100644 --- a/vector/src/main/java/im/vector/app/features/home/room/detail/composer/voice/VoiceRecorderFragment.kt +++ b/vector/src/main/java/im/vector/app/features/home/room/detail/composer/voice/VoiceRecorderFragment.kt @@ -37,6 +37,9 @@ import im.vector.app.features.home.room.detail.TimelineViewModel import im.vector.app.features.home.room.detail.composer.MessageComposerAction import im.vector.app.features.home.room.detail.composer.MessageComposerViewEvents import im.vector.app.features.home.room.detail.composer.MessageComposerViewModel +import im.vector.app.features.home.room.detail.composer.MessageComposerViewState +import im.vector.app.features.home.room.detail.composer.SendMode +import im.vector.app.features.home.room.detail.composer.boolean import im.vector.app.features.home.room.detail.timeline.helper.AudioMessagePlaybackTracker import javax.inject.Inject @@ -70,6 +73,15 @@ class VoiceRecorderFragment : VectorBaseFragment() else -> Unit } } + + messageComposerViewModel.onEach(MessageComposerViewState::sendMode, MessageComposerViewState::canSendMessage) { mode, canSend -> + if (!canSend.boolean()) { + return@onEach + } + if (mode is SendMode.Voice) { + views.voiceMessageRecorderView.isVisible = true + } + } } override fun onResume() { diff --git a/vector/src/main/java/im/vector/app/features/settings/VectorPreferences.kt b/vector/src/main/java/im/vector/app/features/settings/VectorPreferences.kt index b7812b9ebb..1cbb8509df 100755 --- a/vector/src/main/java/im/vector/app/features/settings/VectorPreferences.kt +++ b/vector/src/main/java/im/vector/app/features/settings/VectorPreferences.kt @@ -71,6 +71,7 @@ class VectorPreferences @Inject constructor( const val SETTINGS_LABS_PREFERENCE_KEY = "SETTINGS_LABS_PREFERENCE_KEY" const val SETTINGS_LABS_NEW_APP_LAYOUT_KEY = "SETTINGS_LABS_NEW_APP_LAYOUT_KEY" const val SETTINGS_LABS_DEFERRED_DM_KEY = "SETTINGS_LABS_DEFERRED_DM_KEY" + const val SETTINGS_LABS_RICH_TEXT_EDITOR_KEY = "SETTINGS_LABS_RICH_TEXT_EDITOR_KEY" const val SETTINGS_CRYPTOGRAPHY_PREFERENCE_KEY = "SETTINGS_CRYPTOGRAPHY_PREFERENCE_KEY" const val SETTINGS_CRYPTOGRAPHY_DIVIDER_PREFERENCE_KEY = "SETTINGS_CRYPTOGRAPHY_DIVIDER_PREFERENCE_KEY" const val SETTINGS_CRYPTOGRAPHY_MANAGE_PREFERENCE_KEY = "SETTINGS_CRYPTOGRAPHY_MANAGE_PREFERENCE_KEY" @@ -1182,4 +1183,8 @@ class VectorPreferences @Inject constructor( fun showLiveSenderInfo(): Boolean { return defaultPrefs.getBoolean(SETTINGS_TIMELINE_SHOW_LIVE_SENDER_INFO, getDefault(R.bool.settings_timeline_show_live_sender_info_default)) } + + fun isRichTextEditorEnabled(): Boolean { + return defaultPrefs.getBoolean(SETTINGS_LABS_RICH_TEXT_EDITOR_KEY, getDefault(R.bool.settings_labs_rich_text_editor_default)) + } } diff --git a/vector/src/main/res/drawable/ic_composer_bold.xml b/vector/src/main/res/drawable/ic_composer_bold.xml new file mode 100644 index 0000000000..3d9a10d16b --- /dev/null +++ b/vector/src/main/res/drawable/ic_composer_bold.xml @@ -0,0 +1,10 @@ + + + diff --git a/vector/src/main/res/drawable/ic_composer_italic.xml b/vector/src/main/res/drawable/ic_composer_italic.xml new file mode 100644 index 0000000000..faa4f89cd4 --- /dev/null +++ b/vector/src/main/res/drawable/ic_composer_italic.xml @@ -0,0 +1,10 @@ + + + diff --git a/vector/src/main/res/drawable/ic_composer_strikethrough.xml b/vector/src/main/res/drawable/ic_composer_strikethrough.xml new file mode 100644 index 0000000000..3970c95381 --- /dev/null +++ b/vector/src/main/res/drawable/ic_composer_strikethrough.xml @@ -0,0 +1,12 @@ + + + + diff --git a/vector/src/main/res/drawable/ic_composer_underlined.xml b/vector/src/main/res/drawable/ic_composer_underlined.xml new file mode 100644 index 0000000000..fe18d60185 --- /dev/null +++ b/vector/src/main/res/drawable/ic_composer_underlined.xml @@ -0,0 +1,13 @@ + + + + + + diff --git a/vector/src/main/res/layout/composer_rich_text_layout.xml b/vector/src/main/res/layout/composer_rich_text_layout.xml new file mode 100644 index 0000000000..3130061c10 --- /dev/null +++ b/vector/src/main/res/layout/composer_rich_text_layout.xml @@ -0,0 +1,156 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/vector/src/main/res/layout/composer_rich_text_layout_constraint_set_compact.xml b/vector/src/main/res/layout/composer_rich_text_layout_constraint_set_compact.xml new file mode 100644 index 0000000000..585ba2913e --- /dev/null +++ b/vector/src/main/res/layout/composer_rich_text_layout_constraint_set_compact.xml @@ -0,0 +1,200 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/vector/src/main/res/layout/composer_rich_text_layout_constraint_set_expanded.xml b/vector/src/main/res/layout/composer_rich_text_layout_constraint_set_expanded.xml new file mode 100644 index 0000000000..f810b12ed1 --- /dev/null +++ b/vector/src/main/res/layout/composer_rich_text_layout_constraint_set_expanded.xml @@ -0,0 +1,198 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/vector/src/main/res/layout/fragment_composer.xml b/vector/src/main/res/layout/fragment_composer.xml index 0f79500da9..8703af7471 100644 --- a/vector/src/main/res/layout/fragment_composer.xml +++ b/vector/src/main/res/layout/fragment_composer.xml @@ -1,13 +1,29 @@ - + android:layout_height="wrap_content"> + + + + + + diff --git a/vector/src/main/res/layout/view_rich_text_menu_button.xml b/vector/src/main/res/layout/view_rich_text_menu_button.xml new file mode 100644 index 0000000000..a63a01e7c2 --- /dev/null +++ b/vector/src/main/res/layout/view_rich_text_menu_button.xml @@ -0,0 +1,10 @@ + + + + + diff --git a/vector/src/main/res/xml/vector_settings_labs.xml b/vector/src/main/res/xml/vector_settings_labs.xml index 9fac6d722a..a3420c5865 100644 --- a/vector/src/main/res/xml/vector_settings_labs.xml +++ b/vector/src/main/res/xml/vector_settings_labs.xml @@ -96,4 +96,11 @@ android:title="@string/labs_enable_deferred_dm_title" app:isPreferenceVisible="@bool/settings_labs_deferred_dm_visible" /> + + diff --git a/vector/src/test/java/im/vector/app/features/command/CommandParserTest.kt b/vector/src/test/java/im/vector/app/features/command/CommandParserTest.kt index c257377849..f502db85ca 100644 --- a/vector/src/test/java/im/vector/app/features/command/CommandParserTest.kt +++ b/vector/src/test/java/im/vector/app/features/command/CommandParserTest.kt @@ -71,7 +71,7 @@ class CommandParserTest { private fun test(message: String, expectedResult: ParsedCommand) { val commandParser = CommandParser() - val result = commandParser.parseSlashCommand(message, false) + val result = commandParser.parseSlashCommand(message, null, false) result shouldBeEqualTo expectedResult } }