Integrate WYSIWYG editor (#7288)

* Add WYSIWYG lib dependency

* Replace EditText with RichTextEditor

* Add bold button, fix sending formatting messages issues

* Add missing inline formatting buttons, make scrollview horizontal

* Disable autocomplete for rich text editor

* Add formatted text to messages sent, replies, quotes and edited messages.

* Several fixes

* Add changelog

* Try to fix lint issues

* Address review comments.

* Exclude Epoxy KSP generated files from ktlint checks
This commit is contained in:
Jorge Martin Espinosa 2022-10-11 17:05:47 +02:00 committed by GitHub
parent 2fe636e93b
commit def67b2e7d
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
36 changed files with 1316 additions and 232 deletions

View File

@ -148,6 +148,9 @@ allprojects {
// To have XML report for Danger // To have XML report for Danger
reporter(org.jlleitschuh.gradle.ktlint.reporter.ReporterType.CHECKSTYLE) reporter(org.jlleitschuh.gradle.ktlint.reporter.ReporterType.CHECKSTYLE)
} }
filter {
exclude { element -> element.file.path.contains("$buildDir/generated/") }
}
disabledRules = [ disabledRules = [
// TODO Re-enable these 4 rules after reformatting project // TODO Re-enable these 4 rules after reformatting project
"indent", "indent",

1
changelog.d/7288.feature Normal file
View File

@ -0,0 +1 @@
Add WYSIWYG editor.

10
changelog.d/7288.sdk Normal file
View File

@ -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.

View File

@ -102,6 +102,7 @@ ext.libs = [
], ],
element : [ element : [
'opusencoder' : "io.element.android:opusencoder:1.1.0", 'opusencoder' : "io.element.android:opusencoder:1.1.0",
'wysiwyg' : "io.element.android:wysiwyg:0.1.0"
], ],
squareup : [ squareup : [
'moshi' : "com.squareup.moshi:moshi:$moshi", 'moshi' : "com.squareup.moshi:moshi:$moshi",

View File

@ -178,6 +178,7 @@ ext.groups = [
'org.apache.httpcomponents', 'org.apache.httpcomponents',
'org.apache.sanselan', 'org.apache.sanselan',
'org.bouncycastle', 'org.bouncycastle',
'org.ccil.cowan.tagsoup',
'org.checkerframework', 'org.checkerframework',
'org.codehaus', 'org.codehaus',
'org.codehaus.groovy', 'org.codehaus.groovy',

View File

@ -446,6 +446,9 @@
<string name="labs_enable_deferred_dm_title">Enable deferred DMs</string> <string name="labs_enable_deferred_dm_title">Enable deferred DMs</string>
<string name="labs_enable_deferred_dm_summary">Create DM only on first message</string> <string name="labs_enable_deferred_dm_summary">Create DM only on first message</string>
<string name="labs_enable_rich_text_editor_title">Enable rich text editor</string>
<string name="labs_enable_rich_text_editor_summary">Use a rich text editor to send formatted messages</string>
<!-- Home fragment --> <!-- Home fragment -->
<string name="invitations_header">Invites</string> <string name="invitations_header">Invites</string>
<string name="low_priority_header">Low priority</string> <string name="low_priority_header">Low priority</string>

View File

@ -91,7 +91,8 @@ interface RelationService {
* Edit a text message body. Limited to "m.text" contentType. * Edit a text message body. Limited to "m.text" contentType.
* @param targetEvent The event to edit * @param targetEvent The event to edit
* @param msgType the message type * @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 newBodyAutoMarkdown true to parse markdown on the new body
* @param compatibilityBodyText The text that will appear on clients that don't support yet edition * @param compatibilityBodyText The text that will appear on clients that don't support yet edition
*/ */
@ -99,6 +100,7 @@ interface RelationService {
targetEvent: TimelineEvent, targetEvent: TimelineEvent,
msgType: String, msgType: String,
newBodyText: CharSequence, newBodyText: CharSequence,
newFormattedBodyText: CharSequence? = null,
newBodyAutoMarkdown: Boolean, newBodyAutoMarkdown: Boolean,
compatibilityBodyText: String = "* $newBodyText" compatibilityBodyText: String = "* $newBodyText"
): Cancelable ): Cancelable
@ -108,13 +110,15 @@ interface RelationService {
* This method will take the new body (stripped from fallbacks) and re-add them before sending. * This method will take the new body (stripped from fallbacks) and re-add them before sending.
* @param replyToEdit The event to edit * @param replyToEdit The event to edit
* @param originalTimelineEvent the message that this reply (being edited) is relating to * @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 * @param compatibilityBodyText The text that will appear on clients that don't support yet edition
*/ */
fun editReply( fun editReply(
replyToEdit: TimelineEvent, replyToEdit: TimelineEvent,
originalTimelineEvent: TimelineEvent, originalTimelineEvent: TimelineEvent,
newBodyText: String, newBodyText: String,
newFormattedBodyText: String? = null,
compatibilityBodyText: String = "* $newBodyText" compatibilityBodyText: String = "* $newBodyText"
): Cancelable ): Cancelable
@ -133,6 +137,7 @@ interface RelationService {
* by the sdk into pills. * by the sdk into pills.
* @param eventReplied the event referenced by the reply * @param eventReplied the event referenced by the reply
* @param replyText the reply text * @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 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 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 * @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( fun replyToMessage(
eventReplied: TimelineEvent, eventReplied: TimelineEvent,
replyText: CharSequence, replyText: CharSequence,
replyFormattedText: CharSequence? = null,
autoMarkdown: Boolean = false, autoMarkdown: Boolean = false,
showInThread: Boolean = false, showInThread: Boolean = false,
rootThreadEventId: String? = null rootThreadEventId: String? = null

View File

@ -60,12 +60,19 @@ interface SendService {
/** /**
* Method to quote an events content. * Method to quote an events content.
* @param quotedEvent The event to which we will quote it's 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 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 * @param rootThreadEventId when this param is not null, the message will be sent in this specific thread
* @return a [Cancelable] * @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. * Method to send a media asynchronously.

View File

@ -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.MessageBeaconInfoContent
import org.matrix.android.sdk.api.session.room.model.message.MessageBeaconLocationDataContent 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.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.MessagePollContent
import org.matrix.android.sdk.api.session.room.model.message.MessageStickerContent import org.matrix.android.sdk.api.session.room.model.message.MessageStickerContent
import org.matrix.android.sdk.api.session.room.model.message.MessageTextContent 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. * Get the latest message body, after a possible edition, stripping the reply prefix if necessary.
*/ */
fun TimelineEvent.getTextEditableContent(): String { fun TimelineEvent.getTextEditableContent(): String {
val lastContentBody = getLastMessageContent()?.body ?: return "" val lastMessageContent = getLastMessageContent()
val lastContentBody = lastMessageContent.getFormattedBody() ?: return ""
return if (isReply()) { return if (isReply()) {
extractUsefulTextFromReply(lastContentBody) extractUsefulTextFromReply(lastContentBody)
} else { } else {
@ -199,3 +201,11 @@ fun MessageContent.getTextDisplayableContent(): String {
?: (this as MessageTextContent?)?.matrixFormattedBody?.let { ContentUtils.formatSpoilerTextFromHtml(it) } ?: (this as MessageTextContent?)?.matrixFormattedBody?.let { ContentUtils.formatSpoilerTextFromHtml(it) }
?: body ?: body
} }
fun MessageContent?.getFormattedBody(): String? {
return if (this is MessageContentWithFormattedBody) {
formattedBody
} else {
this?.body
}
}

View File

@ -105,19 +105,21 @@ internal class DefaultRelationService @AssistedInject constructor(
targetEvent: TimelineEvent, targetEvent: TimelineEvent,
msgType: String, msgType: String,
newBodyText: CharSequence, newBodyText: CharSequence,
newFormattedBodyText: CharSequence?,
newBodyAutoMarkdown: Boolean, newBodyAutoMarkdown: Boolean,
compatibilityBodyText: String compatibilityBodyText: String
): Cancelable { ): Cancelable {
return eventEditor.editTextMessage(targetEvent, msgType, newBodyText, newBodyAutoMarkdown, compatibilityBodyText) return eventEditor.editTextMessage(targetEvent, msgType, newBodyText, newFormattedBodyText, newBodyAutoMarkdown, compatibilityBodyText)
} }
override fun editReply( override fun editReply(
replyToEdit: TimelineEvent, replyToEdit: TimelineEvent,
originalTimelineEvent: TimelineEvent, originalTimelineEvent: TimelineEvent,
newBodyText: String, newBodyText: String,
newFormattedBodyText: String?,
compatibilityBodyText: String compatibilityBodyText: String
): Cancelable { ): Cancelable {
return eventEditor.editReply(replyToEdit, originalTimelineEvent, newBodyText, compatibilityBodyText) return eventEditor.editReply(replyToEdit, originalTimelineEvent, newBodyText, newFormattedBodyText, compatibilityBodyText)
} }
override suspend fun fetchEditHistory(eventId: String): List<Event> { override suspend fun fetchEditHistory(eventId: String): List<Event> {
@ -127,6 +129,7 @@ internal class DefaultRelationService @AssistedInject constructor(
override fun replyToMessage( override fun replyToMessage(
eventReplied: TimelineEvent, eventReplied: TimelineEvent,
replyText: CharSequence, replyText: CharSequence,
replyFormattedText: CharSequence?,
autoMarkdown: Boolean, autoMarkdown: Boolean,
showInThread: Boolean, showInThread: Boolean,
rootThreadEventId: String? rootThreadEventId: String?
@ -135,6 +138,7 @@ internal class DefaultRelationService @AssistedInject constructor(
roomId = roomId, roomId = roomId,
eventReplied = eventReplied, eventReplied = eventReplied,
replyText = replyText, replyText = replyText,
replyTextFormatted = replyFormattedText,
autoMarkdown = autoMarkdown, autoMarkdown = autoMarkdown,
rootThreadEventId = rootThreadEventId, rootThreadEventId = rootThreadEventId,
showInThread = showInThread showInThread = showInThread
@ -178,6 +182,7 @@ internal class DefaultRelationService @AssistedInject constructor(
roomId = roomId, roomId = roomId,
eventReplied = eventReplied, eventReplied = eventReplied,
replyText = replyInThreadText, replyText = replyInThreadText,
replyTextFormatted = formattedText,
autoMarkdown = autoMarkdown, autoMarkdown = autoMarkdown,
rootThreadEventId = rootThreadEventId, rootThreadEventId = rootThreadEventId,
showInThread = false showInThread = false

View File

@ -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.session.room.timeline.TimelineEvent
import org.matrix.android.sdk.api.util.Cancelable import org.matrix.android.sdk.api.util.Cancelable
import org.matrix.android.sdk.api.util.NoOpCancellable 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.database.mapper.toEntity
import org.matrix.android.sdk.internal.session.room.send.LocalEchoEventFactory import org.matrix.android.sdk.internal.session.room.send.LocalEchoEventFactory
import org.matrix.android.sdk.internal.session.room.send.LocalEchoRepository import org.matrix.android.sdk.internal.session.room.send.LocalEchoRepository
@ -42,19 +43,25 @@ internal class EventEditor @Inject constructor(
targetEvent: TimelineEvent, targetEvent: TimelineEvent,
msgType: String, msgType: String,
newBodyText: CharSequence, newBodyText: CharSequence,
newBodyFormattedText: CharSequence?,
newBodyAutoMarkdown: Boolean, newBodyAutoMarkdown: Boolean,
compatibilityBodyText: String compatibilityBodyText: String
): Cancelable { ): Cancelable {
val roomId = targetEvent.roomId val roomId = targetEvent.roomId
if (targetEvent.root.sendState.hasFailed()) { if (targetEvent.root.sendState.hasFailed()) {
// We create a new in memory event for the EventSenderProcessor but we keep the eventId of the failed event. // 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 eventId = targetEvent.eventId
) )
return sendFailedEvent(targetEvent, editedEvent) return sendFailedEvent(targetEvent, editedEvent)
} else if (targetEvent.root.sendState.isSent()) { } else if (targetEvent.root.sendState.isSent()) {
val event = eventFactory val event = eventFactory
.createReplaceTextEvent(roomId, targetEvent.eventId, newBodyText, newBodyAutoMarkdown, msgType, compatibilityBodyText) .createReplaceTextEvent(roomId, targetEvent.eventId, newBodyText, newBodyFormattedText, newBodyAutoMarkdown, msgType, compatibilityBodyText)
return sendReplaceEvent(event) return sendReplaceEvent(event)
} else { } else {
// Should we throw? // Should we throw?
@ -100,6 +107,7 @@ internal class EventEditor @Inject constructor(
replyToEdit: TimelineEvent, replyToEdit: TimelineEvent,
originalTimelineEvent: TimelineEvent, originalTimelineEvent: TimelineEvent,
newBodyText: String, newBodyText: String,
newBodyFormattedText: String?,
compatibilityBodyText: String compatibilityBodyText: String
): Cancelable { ): Cancelable {
val roomId = replyToEdit.roomId val roomId = replyToEdit.roomId
@ -109,6 +117,7 @@ internal class EventEditor @Inject constructor(
roomId = roomId, roomId = roomId,
eventReplied = originalTimelineEvent, eventReplied = originalTimelineEvent,
replyText = newBodyText, replyText = newBodyText,
replyTextFormatted = newBodyFormattedText,
autoMarkdown = false, autoMarkdown = false,
showInThread = false showInThread = false
)?.copy( )?.copy(

View File

@ -99,11 +99,18 @@ internal class DefaultSendService @AssistedInject constructor(
.let { sendEvent(it) } .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( return localEchoEventFactory.createQuotedTextEvent(
roomId = roomId, roomId = roomId,
quotedEvent = quotedEvent, quotedEvent = quotedEvent,
text = text, text = text,
formattedText = formattedText,
autoMarkdown = autoMarkdown, autoMarkdown = autoMarkdown,
rootThreadEventId = rootThreadEventId rootThreadEventId = rootThreadEventId
) )

View File

@ -124,19 +124,23 @@ internal class LocalEchoEventFactory @Inject constructor(
roomId: String, roomId: String,
targetEventId: String, targetEventId: String,
newBodyText: CharSequence, newBodyText: CharSequence,
newBodyFormattedText: CharSequence?,
newBodyAutoMarkdown: Boolean, newBodyAutoMarkdown: Boolean,
msgType: String, msgType: String,
compatibilityText: String compatibilityText: String
): Event { ): Event {
val content = if (newBodyFormattedText != null) {
TextContent(newBodyText.toString(), newBodyFormattedText.toString()).toMessageTextContent(msgType)
} else {
createTextContent(newBodyText, newBodyAutoMarkdown).toMessageTextContent(msgType)
}.toContent()
return createMessageEvent( return createMessageEvent(
roomId, roomId,
MessageTextContent( MessageTextContent(
msgType = msgType, msgType = msgType,
body = compatibilityText, body = compatibilityText,
relatesTo = RelationDefaultContent(RelationType.REPLACE, targetEventId), relatesTo = RelationDefaultContent(RelationType.REPLACE, targetEventId),
newContent = createTextContent(newBodyText, newBodyAutoMarkdown) newContent = content,
.toMessageTextContent(msgType)
.toContent()
) )
) )
} }
@ -581,6 +585,7 @@ internal class LocalEchoEventFactory @Inject constructor(
roomId: String, roomId: String,
eventReplied: TimelineEvent, eventReplied: TimelineEvent,
replyText: CharSequence, replyText: CharSequence,
replyTextFormatted: CharSequence?,
autoMarkdown: Boolean, autoMarkdown: Boolean,
rootThreadEventId: String? = null, rootThreadEventId: String? = null,
showInThread: Boolean showInThread: Boolean
@ -594,7 +599,7 @@ internal class LocalEchoEventFactory @Inject constructor(
val body = bodyForReply(eventReplied.getLastMessageContent(), eventReplied.isReply()) val body = bodyForReply(eventReplied.getLastMessageContent(), eventReplied.isReply())
// As we always supply formatted body for replies we should force the MarkdownParser to produce html. // 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. // 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 bodyFormatted = body.formattedText ?: markdownParser.parse(body.text, force = true, advanced = autoMarkdown).takeFormatted()
val replyFormatted = buildFormattedReply( val replyFormatted = buildFormattedReply(
@ -602,7 +607,7 @@ internal class LocalEchoEventFactory @Inject constructor(
userLink, userLink,
userId, userId,
bodyFormatted, bodyFormatted,
replyTextFormatted finalReplyTextFormatted
) )
// //
// > <@alice:example.org> This is the original body // > <@alice:example.org> This is the original body
@ -765,18 +770,20 @@ internal class LocalEchoEventFactory @Inject constructor(
roomId: String, roomId: String,
quotedEvent: TimelineEvent, quotedEvent: TimelineEvent,
text: String, text: String,
formattedText: String?,
autoMarkdown: Boolean, autoMarkdown: Boolean,
rootThreadEventId: String? rootThreadEventId: String?
): Event { ): Event {
val messageContent = quotedEvent.getLastMessageContent() 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 quoteText = legacyRiotQuoteText(textMsg, text)
val quoteFormattedText = "<blockquote>$textMsg</blockquote>$formattedText"
return if (rootThreadEventId != null) { return if (rootThreadEventId != null) {
createMessageEvent( createMessageEvent(
roomId, roomId,
markdownParser markdownParser
.parse(quoteText, force = true, advanced = autoMarkdown) .parse(quoteText, force = true, advanced = autoMarkdown).copy(formattedText = quoteFormattedText)
.toThreadTextContent( .toThreadTextContent(
rootThreadEventId = rootThreadEventId, rootThreadEventId = rootThreadEventId,
latestThreadEventId = localEchoRepository.getLatestThreadEvent(rootThreadEventId), latestThreadEventId = localEchoRepository.getLatestThreadEvent(rootThreadEventId),
@ -786,7 +793,7 @@ internal class LocalEchoEventFactory @Inject constructor(
} else { } else {
createFormattedTextEvent( createFormattedTextEvent(
roomId, roomId,
markdownParser.parse(quoteText, force = true, advanced = autoMarkdown), markdownParser.parse(quoteText, force = true, advanced = autoMarkdown).copy(formattedText = quoteFormattedText),
MessageType.MSGTYPE_TEXT MessageType.MSGTYPE_TEXT
) )
} }

View File

@ -43,6 +43,8 @@
<bool name="settings_labs_new_app_layout_default">true</bool> <bool name="settings_labs_new_app_layout_default">true</bool>
<bool name="settings_timeline_show_live_sender_info_visible">true</bool> <bool name="settings_timeline_show_live_sender_info_visible">true</bool>
<bool name="settings_timeline_show_live_sender_info_default">false</bool> <bool name="settings_timeline_show_live_sender_info_default">false</bool>
<bool name="settings_labs_rich_text_editor_visible">true</bool>
<bool name="settings_labs_rich_text_editor_default">false</bool>
<!-- Level 1: Advanced settings --> <!-- Level 1: Advanced settings -->
<!-- Level 1: Help and about --> <!-- Level 1: Help and about -->

View File

@ -104,6 +104,7 @@ android {
} }
} }
dependencies { dependencies {
implementation project(":vector-config") implementation project(":vector-config")
api project(":matrix-sdk-android") api project(":matrix-sdk-android")
implementation project(":matrix-sdk-android-flow") implementation project(":matrix-sdk-android-flow")
@ -143,6 +144,9 @@ dependencies {
// Opus Encoder // Opus Encoder
implementation libs.element.opusencoder implementation libs.element.opusencoder
// WYSIWYG Editor
implementation libs.element.wysiwyg
// Log // Log
api libs.jakewharton.timber api libs.jakewharton.timber

View File

@ -18,6 +18,7 @@ package im.vector.app.features.command
import im.vector.app.core.extensions.isEmail import im.vector.app.core.extensions.isEmail
import im.vector.app.core.extensions.isMsisdn import im.vector.app.core.extensions.isMsisdn
import im.vector.app.core.extensions.orEmpty
import im.vector.app.features.home.room.detail.ChatEffect import im.vector.app.features.home.room.detail.ChatEffect
import org.matrix.android.sdk.api.MatrixPatterns import org.matrix.android.sdk.api.MatrixPatterns
import org.matrix.android.sdk.api.MatrixUrls.isMxcUrl import org.matrix.android.sdk.api.MatrixUrls.isMxcUrl
@ -30,39 +31,30 @@ class CommandParser @Inject constructor() {
/** /**
* Convert the text message into a Slash command. * 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 * @param isInThreadTimeline true if the user is currently typing in a thread
* @return a parsed slash command (ok or error) * @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 // check if it has the Slash marker
return if (!textMessage.startsWith("/")) { val message = formattedMessage ?: textMessage
return if (!message.startsWith("/")) {
ParsedCommand.ErrorNotACommand ParsedCommand.ErrorNotACommand
} else { } else {
// "/" only // "/" only
if (textMessage.length == 1) { if (message.length == 1) {
return ParsedCommand.ErrorEmptySlashCommand return ParsedCommand.ErrorEmptySlashCommand
} }
// Exclude "//" // Exclude "//"
if ("/" == textMessage.substring(1, 2)) { if ("/" == message.substring(1, 2)) {
return ParsedCommand.ErrorNotACommand return ParsedCommand.ErrorNotACommand
} }
val messageParts = try { val (messageParts, message) = extractMessage(message.toString()) ?: return ParsedCommand.ErrorEmptySlashCommand
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 slashCommand = messageParts.first() val slashCommand = messageParts.first()
val message = textMessage.substring(slashCommand.length).trim()
getNotSupportedByThreads(isInThreadTimeline, slashCommand)?.let { getNotSupportedByThreads(isInThreadTimeline, slashCommand)?.let {
return ParsedCommand.ErrorCommandNotSupportedInThreads(it) return ParsedCommand.ErrorCommandNotSupportedInThreads(it)
@ -71,7 +63,12 @@ class CommandParser @Inject constructor() {
when { when {
Command.PLAIN.matches(slashCommand) -> { Command.PLAIN.matches(slashCommand) -> {
if (message.isNotEmpty()) { if (message.isNotEmpty()) {
if (formattedMessage != null) {
val trimmedPlainTextMessage = extractMessage(textMessage.toString())?.second.orEmpty()
ParsedCommand.SendFormattedText(message = trimmedPlainTextMessage, formattedMessage = message)
} else {
ParsedCommand.SendPlainText(message = message) ParsedCommand.SendPlainText(message = message)
}
} else { } else {
ParsedCommand.ErrorSyntax(Command.PLAIN) ParsedCommand.ErrorSyntax(Command.PLAIN)
} }
@ -415,6 +412,25 @@ class CommandParser @Inject constructor() {
} }
} }
private fun extractMessage(message: String): Pair<List<String>, 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<Command> by lazy { private val notSupportedThreadsCommands: List<Command> by lazy {
Command.values().filter { Command.values().filter {
!it.isThreadCommand !it.isThreadCommand

View File

@ -39,6 +39,7 @@ sealed interface ParsedCommand {
// Valid commands: // Valid commands:
data class SendPlainText(val message: CharSequence) : ParsedCommand 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 SendEmote(val message: CharSequence) : ParsedCommand
data class SendRainbow(val message: CharSequence) : ParsedCommand data class SendRainbow(val message: CharSequence) : ParsedCommand
data class SendRainbowEmote(val message: CharSequence) : ParsedCommand data class SendRainbowEmote(val message: CharSequence) : ParsedCommand

View File

@ -23,7 +23,7 @@ import org.matrix.android.sdk.api.session.content.ContentAttachmentData
import org.matrix.android.sdk.api.session.room.model.message.MessageAudioContent import org.matrix.android.sdk.api.session.room.model.message.MessageAudioContent
sealed class MessageComposerAction : VectorViewModelAction { 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 EnterEditMode(val eventId: String) : MessageComposerAction()
data class EnterQuoteMode(val eventId: String) : MessageComposerAction() data class EnterQuoteMode(val eventId: String) : MessageComposerAction()
data class EnterReplyMode(val eventId: String) : MessageComposerAction() data class EnterReplyMode(val eventId: String) : MessageComposerAction()

View File

@ -37,6 +37,7 @@ import androidx.annotation.RequiresApi
import androidx.annotation.StringRes import androidx.annotation.StringRes
import androidx.core.content.ContextCompat import androidx.core.content.ContextCompat
import androidx.core.text.buildSpannedString import androidx.core.text.buildSpannedString
import androidx.core.view.isGone
import androidx.core.view.isInvisible import androidx.core.view.isInvisible
import androidx.core.view.isVisible import androidx.core.view.isVisible
import androidx.lifecycle.Lifecycle import androidx.lifecycle.Lifecycle
@ -161,6 +162,14 @@ class MessageComposerFragment : VectorBaseFragment<FragmentComposerBinding>(), A
private val messageComposerViewModel: MessageComposerViewModel by parentFragmentViewModel() private val messageComposerViewModel: MessageComposerViewModel by parentFragmentViewModel()
private lateinit var sharedActionViewModel: MessageSharedActionViewModel 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 { override fun getBinding(inflater: LayoutInflater, container: ViewGroup?): FragmentComposerBinding {
return FragmentComposerBinding.inflate(inflater, container, false) return FragmentComposerBinding.inflate(inflater, container, false)
} }
@ -175,6 +184,9 @@ class MessageComposerFragment : VectorBaseFragment<FragmentComposerBinding>(), A
setupComposer() setupComposer()
setupEmojiButton() setupEmojiButton()
views.composerLayout.isGone = vectorPreferences.isRichTextEditorEnabled()
views.richTextComposerLayout.isVisible = vectorPreferences.isRichTextEditorEnabled()
messageComposerViewModel.observeViewEvents { messageComposerViewModel.observeViewEvents {
when (it) { when (it) {
is MessageComposerViewEvents.JoinRoomCommandSuccess -> handleJoinedToAnotherRoom(it) is MessageComposerViewEvents.JoinRoomCommandSuccess -> handleJoinedToAnotherRoom(it)
@ -218,29 +230,33 @@ class MessageComposerFragment : VectorBaseFragment<FragmentComposerBinding>(), A
if (withState(messageComposerViewModel) { it.isVoiceRecording } && requireActivity().isChangingConfigurations) { if (withState(messageComposerViewModel) { it.isVoiceRecording } && requireActivity().isChangingConfigurations) {
// we're rotating, maintain any active recordings // we're rotating, maintain any active recordings
} else { } else {
messageComposerViewModel.handle(MessageComposerAction.OnEntersBackground(views.composerLayout.text.toString())) messageComposerViewModel.handle(MessageComposerAction.OnEntersBackground(composer.text.toString()))
} }
} }
override fun onDestroyView() { override fun onDestroyView() {
super.onDestroyView() super.onDestroyView()
if (!vectorPreferences.isRichTextEditorEnabled()) {
autoCompleter.clear() autoCompleter.clear()
}
messageComposerViewModel.endAllVoiceActions() messageComposerViewModel.endAllVoiceActions()
} }
override fun invalidate() = withState(timelineViewModel, messageComposerViewModel) { mainState, messageComposerState -> override fun invalidate() = withState(timelineViewModel, messageComposerViewModel) { mainState, messageComposerState ->
if (mainState.tombstoneEvent != null) return@withState if (mainState.tombstoneEvent != null) return@withState
views.root.isInvisible = !messageComposerState.isComposerVisible composer.setInvisible(!messageComposerState.isComposerVisible)
views.composerLayout.views.sendButton.isInvisible = !messageComposerState.isSendButtonVisible composer.sendButton.isInvisible = !messageComposerState.isSendButtonVisible
} }
private fun setupComposer() { private fun setupComposer() {
val composerEditText = views.composerLayout.views.composerEditText val composerEditText = composer.editText
composerEditText.setHint(R.string.room_message_placeholder) composerEditText.setHint(R.string.room_message_placeholder)
if (!vectorPreferences.isRichTextEditorEnabled()) {
autoCompleter.setup(composerEditText) autoCompleter.setup(composerEditText)
}
observerUserTyping() observerUserTyping()
@ -257,20 +273,22 @@ class MessageComposerFragment : VectorBaseFragment<FragmentComposerBinding>(), A
!keyEvent.isShiftPressed && !keyEvent.isShiftPressed &&
keyEvent.keyCode == KeyEvent.KEYCODE_ENTER && keyEvent.keyCode == KeyEvent.KEYCODE_ENTER &&
resources.configuration.keyboard != Configuration.KEYBOARD_NOKEYS resources.configuration.keyboard != Configuration.KEYBOARD_NOKEYS
if (isSendAction || externalKeyboardPressedEnter) { val result = if (isSendAction || externalKeyboardPressedEnter) {
sendTextMessage(v.text) sendTextMessage(v.text)
true true
} else false } else false
composer.setTextIfDifferent(null)
result
} }
views.composerLayout.views.composerEmojiButton.isVisible = vectorPreferences.showEmojiKeyboard() composer.emojiButton?.isVisible = vectorPreferences.showEmojiKeyboard()
val showKeyboard = withState(timelineViewModel) { it.showKeyboardWhenPresented } val showKeyboard = withState(timelineViewModel) { it.showKeyboardWhenPresented }
if (isThreadTimeLine() && showKeyboard) { if (isThreadTimeLine() && showKeyboard) {
// Show keyboard when the user started a thread // 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() { override fun onAddAttachment() {
if (!::attachmentTypeSelector.isInitialized) { if (!::attachmentTypeSelector.isInitialized) {
attachmentTypeSelector = AttachmentTypeSelectorView(vectorBaseActivity, vectorBaseActivity.layoutInflater, this@MessageComposerFragment) attachmentTypeSelector = AttachmentTypeSelectorView(vectorBaseActivity, vectorBaseActivity.layoutInflater, this@MessageComposerFragment)
@ -286,15 +304,15 @@ class MessageComposerFragment : VectorBaseFragment<FragmentComposerBinding>(), A
vectorFeatures.isVoiceBroadcastEnabled(), // TODO check user permission vectorFeatures.isVoiceBroadcastEnabled(), // TODO check user permission
) )
} }
attachmentTypeSelector.show(views.composerLayout.views.attachmentButton) attachmentTypeSelector.show(composer.attachmentButton)
} }
override fun onExpandOrCompactChange() { override fun onExpandOrCompactChange() {
views.composerLayout.views.composerEmojiButton.isVisible = isEmojiKeyboardVisible composer.emojiButton?.isVisible = isEmojiKeyboardVisible
} }
override fun onSendMessage(text: CharSequence) { override fun onSendMessage(text: CharSequence) {
sendTextMessage(text) sendTextMessage(text, composer.formattedText)
} }
override fun onCloseRelatedMessage() { override fun onCloseRelatedMessage() {
@ -311,16 +329,20 @@ class MessageComposerFragment : VectorBaseFragment<FragmentComposerBinding>(), A
} }
} }
private fun sendTextMessage(text: CharSequence) { private fun sendTextMessage(text: CharSequence, formattedText: String? = null) {
if (lockSendButton) { if (lockSendButton) {
Timber.w("Send button is locked") Timber.w("Send button is locked")
return return
} }
if (text.isNotBlank()) { if (text.isNotBlank()) {
// We collapse ASAP, if not there will be a slight annoying delay // We collapse ASAP, if not there will be a slight annoying delay
views.composerLayout.collapse(true) composer.collapse(true)
lockSendButton = 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() emojiPopup.dismiss()
} }
} }
@ -336,22 +358,22 @@ class MessageComposerFragment : VectorBaseFragment<FragmentComposerBinding>(), A
return isHandled return isHandled
} }
private fun renderRegularMode(content: String) { private fun renderRegularMode(content: CharSequence) {
autoCompleter.exitSpecialMode() autoCompleter.exitSpecialMode()
views.composerLayout.collapse() composer.collapse()
views.composerLayout.setTextIfDifferent(content) composer.setTextIfDifferent(content)
views.composerLayout.views.sendButton.contentDescription = getString(R.string.action_send) composer.sendButton.contentDescription = getString(R.string.action_send)
} }
private fun renderSpecialMode( private fun renderSpecialMode(
event: TimelineEvent, event: TimelineEvent,
@DrawableRes iconRes: Int, @DrawableRes iconRes: Int,
@StringRes descriptionRes: Int, @StringRes descriptionRes: Int,
defaultContent: String defaultContent: CharSequence,
) { ) {
autoCompleter.enterSpecialMode() autoCompleter.enterSpecialMode()
// switch to expanded bar // switch to expanded bar
views.composerLayout.views.composerRelatedMessageTitle.apply { composer.composerRelatedMessageTitle.apply {
text = event.senderInfo.disambiguatedDisplayName text = event.senderInfo.disambiguatedDisplayName
setTextColor(matrixItemColorProvider.getColor(MatrixItem.UserItem(event.root.senderId ?: "@"))) setTextColor(matrixItemColorProvider.getColor(MatrixItem.UserItem(event.root.senderId ?: "@")))
} }
@ -369,32 +391,32 @@ class MessageComposerFragment : VectorBaseFragment<FragmentComposerBinding>(), A
val document = parser.parse(messageContent.formattedBody ?: messageContent.body) val document = parser.parse(messageContent.formattedBody ?: messageContent.body)
formattedBody = eventHtmlRenderer.render(document, pillsPostProcessor) formattedBody = eventHtmlRenderer.render(document, pillsPostProcessor)
} }
views.composerLayout.views.composerRelatedMessageContent.text = (formattedBody ?: nonFormattedBody) composer.composerRelatedMessageContent.text = (formattedBody ?: nonFormattedBody)
// Image Event // Image Event
val data = event.buildImageContentRendererData(dimensionConverter.dpToPx(66)) val data = event.buildImageContentRendererData(dimensionConverter.dpToPx(66))
val isImageVisible = if (data != null) { val isImageVisible = if (data != null) {
imageContentRenderer.render(data, ImageContentRenderer.Mode.THUMBNAIL, views.composerLayout.views.composerRelatedMessageImage) imageContentRenderer.render(data, ImageContentRenderer.Mode.THUMBNAIL, composer.composerRelatedMessageImage)
true true
} else { } else {
imageContentRenderer.clear(views.composerLayout.views.composerRelatedMessageImage) imageContentRenderer.clear(composer.composerRelatedMessageImage)
false 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)) composer.composerRelatedMessageActionIcon.setImageDrawable(ContextCompat.getDrawable(requireContext(), iconRes))
views.composerLayout.views.sendButton.contentDescription = getString(descriptionRes) 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) { if (isAdded) {
// need to do it here also when not using quick reply // need to do it here also when not using quick reply
focusComposerAndShowKeyboard() focusComposerAndShowKeyboard()
views.composerLayout.views.composerRelatedMessageImage.isVisible = isImageVisible composer.composerRelatedMessageImage.isVisible = isImageVisible
} }
} }
focusComposerAndShowKeyboard() focusComposerAndShowKeyboard()
@ -402,7 +424,7 @@ class MessageComposerFragment : VectorBaseFragment<FragmentComposerBinding>(), A
private fun observerUserTyping() { private fun observerUserTyping() {
if (isThreadTimeLine()) return if (isThreadTimeLine()) return
views.composerLayout.views.composerEditText.textChanges() composer.editText.textChanges()
.skipInitialValue() .skipInitialValue()
.debounce(300) .debounce(300)
.map { it.isNotEmpty() } .map { it.isNotEmpty() }
@ -412,7 +434,7 @@ class MessageComposerFragment : VectorBaseFragment<FragmentComposerBinding>(), A
} }
.launchIn(viewLifecycleOwner.lifecycleScope) .launchIn(viewLifecycleOwner.lifecycleScope)
views.composerLayout.views.composerEditText.focusChanges() composer.editText.focusChanges()
.onEach { .onEach {
timelineViewModel.handle(RoomDetailAction.ComposerFocusChange(it)) timelineViewModel.handle(RoomDetailAction.ComposerFocusChange(it))
} }
@ -420,18 +442,18 @@ class MessageComposerFragment : VectorBaseFragment<FragmentComposerBinding>(), A
} }
private fun focusComposerAndShowKeyboard() { private fun focusComposerAndShowKeyboard() {
if (views.composerLayout.isVisible) { if (composer.isVisible) {
views.composerLayout.views.composerEditText.showKeyboard(andRequestFocus = true) composer.editText.showKeyboard(andRequestFocus = true)
} }
} }
private fun handleSendButtonVisibilityChanged(event: MessageComposerViewEvents.AnimateSendButtonVisibility) { private fun handleSendButtonVisibilityChanged(event: MessageComposerViewEvents.AnimateSendButtonVisibility) {
if (event.isVisible) { if (event.isVisible) {
views.root.views.sendButton.alpha = 0f composer.sendButton.alpha = 0f
views.root.views.sendButton.isVisible = true composer.sendButton.isVisible = true
views.root.views.sendButton.animate().alpha(1f).setDuration(150).start() composer.sendButton.animate().alpha(1f).setDuration(150).start()
} else { } else {
views.root.views.sendButton.isInvisible = true composer.sendButton.isInvisible = true
} }
} }
@ -455,18 +477,18 @@ class MessageComposerFragment : VectorBaseFragment<FragmentComposerBinding>(), A
rootView = views.root, rootView = views.root,
keyboardAnimationStyle = R.style.emoji_fade_animation_style, keyboardAnimationStyle = R.style.emoji_fade_animation_style,
onEmojiPopupShownListener = { onEmojiPopupShownListener = {
views.composerLayout.views.composerEmojiButton.apply { composer.emojiButton?.apply {
contentDescription = getString(R.string.a11y_close_emoji_picker) contentDescription = getString(R.string.a11y_close_emoji_picker)
setImageResource(R.drawable.ic_keyboard) setImageResource(R.drawable.ic_keyboard)
} }
}, },
onEmojiPopupDismissListener = lifecycleAwareDismissAction { onEmojiPopupDismissListener = lifecycleAwareDismissAction {
views.composerLayout.views.composerEmojiButton.apply { composer.emojiButton?.apply {
contentDescription = getString(R.string.a11y_open_emoji_picker) contentDescription = getString(R.string.a11y_open_emoji_picker)
setImageResource(R.drawable.ic_insert_emoji) setImageResource(R.drawable.ic_insert_emoji)
} }
}, },
editText = views.composerLayout.views.composerEditText editText = composer.editText
) )
} }
@ -483,7 +505,7 @@ class MessageComposerFragment : VectorBaseFragment<FragmentComposerBinding>(), A
} }
private fun setupEmojiButton() { private fun setupEmojiButton() {
views.composerLayout.views.composerEmojiButton.debouncedClicks { composer.emojiButton?.debouncedClicks {
emojiPopup.toggle() emojiPopup.toggle()
} }
} }
@ -494,7 +516,7 @@ class MessageComposerFragment : VectorBaseFragment<FragmentComposerBinding>(), A
} }
private fun handleJoinedToAnotherRoom(action: MessageComposerViewEvents.JoinRoomCommandSuccess) { private fun handleJoinedToAnotherRoom(action: MessageComposerViewEvents.JoinRoomCommandSuccess) {
views.composerLayout.setTextIfDifferent("") composer.setTextIfDifferent("")
lockSendButton = false lockSendButton = false
navigator.openRoom(vectorBaseActivity, action.roomId) navigator.openRoom(vectorBaseActivity, action.roomId)
} }
@ -549,7 +571,7 @@ class MessageComposerFragment : VectorBaseFragment<FragmentComposerBinding>(), A
private fun handleSlashCommandResultOk(parsedCommand: ParsedCommand) { private fun handleSlashCommandResultOk(parsedCommand: ParsedCommand) {
dismissLoadingDialog() dismissLoadingDialog()
views.composerLayout.setTextIfDifferent("") composer.setTextIfDifferent("")
when (parsedCommand) { when (parsedCommand) {
is ParsedCommand.DevTools -> { is ParsedCommand.DevTools -> {
navigator.openDevTools(requireContext(), roomId) navigator.openDevTools(requireContext(), roomId)
@ -608,7 +630,7 @@ class MessageComposerFragment : VectorBaseFragment<FragmentComposerBinding>(), A
override fun onContactAttachmentReady(contactAttachment: ContactAttachment) { override fun onContactAttachmentReady(contactAttachment: ContactAttachment) {
val formattedContact = contactAttachment.toHumanReadable() val formattedContact = contactAttachment.toHumanReadable()
messageComposerViewModel.handle(MessageComposerAction.SendMessage(formattedContact, false)) messageComposerViewModel.handle(MessageComposerAction.SendMessage(formattedContact, null, false))
} }
override fun onAttachmentError(throwable: Throwable) { override fun onAttachmentError(throwable: Throwable) {
@ -718,13 +740,13 @@ class MessageComposerFragment : VectorBaseFragment<FragmentComposerBinding>(), A
@SuppressLint("SetTextI18n") @SuppressLint("SetTextI18n")
private fun insertUserDisplayNameInTextEditor(userId: String) { private fun insertUserDisplayNameInTextEditor(userId: String) {
val startToCompose = views.composerLayout.text.isNullOrBlank() val startToCompose = composer.text.isNullOrBlank()
if (startToCompose && if (startToCompose &&
userId == session.myUserId) { userId == session.myUserId) {
// Empty composer, current user: start an emote // Empty composer, current user: start an emote
views.composerLayout.views.composerEditText.setText("${Command.EMOTE.command} ") composer.editText.setText("${Command.EMOTE.command} ")
views.composerLayout.views.composerEditText.setSelection(Command.EMOTE.command.length + 1) composer.editText.setSelection(Command.EMOTE.command.length + 1)
} else { } else {
val roomMember = timelineViewModel.getMember(userId) val roomMember = timelineViewModel.getMember(userId)
val displayName = sanitizeDisplayName(roomMember?.displayName ?: userId) val displayName = sanitizeDisplayName(roomMember?.displayName ?: userId)
@ -737,7 +759,7 @@ class MessageComposerFragment : VectorBaseFragment<FragmentComposerBinding>(), A
requireContext(), requireContext(),
MatrixItem.UserItem(userId, displayName, roomMember?.avatarUrl) MatrixItem.UserItem(userId, displayName, roomMember?.avatarUrl)
) )
.also { it.bind(views.composerLayout.views.composerEditText) }, .also { it.bind(composer.editText) },
0, 0,
displayName.length, displayName.length,
Spannable.SPAN_EXCLUSIVE_EXCLUSIVE Spannable.SPAN_EXCLUSIVE_EXCLUSIVE
@ -747,11 +769,11 @@ class MessageComposerFragment : VectorBaseFragment<FragmentComposerBinding>(), A
if (startToCompose) { if (startToCompose) {
if (displayName.startsWith("/")) { if (displayName.startsWith("/")) {
// Ensure displayName will not be interpreted as a Slash command // 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 { } else {
views.composerLayout.views.composerEditText.text?.insert(views.composerLayout.views.composerEditText.selectionStart, pill) composer.editText.text?.insert(composer.editText.selectionStart, pill)
} }
} }
focusComposerAndShowKeyboard() focusComposerAndShowKeyboard()

View File

@ -1,5 +1,5 @@
/* /*
* Copyright 2019 New Vector Ltd * Copyright (c) 2022 New Vector Ltd
* *
* Licensed under the Apache License, Version 2.0 (the "License"); * Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License. * you may not use this file except in compliance with the License.
@ -16,137 +16,34 @@
package im.vector.app.features.home.room.detail.composer package im.vector.app.features.home.room.detail.composer
import android.content.Context
import android.net.Uri
import android.text.Editable import android.text.Editable
import android.util.AttributeSet import android.widget.EditText
import android.view.ViewGroup import android.widget.ImageButton
import androidx.constraintlayout.widget.ConstraintLayout import android.widget.ImageView
import androidx.constraintlayout.widget.ConstraintSet import android.widget.TextView
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
/** interface MessageComposerView {
* 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
val text: Editable? 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 { var callback: PlainTextComposerLayout.Callback?
inflate(context, R.layout.composer_layout, this)
views = ComposerLayoutBinding.bind(this)
collapse(false) var isVisible: Boolean
views.composerEditText.callback = object : ComposerEditText.Callback { fun collapse(animate: Boolean = true, transitionComplete: (() -> Unit)? = null)
override fun onRichContentSelected(contentUri: Uri): Boolean { fun expand(animate: Boolean = true, transitionComplete: (() -> Unit)? = null)
return callback?.onRichContentSelected(contentUri) ?: false fun setTextIfDifferent(text: CharSequence?): Boolean
} fun replaceFormattedContent(text: CharSequence)
override fun onTextChanged(text: CharSequence) { fun setInvisible(isInvisible: Boolean)
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)
}
} }

View File

@ -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.RoomAvatarContent
import org.matrix.android.sdk.api.session.room.model.RoomEncryptionAlgorithm 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.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.message.MessageType
import org.matrix.android.sdk.api.session.room.model.relation.shouldRenderInThread import org.matrix.android.sdk.api.session.room.model.relation.shouldRenderInThread
import org.matrix.android.sdk.api.session.room.powerlevels.PowerLevelsHelper import org.matrix.android.sdk.api.session.room.powerlevels.PowerLevelsHelper
@ -201,6 +202,7 @@ class MessageComposerViewModel @AssistedInject constructor(
is SendMode.Regular -> { is SendMode.Regular -> {
when (val parsedCommand = commandParser.parseSlashCommand( when (val parsedCommand = commandParser.parseSlashCommand(
textMessage = action.text, textMessage = action.text,
formattedMessage = action.formattedText,
isInThreadTimeline = state.isInThreadTimeline() isInThreadTimeline = state.isInThreadTimeline()
)) { )) {
is ParsedCommand.ErrorNotACommand -> { is ParsedCommand.ErrorNotACommand -> {
@ -209,11 +211,16 @@ class MessageComposerViewModel @AssistedInject constructor(
room.relationService().replyInThread( room.relationService().replyInThread(
rootThreadEventId = state.rootThreadEventId, rootThreadEventId = state.rootThreadEventId,
replyInThreadText = action.text, replyInThreadText = action.text,
formattedText = action.formattedText,
autoMarkdown = action.autoMarkdown autoMarkdown = action.autoMarkdown
) )
} else {
if (action.formattedText != null) {
room.sendService().sendFormattedTextMessage(action.text.toString(), action.formattedText)
} else { } else {
room.sendService().sendTextMessage(action.text, autoMarkdown = action.autoMarkdown) room.sendService().sendTextMessage(action.text, autoMarkdown = action.autoMarkdown)
} }
}
_viewEvents.post(MessageComposerViewEvents.MessageSent) _viewEvents.post(MessageComposerViewEvents.MessageSent)
popDraft() popDraft()
@ -244,6 +251,24 @@ class MessageComposerViewModel @AssistedInject constructor(
_viewEvents.post(MessageComposerViewEvents.MessageSent) _viewEvents.post(MessageComposerViewEvents.MessageSent)
popDraft() 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 -> { is ParsedCommand.ChangeRoomName -> {
handleChangeRoomNameSlashCommand(parsedCommand) handleChangeRoomNameSlashCommand(parsedCommand)
} }
@ -510,16 +535,24 @@ class MessageComposerViewModel @AssistedInject constructor(
if (inReplyTo != null) { if (inReplyTo != null) {
// TODO check if same content? // TODO check if same content?
room.getTimelineEvent(inReplyTo)?.let { 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 { } else {
val messageContent = state.sendMode.timelineEvent.getVectorLastMessageContent() val messageContent = state.sendMode.timelineEvent.getVectorLastMessageContent()
val existingBody = messageContent?.body ?: "" val existingBody: String
if (existingBody != action.text) { val needsEdit = if (messageContent is MessageContentWithFormattedBody) {
existingBody = messageContent.formattedBody ?: ""
existingBody != action.formattedText
} else {
existingBody = messageContent?.body ?: ""
existingBody != action.text
}
if (needsEdit) {
room.relationService().editTextMessage( room.relationService().editTextMessage(
state.sendMode.timelineEvent, state.sendMode.timelineEvent,
messageContent?.msgType ?: MessageType.MSGTYPE_TEXT, messageContent?.msgType ?: MessageType.MSGTYPE_TEXT,
action.text, action.text,
(messageContent as? MessageContentWithFormattedBody)?.formattedBody,
action.autoMarkdown action.autoMarkdown
) )
} else { } else {
@ -533,6 +566,7 @@ class MessageComposerViewModel @AssistedInject constructor(
room.sendService().sendQuotedTextMessage( room.sendService().sendQuotedTextMessage(
quotedEvent = state.sendMode.timelineEvent, quotedEvent = state.sendMode.timelineEvent,
text = action.text.toString(), text = action.text.toString(),
formattedText = action.formattedText,
autoMarkdown = action.autoMarkdown, autoMarkdown = action.autoMarkdown,
rootThreadEventId = state.rootThreadEventId rootThreadEventId = state.rootThreadEventId
) )
@ -549,11 +583,13 @@ class MessageComposerViewModel @AssistedInject constructor(
rootThreadEventId = it, rootThreadEventId = it,
replyInThreadText = action.text.toString(), replyInThreadText = action.text.toString(),
autoMarkdown = action.autoMarkdown, autoMarkdown = action.autoMarkdown,
formattedText = action.formattedText,
eventReplied = timelineEvent eventReplied = timelineEvent
) )
} ?: room.relationService().replyToMessage( } ?: room.relationService().replyToMessage(
eventReplied = timelineEvent, eventReplied = timelineEvent,
replyText = action.text.toString(), replyText = action.text.toString(),
replyFormattedText = action.formattedText,
autoMarkdown = action.autoMarkdown, autoMarkdown = action.autoMarkdown,
showInThread = showInThread, showInThread = showInThread,
rootThreadEventId = rootThreadEventId rootThreadEventId = rootThreadEventId

View File

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

View File

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

View File

@ -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.MessageComposerAction
import im.vector.app.features.home.room.detail.composer.MessageComposerViewEvents 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.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 im.vector.app.features.home.room.detail.timeline.helper.AudioMessagePlaybackTracker
import javax.inject.Inject import javax.inject.Inject
@ -70,6 +73,15 @@ class VoiceRecorderFragment : VectorBaseFragment<FragmentVoiceRecorderBinding>()
else -> Unit 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() { override fun onResume() {

View File

@ -71,6 +71,7 @@ class VectorPreferences @Inject constructor(
const val SETTINGS_LABS_PREFERENCE_KEY = "SETTINGS_LABS_PREFERENCE_KEY" 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_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_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_PREFERENCE_KEY = "SETTINGS_CRYPTOGRAPHY_PREFERENCE_KEY"
const val SETTINGS_CRYPTOGRAPHY_DIVIDER_PREFERENCE_KEY = "SETTINGS_CRYPTOGRAPHY_DIVIDER_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" const val SETTINGS_CRYPTOGRAPHY_MANAGE_PREFERENCE_KEY = "SETTINGS_CRYPTOGRAPHY_MANAGE_PREFERENCE_KEY"
@ -1182,4 +1183,8 @@ class VectorPreferences @Inject constructor(
fun showLiveSenderInfo(): Boolean { fun showLiveSenderInfo(): Boolean {
return defaultPrefs.getBoolean(SETTINGS_TIMELINE_SHOW_LIVE_SENDER_INFO, getDefault(R.bool.settings_timeline_show_live_sender_info_default)) 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))
}
} }

View File

@ -0,0 +1,10 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="44dp"
android:height="44dp"
android:viewportWidth="44"
android:viewportHeight="44">
<path
android:pathData="M16,14.5C16,13.672 16.672,13 17.5,13H22.288C25.139,13 27.25,15.466 27.25,18.25C27.25,19.38 26.902,20.458 26.298,21.34C27.765,22.268 28.75,23.882 28.75,25.75C28.75,28.689 26.311,31 23.393,31H17.5C16.672,31 16,30.328 16,29.5V14.5ZM19,16V20.5H22.288C23.261,20.5 24.25,19.608 24.25,18.25C24.25,16.892 23.261,16 22.288,16H19ZM19,23.5V28H23.393C24.735,28 25.75,26.953 25.75,25.75C25.75,24.547 24.735,23.5 23.393,23.5H19Z"
android:fillColor="#8D97A5"
android:fillType="evenOdd"/>
</vector>

View File

@ -0,0 +1,10 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="44dp"
android:height="44dp"
android:viewportWidth="44"
android:viewportHeight="44">
<path
android:pathData="M22.619,14.999L19.747,29.005H17.2C16.758,29.005 16.4,29.363 16.4,29.805C16.4,30.247 16.758,30.605 17.2,30.605H20.389C20.397,30.605 20.405,30.605 20.412,30.605H23.6C24.042,30.605 24.4,30.247 24.4,29.805C24.4,29.363 24.042,29.005 23.6,29.005H21.381L24.253,14.999H26.8C27.242,14.999 27.6,14.64 27.6,14.199C27.6,13.757 27.242,13.399 26.8,13.399H23.615C23.604,13.398 23.594,13.398 23.583,13.399H20.4C19.958,13.399 19.6,13.757 19.6,14.199C19.6,14.64 19.958,14.999 20.4,14.999H22.619Z"
android:fillColor="#8D97A5"
android:fillType="evenOdd"/>
</vector>

View File

@ -0,0 +1,12 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="44dp"
android:height="44dp"
android:viewportWidth="44"
android:viewportHeight="44">
<path
android:pathData="M24.897,17.154C24.235,15.821 22.876,15.21 21.374,15.372C19.05,15.622 18.44,17.423 18.722,18.592C19.032,19.872 20.046,20.37 21.839,20.826H29.92C30.517,20.826 31,21.351 31,22C31,22.648 30.517,23.174 29.92,23.174H14.08C13.483,23.174 13,22.648 13,22C13,21.351 13.483,20.826 14.08,20.826H17.355C17.041,20.377 16.791,19.839 16.633,19.189C16.003,16.581 17.554,13.424 21.16,13.036C23.285,12.807 25.615,13.661 26.798,16.038C27.081,16.608 26.886,17.32 26.361,17.629C25.836,17.937 25.181,17.725 24.897,17.154Z"
android:fillColor="#8D97A5"/>
<path
android:pathData="M25.427,25.13H27.67C27.888,26.306 27.721,27.56 27.05,28.632C26.114,30.125 24.37,31 21.985,31C18.076,31 16.279,28.584 15.912,26.986C15.768,26.357 16.12,25.72 16.698,25.563C17.277,25.406 17.863,25.788 18.008,26.417C18.119,26.902 19.002,28.652 21.985,28.652C23.907,28.652 24.854,27.965 25.264,27.31C25.642,26.707 25.708,25.909 25.427,25.13Z"
android:fillColor="#8D97A5"/>
</vector>

View File

@ -0,0 +1,13 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="44dp"
android:height="44dp"
android:viewportWidth="44"
android:viewportHeight="44">
<group>
<clip-path
android:pathData="M10,10h24v24h-24z"/>
<path
android:pathData="M22.79,26.95C25.82,26.56 28,23.84 28,20.79V14.25C28,13.56 27.44,13 26.75,13C26.06,13 25.5,13.56 25.5,14.25V20.9C25.5,22.57 24.37,24.09 22.73,24.42C20.48,24.89 18.5,23.17 18.5,21V14.25C18.5,13.56 17.94,13 17.25,13C16.56,13 16,13.56 16,14.25V21C16,24.57 19.13,27.42 22.79,26.95ZM15,30C15,30.55 15.45,31 16,31H28C28.55,31 29,30.55 29,30C29,29.45 28.55,29 28,29H16C15.45,29 15,29.45 15,30Z"
android:fillColor="#8D97A5"/>
</group>
</vector>

View File

@ -0,0 +1,156 @@
<?xml version="1.0" encoding="utf-8"?>
<merge xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="wrap_content"
tools:constraintSet="@layout/composer_rich_text_layout_constraint_set_compact"
tools:parentTag="androidx.constraintlayout.widget.ConstraintLayout">
<!-- ========================
/!\ Constraints for this layout are defined in external layout files that are used as constraint set for animation.
/!\ These 3 files must be modified to stay coherent!
======================== -->
<View
android:id="@+id/related_message_background"
android:layout_width="0dp"
android:layout_height="0dp"
android:background="?colorSurface"
tools:ignore="MissingConstraints" />
<View
android:id="@+id/related_message_background_top_separator"
android:layout_width="0dp"
android:layout_height="0dp"
android:background="?vctr_list_separator"
tools:ignore="MissingConstraints" />
<ImageView
android:id="@+id/composerRelatedMessageAvatar"
android:layout_width="0dp"
android:layout_height="0dp"
android:importantForAccessibility="no"
tools:ignore="MissingConstraints" />
<TextView
android:id="@+id/composerRelatedMessageTitle"
android:layout_width="0dp"
android:layout_height="0dp"
android:textStyle="bold"
tools:ignore="MissingConstraints"
tools:text="@tools:sample/first_names"
tools:visibility="gone" />
<TextView
android:id="@+id/composerRelatedMessageContent"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:ellipsize="end"
android:maxLines="3"
android:textColor="?vctr_message_text_color"
tools:ignore="MissingConstraints"
tools:text="@tools:sample/lorem"
tools:visibility="gone" />
<ImageView
android:id="@+id/composerRelatedMessageActionIcon"
android:layout_width="0dp"
android:layout_height="0dp"
android:importantForAccessibility="no"
app:tint="?vctr_content_primary"
tools:ignore="MissingConstraints,MissingPrefix" />
<ImageView
android:id="@+id/composerRelatedMessageImage"
android:layout_width="0dp"
android:layout_height="0dp"
android:importantForAccessibility="no"
tools:ignore="MissingPrefix" />
<ImageButton
android:id="@+id/composerRelatedMessageCloseButton"
android:layout_width="22dp"
android:layout_height="22dp"
android:background="?android:attr/selectableItemBackground"
android:contentDescription="@string/action_cancel"
android:src="@drawable/ic_close_round"
app:tint="?colorError"
tools:ignore="MissingConstraints,MissingPrefix" />
<androidx.constraintlayout.widget.Barrier
android:id="@+id/composer_preview_barrier"
android:layout_width="0dp"
android:layout_height="0dp"
app:barrierDirection="bottom"
app:barrierMargin="8dp"
app:constraint_referenced_ids="composerRelatedMessageContent,composerRelatedMessageActionIcon"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent" />
<ImageButton
android:id="@+id/attachmentButton"
android:layout_width="0dp"
android:layout_height="0dp"
android:background="?android:attr/selectableItemBackground"
android:contentDescription="@string/option_send_files"
android:src="@drawable/ic_attachment"
tools:ignore="MissingConstraints" />
<FrameLayout
android:id="@+id/composerEditTextOuterBorder"
android:layout_width="0dp"
android:layout_height="0dp"
android:background="@drawable/bg_composer_edit_text" />
<io.element.android.wysiwyg.EditorEditText
android:id="@+id/composerEditText"
style="@style/Widget.Vector.EditText.Composer"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:nextFocusLeft="@id/composerEditText"
android:nextFocusUp="@id/composerEditText"
tools:hint="@string/room_message_placeholder"
tools:ignore="MissingConstraints" />
<ImageButton
android:id="@+id/sendButton"
android:layout_width="0dp"
android:layout_height="0dp"
android:background="@drawable/bg_send"
android:contentDescription="@string/action_send"
android:src="@drawable/ic_send"
tools:ignore="MissingConstraints" />
<HorizontalScrollView android:id="@+id/richTextMenuScrollView"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:scrollbars="none"
app:layout_constraintTop_toTopOf="@id/sendButton"
app:layout_constraintStart_toEndOf="@id/attachmentButton"
app:layout_constraintEnd_toStartOf="@id/sendButton"
app:layout_constraintBottom_toBottomOf="parent"
android:fillViewport="true">
<LinearLayout
android:id="@+id/richTextMenu"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:orientation="horizontal">
</LinearLayout>
</HorizontalScrollView>
<!--
<ImageButton
android:id="@+id/voiceMessageMicButton"
android:layout_width="32dp"
android:layout_height="32dp"
android:layout_marginEnd="12dp"
android:layout_marginBottom="12dp"
android:background="?android:attr/selectableItemBackground"
android:contentDescription="@string/a11y_start_voice_message"
android:src="@drawable/ic_voice_mic" />
-->
</merge>

View File

@ -0,0 +1,200 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:id="@+id/composerLayout"
android:layout_width="match_parent"
android:layout_height="wrap_content"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent">
<View
android:id="@+id/related_message_background"
android:layout_width="0dp"
android:layout_height="0dp"
android:background="?colorSurface"
app:layout_constraintBottom_toTopOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
tools:layout_height="40dp" />
<View
android:id="@+id/related_message_background_top_separator"
android:layout_width="0dp"
android:layout_height="0dp"
android:background="?vctr_list_separator"
app:layout_constraintBottom_toTopOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent" />
<ImageView
android:id="@+id/composerRelatedMessageAvatar"
android:layout_width="40dp"
android:layout_height="40dp"
android:importantForAccessibility="no"
android:visibility="invisible"
app:layout_constraintBottom_toTopOf="parent"
app:layout_constraintEnd_toStartOf="parent" />
<TextView
android:id="@+id/composerRelatedMessageTitle"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:textStyle="bold"
android:visibility="invisible"
app:layout_constraintBottom_toTopOf="@id/composerRelatedMessageContent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
tools:text="@tools:sample/first_names" />
<TextView
android:id="@+id/composerRelatedMessageContent"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:visibility="invisible"
app:layout_constraintBottom_toTopOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
tools:text="@tools:sample/lorem/random" />
<ImageView
android:id="@+id/composerRelatedMessageActionIcon"
android:layout_width="20dp"
android:layout_height="20dp"
android:layout_marginTop="8dp"
android:layout_marginBottom="38dp"
android:alpha="0"
android:importantForAccessibility="no"
app:layout_constraintEnd_toStartOf="parent"
app:layout_constraintTop_toBottomOf="parent"
app:tint="?vctr_content_primary"
tools:ignore="MissingConstraints,MissingPrefix"
tools:src="@drawable/ic_edit" />
<ImageView
android:id="@+id/composerRelatedMessageImage"
android:layout_width="0dp"
android:layout_height="0dp"
android:importantForAccessibility="no"
app:layout_constraintBottom_toTopOf="parent"
app:layout_constraintStart_toEndOf="parent"
tools:ignore="MissingPrefix"
tools:src="@tools:sample/backgrounds/scenic" />
<ImageButton
android:id="@+id/composerRelatedMessageCloseButton"
android:layout_width="22dp"
android:layout_height="22dp"
android:background="?android:attr/selectableItemBackground"
android:contentDescription="@string/action_cancel"
android:src="@drawable/ic_close_round"
android:visibility="invisible"
app:layout_constraintBottom_toTopOf="parent"
app:layout_constraintStart_toEndOf="parent"
app:tint="?colorError"
tools:ignore="MissingPrefix"
tools:visibility="visible" />
<androidx.constraintlayout.widget.Barrier
android:id="@+id/composer_preview_barrier"
android:layout_width="0dp"
android:layout_height="0dp"
app:barrierDirection="bottom"
app:barrierMargin="8dp"
app:constraint_referenced_ids="composerRelatedMessageContent,composerRelatedMessageActionIcon"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent" />
<ImageButton
android:id="@+id/attachmentButton"
android:layout_width="@dimen/composer_attachment_size"
android:layout_height="@dimen/composer_attachment_size"
android:layout_margin="@dimen/composer_attachment_margin"
android:background="?android:attr/selectableItemBackground"
android:contentDescription="@string/option_send_files"
android:src="@drawable/ic_attachment"
app:layout_constraintBottom_toBottomOf="@id/sendButton"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="@id/sendButton"
app:layout_goneMarginBottom="57dp"
tools:ignore="MissingPrefix" />
<FrameLayout
android:id="@+id/composerEditTextOuterBorder"
android:layout_width="0dp"
android:layout_height="0dp"
android:layout_marginTop="8dp"
android:layout_marginBottom="8dp"
app:layout_constraintBottom_toBottomOf="@id/composerEditText"
app:layout_constraintStart_toStartOf="@id/composerEditText"
app:layout_constraintEnd_toEndOf="@id/composerEditText"
app:layout_constraintTop_toTopOf="@id/composerEditText"
app:layout_goneMarginEnd="12dp" />
<io.element.android.wysiwyg.EditorEditText
android:id="@+id/composerEditText"
style="@style/Widget.Vector.EditText.Composer"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:hint="@string/room_message_placeholder"
android:nextFocusLeft="@id/composerEditText"
android:nextFocusUp="@id/composerEditText"
android:layout_marginHorizontal="10dp"
app:layout_constraintVertical_bias="0"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
tools:text="@tools:sample/lorem/random" />
<ImageButton
android:id="@+id/sendButton"
android:layout_width="56dp"
android:layout_height="@dimen/composer_min_height"
android:layout_marginEnd="2dp"
android:background="@drawable/bg_send"
android:contentDescription="@string/action_send"
android:scaleType="center"
android:src="@drawable/ic_send"
android:visibility="invisible"
app:layout_constraintTop_toBottomOf="@id/composerEditText"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
tools:ignore="MissingPrefix"
tools:visibility="visible" />
<HorizontalScrollView android:id="@+id/richTextMenuScrollView"
android:layout_width="0dp"
android:layout_height="wrap_content"
app:layout_constraintTop_toTopOf="@id/sendButton"
app:layout_constraintStart_toEndOf="@id/attachmentButton"
app:layout_constraintEnd_toStartOf="@id/sendButton"
app:layout_constraintBottom_toBottomOf="parent"
android:fillViewport="true">
<LinearLayout
android:id="@+id/richTextMenu"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:orientation="horizontal">
</LinearLayout>
</HorizontalScrollView>
<!--
<ImageButton
android:id="@+id/voiceMessageMicButton"
android:layout_width="32dp"
android:layout_height="32dp"
android:layout_marginEnd="12dp"
android:layout_marginBottom="12dp"
android:background="?android:attr/selectableItemBackground"
android:contentDescription="@string/a11y_start_voice_message"
android:src="@drawable/ic_voice_mic"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent" />
-->
</androidx.constraintlayout.widget.ConstraintLayout>

View File

@ -0,0 +1,198 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:id="@+id/composerLayout"
android:layout_width="match_parent"
android:layout_height="wrap_content"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent">
<View
android:id="@+id/related_message_background"
android:layout_width="match_parent"
android:layout_height="0dp"
android:background="?colorSurface"
app:layout_constraintBottom_toBottomOf="@id/composer_preview_barrier"
app:layout_constraintTop_toTopOf="parent" />
<View
android:id="@+id/related_message_background_top_separator"
android:layout_width="0dp"
android:layout_height="1dp"
android:background="?vctr_list_separator"
app:layout_constraintEnd_toEndOf="@id/related_message_background"
app:layout_constraintStart_toStartOf="@id/related_message_background"
app:layout_constraintTop_toTopOf="@id/related_message_background" />
<ImageView
android:id="@+id/composerRelatedMessageAvatar"
android:layout_width="40dp"
android:layout_height="40dp"
android:layout_marginStart="8dp"
android:layout_marginTop="8dp"
android:layout_marginBottom="8dp"
android:importantForAccessibility="no"
app:layout_constraintBottom_toTopOf="@id/composerRelatedMessageActionIcon"
app:layout_constraintEnd_toStartOf="@id/composerRelatedMessageTitle"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="@id/composerRelatedMessageTitle"
tools:src="@sample/user_round_avatars" />
<TextView
android:id="@+id/composerRelatedMessageTitle"
style="@style/Widget.Vector.TextView.Body"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_margin="8dp"
android:textStyle="bold"
app:layout_constraintEnd_toStartOf="@id/composerRelatedMessageCloseButton"
app:layout_constraintStart_toEndOf="@id/composerRelatedMessageAvatar"
app:layout_constraintTop_toTopOf="parent"
tools:text="@tools:sample/first_names" />
<ImageView
android:id="@+id/composerRelatedMessageImage"
android:layout_width="100dp"
android:layout_height="66dp"
android:layout_marginTop="6dp"
android:importantForAccessibility="no"
android:scaleType="centerCrop"
android:visibility="gone"
app:layout_constraintStart_toStartOf="@id/composerRelatedMessageTitle"
app:layout_constraintTop_toBottomOf="@id/composerRelatedMessageTitle"
tools:ignore="MissingPrefix"
tools:src="@tools:sample/backgrounds/scenic"
tools:visibility="visible" />
<TextView
android:id="@+id/composerRelatedMessageContent"
style="@style/Widget.Vector.TextView.Body"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:ellipsize="end"
android:maxLines="2"
android:textColor="?vctr_message_text_color"
app:layout_constrainedHeight="true"
app:layout_constraintEnd_toEndOf="@id/composerRelatedMessageTitle"
app:layout_constraintStart_toStartOf="@id/composerRelatedMessageTitle"
app:layout_constraintTop_toBottomOf="@id/composerRelatedMessageImage"
tools:text="@tools:sample/lorem/random" />
<ImageView
android:id="@+id/composerRelatedMessageActionIcon"
android:layout_width="10dp"
android:layout_height="10dp"
android:layout_marginTop="6dp"
android:layout_marginBottom="38dp"
android:alpha="1"
android:importantForAccessibility="no"
android:visibility="visible"
app:layout_constraintEnd_toEndOf="@id/composerRelatedMessageAvatar"
app:layout_constraintStart_toStartOf="@id/composerRelatedMessageAvatar"
app:layout_constraintTop_toBottomOf="@id/composerRelatedMessageAvatar"
app:tint="?vctr_content_primary"
tools:ignore="MissingPrefix"
tools:src="@drawable/ic_edit" />
<ImageButton
android:id="@+id/composerRelatedMessageCloseButton"
android:layout_width="48dp"
android:layout_height="48dp"
android:background="?android:attr/selectableItemBackground"
android:contentDescription="@string/action_cancel"
android:src="@drawable/ic_close_round"
app:layout_constraintBottom_toBottomOf="@id/composer_preview_barrier"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toTopOf="parent"
app:tint="?colorError"
tools:ignore="MissingPrefix" />
<androidx.constraintlayout.widget.Barrier
android:id="@+id/composer_preview_barrier"
android:layout_width="0dp"
android:layout_height="0dp"
app:barrierDirection="bottom"
app:barrierMargin="8dp"
app:constraint_referenced_ids="composerRelatedMessageContent,composerRelatedMessageActionIcon"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent" />
<ImageButton
android:id="@+id/attachmentButton"
android:layout_width="@dimen/composer_attachment_size"
android:layout_height="@dimen/composer_attachment_size"
android:layout_margin="@dimen/composer_attachment_margin"
android:background="?android:attr/selectableItemBackground"
android:contentDescription="@string/option_send_files"
android:src="@drawable/ic_attachment"
app:layout_constraintBottom_toBottomOf="@id/sendButton"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="@id/sendButton"
tools:ignore="MissingPrefix" />
<FrameLayout
android:id="@+id/composerEditTextOuterBorder"
android:layout_width="0dp"
android:layout_height="0dp"
android:layout_marginStart="8dp"
android:layout_marginEnd="8dp"
android:layout_marginTop="8dp"
android:layout_marginBottom="8dp"
app:layout_constraintBottom_toTopOf="@id/sendButton"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/composer_preview_barrier"
app:layout_goneMarginEnd="12dp" />
<io.element.android.wysiwyg.EditorEditText
android:id="@+id/composerEditText"
style="@style/Widget.Vector.EditText.Composer"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:nextFocusLeft="@id/composerEditText"
android:nextFocusUp="@id/composerEditText"
app:layout_constraintBottom_toTopOf="@id/sendButton"
app:layout_constraintEnd_toEndOf="@id/composerEditTextOuterBorder"
app:layout_constraintStart_toStartOf="@id/composerEditTextOuterBorder"
app:layout_constraintTop_toBottomOf="@id/composer_preview_barrier"
tools:text="@tools:sample/lorem/random" />
<ImageButton
android:id="@+id/sendButton"
android:layout_width="56dp"
android:layout_height="@dimen/composer_min_height"
android:layout_marginEnd="2dp"
android:background="@drawable/bg_send"
android:contentDescription="@string/action_send"
android:scaleType="center"
android:src="@drawable/ic_send"
android:visibility="invisible"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toBottomOf="@id/composerEditText"
app:layout_constraintVertical_bias="1"
tools:ignore="MissingPrefix"
tools:visibility="visible" />
<HorizontalScrollView android:id="@+id/richTextMenuScrollView"
android:layout_width="0dp"
android:layout_height="wrap_content"
app:layout_constraintTop_toTopOf="@id/sendButton"
app:layout_constraintStart_toEndOf="@id/attachmentButton"
app:layout_constraintEnd_toStartOf="@id/sendButton"
app:layout_constraintBottom_toBottomOf="parent"
android:fillViewport="true">
<LinearLayout
android:id="@+id/richTextMenu"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:orientation="horizontal">
</LinearLayout>
</HorizontalScrollView>
</androidx.constraintlayout.widget.ConstraintLayout>

View File

@ -1,8 +1,12 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<im.vector.app.features.home.room.detail.composer.MessageComposerView <FrameLayout
xmlns:android="http://schemas.android.com/apk/res/android" xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto" xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools" xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="wrap_content">
<im.vector.app.features.home.room.detail.composer.PlainTextComposerLayout
android:id="@+id/composerLayout" android:id="@+id/composerLayout"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="wrap_content" android:layout_height="wrap_content"
@ -10,4 +14,16 @@
android:minHeight="56dp" android:minHeight="56dp"
android:transitionName="composer" android:transitionName="composer"
android:visibility="gone" android:visibility="gone"
tools:visibility="gone" />
<im.vector.app.features.home.room.detail.composer.RichTextComposerLayout
android:id="@+id/richTextComposerLayout"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:background="?android:colorBackground"
android:minHeight="56dp"
android:transitionName="composer"
android:visibility="gone"
tools:visibility="visible" /> tools:visibility="visible" />
</FrameLayout>

View File

@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<ImageButton xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="48dp"
android:layout_height="48dp"
android:layout_marginHorizontal="2dp"
android:background="@android:color/transparent"
android:contentDescription="@string/app_name">
<!-- The contentDescription attr is populated programmatically. This is just to fix lint issues. -->
</ImageButton>

View File

@ -96,4 +96,11 @@
android:title="@string/labs_enable_deferred_dm_title" android:title="@string/labs_enable_deferred_dm_title"
app:isPreferenceVisible="@bool/settings_labs_deferred_dm_visible" /> app:isPreferenceVisible="@bool/settings_labs_deferred_dm_visible" />
<im.vector.app.core.preference.VectorSwitchPreference
android:defaultValue="@bool/settings_labs_rich_text_editor_default"
android:key="SETTINGS_LABS_RICH_TEXT_EDITOR_KEY"
android:summary="@string/labs_enable_rich_text_editor_summary"
android:title="@string/labs_enable_rich_text_editor_title"
app:isPreferenceVisible="@bool/settings_labs_rich_text_editor_visible" />
</androidx.preference.PreferenceScreen> </androidx.preference.PreferenceScreen>

View File

@ -71,7 +71,7 @@ class CommandParserTest {
private fun test(message: String, expectedResult: ParsedCommand) { private fun test(message: String, expectedResult: ParsedCommand) {
val commandParser = CommandParser() val commandParser = CommandParser()
val result = commandParser.parseSlashCommand(message, false) val result = commandParser.parseSlashCommand(message, null, false)
result shouldBeEqualTo expectedResult result shouldBeEqualTo expectedResult
} }
} }