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
}
}