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
reporter(org.jlleitschuh.gradle.ktlint.reporter.ReporterType.CHECKSTYLE)
}
filter {
exclude { element -> element.file.path.contains("$buildDir/generated/") }
}
disabledRules = [
// TODO Re-enable these 4 rules after reformatting project
"indent",

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 : [
'opusencoder' : "io.element.android:opusencoder:1.1.0",
'wysiwyg' : "io.element.android:wysiwyg:0.1.0"
],
squareup : [
'moshi' : "com.squareup.moshi:moshi:$moshi",

View file

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

View file

@ -446,6 +446,9 @@
<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_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 -->
<string name="invitations_header">Invites</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.
* @param targetEvent The event to edit
* @param msgType the message type
* @param newBodyText The edited body
* @param newBodyText The edited body in plain text
* @param newFormattedBodyText The edited body with format
* @param newBodyAutoMarkdown true to parse markdown on the new body
* @param compatibilityBodyText The text that will appear on clients that don't support yet edition
*/
@ -99,6 +100,7 @@ interface RelationService {
targetEvent: TimelineEvent,
msgType: String,
newBodyText: CharSequence,
newFormattedBodyText: CharSequence? = null,
newBodyAutoMarkdown: Boolean,
compatibilityBodyText: String = "* $newBodyText"
): Cancelable
@ -108,13 +110,15 @@ interface RelationService {
* This method will take the new body (stripped from fallbacks) and re-add them before sending.
* @param replyToEdit The event to edit
* @param originalTimelineEvent the message that this reply (being edited) is relating to
* @param newBodyText The edited body (stripped from in reply to content)
* @param newBodyText The plain text edited body (stripped from in reply to content)
* @param newFormattedBodyText The formatted edited body (stripped from in reply to content)
* @param compatibilityBodyText The text that will appear on clients that don't support yet edition
*/
fun editReply(
replyToEdit: TimelineEvent,
originalTimelineEvent: TimelineEvent,
newBodyText: String,
newFormattedBodyText: String? = null,
compatibilityBodyText: String = "* $newBodyText"
): Cancelable
@ -133,6 +137,7 @@ interface RelationService {
* by the sdk into pills.
* @param eventReplied the event referenced by the reply
* @param replyText the reply text
* @param replyFormattedText the reply text, formatted
* @param autoMarkdown If true, the SDK will generate a formatted HTML message from the body text if markdown syntax is present
* @param showInThread If true, relation will be added to the reply in order to be visible from within threads
* @param rootThreadEventId If show in thread is true then we need the rootThreadEventId to generate the relation
@ -140,6 +145,7 @@ interface RelationService {
fun replyToMessage(
eventReplied: TimelineEvent,
replyText: CharSequence,
replyFormattedText: CharSequence? = null,
autoMarkdown: Boolean = false,
showInThread: Boolean = false,
rootThreadEventId: String? = null

View file

@ -60,12 +60,19 @@ interface SendService {
/**
* Method to quote an events content.
* @param quotedEvent The event to which we will quote it's content.
* @param text the text message to send
* @param text the plain text message to send
* @param formattedText the formatted text message to send
* @param autoMarkdown If true, the SDK will generate a formatted HTML message from the body text if markdown syntax is present
* @param rootThreadEventId when this param is not null, the message will be sent in this specific thread
* @return a [Cancelable]
*/
fun sendQuotedTextMessage(quotedEvent: TimelineEvent, text: String, autoMarkdown: Boolean, rootThreadEventId: String? = null): Cancelable
fun sendQuotedTextMessage(
quotedEvent: TimelineEvent,
text: String,
formattedText: String? = null,
autoMarkdown: Boolean,
rootThreadEventId: String? = null
): Cancelable
/**
* Method to send a media asynchronously.

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

View file

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

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

View file

@ -99,11 +99,18 @@ internal class DefaultSendService @AssistedInject constructor(
.let { sendEvent(it) }
}
override fun sendQuotedTextMessage(quotedEvent: TimelineEvent, text: String, autoMarkdown: Boolean, rootThreadEventId: String?): Cancelable {
override fun sendQuotedTextMessage(
quotedEvent: TimelineEvent,
text: String,
formattedText: String?,
autoMarkdown: Boolean,
rootThreadEventId: String?
): Cancelable {
return localEchoEventFactory.createQuotedTextEvent(
roomId = roomId,
quotedEvent = quotedEvent,
text = text,
formattedText = formattedText,
autoMarkdown = autoMarkdown,
rootThreadEventId = rootThreadEventId
)

View file

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

View file

@ -43,6 +43,8 @@
<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_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: Help and about -->

View file

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

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.isMsisdn
import im.vector.app.core.extensions.orEmpty
import im.vector.app.features.home.room.detail.ChatEffect
import org.matrix.android.sdk.api.MatrixPatterns
import org.matrix.android.sdk.api.MatrixUrls.isMxcUrl
@ -30,39 +31,30 @@ class CommandParser @Inject constructor() {
/**
* Convert the text message into a Slash command.
*
* @param textMessage the text message
* @param textMessage the text message in plain text
* @param formattedMessage the text messaged in HTML format
* @param isInThreadTimeline true if the user is currently typing in a thread
* @return a parsed slash command (ok or error)
*/
fun parseSlashCommand(textMessage: CharSequence, isInThreadTimeline: Boolean): ParsedCommand {
@Suppress("NAME_SHADOWING")
fun parseSlashCommand(textMessage: CharSequence, formattedMessage: String?, isInThreadTimeline: Boolean): ParsedCommand {
// check if it has the Slash marker
return if (!textMessage.startsWith("/")) {
val message = formattedMessage ?: textMessage
return if (!message.startsWith("/")) {
ParsedCommand.ErrorNotACommand
} else {
// "/" only
if (textMessage.length == 1) {
if (message.length == 1) {
return ParsedCommand.ErrorEmptySlashCommand
}
// Exclude "//"
if ("/" == textMessage.substring(1, 2)) {
if ("/" == message.substring(1, 2)) {
return ParsedCommand.ErrorNotACommand
}
val messageParts = try {
textMessage.split("\\s+".toRegex()).dropLastWhile { it.isEmpty() }
} catch (e: Exception) {
Timber.e(e, "## parseSlashCommand() : split failed")
null
}
// test if the string cut fails
if (messageParts.isNullOrEmpty()) {
return ParsedCommand.ErrorEmptySlashCommand
}
val (messageParts, message) = extractMessage(message.toString()) ?: return ParsedCommand.ErrorEmptySlashCommand
val slashCommand = messageParts.first()
val message = textMessage.substring(slashCommand.length).trim()
getNotSupportedByThreads(isInThreadTimeline, slashCommand)?.let {
return ParsedCommand.ErrorCommandNotSupportedInThreads(it)
@ -71,7 +63,12 @@ class CommandParser @Inject constructor() {
when {
Command.PLAIN.matches(slashCommand) -> {
if (message.isNotEmpty()) {
if (formattedMessage != null) {
val trimmedPlainTextMessage = extractMessage(textMessage.toString())?.second.orEmpty()
ParsedCommand.SendFormattedText(message = trimmedPlainTextMessage, formattedMessage = message)
} else {
ParsedCommand.SendPlainText(message = message)
}
} else {
ParsedCommand.ErrorSyntax(Command.PLAIN)
}
@ -415,6 +412,25 @@ class CommandParser @Inject constructor() {
}
}
private fun extractMessage(message: String): Pair<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 {
Command.values().filter {
!it.isThreadCommand

View file

@ -39,6 +39,7 @@ sealed interface ParsedCommand {
// Valid commands:
data class SendPlainText(val message: CharSequence) : ParsedCommand
data class SendFormattedText(val message: CharSequence, val formattedMessage: String) : ParsedCommand
data class SendEmote(val message: CharSequence) : ParsedCommand
data class SendRainbow(val message: CharSequence) : ParsedCommand
data class SendRainbowEmote(val message: CharSequence) : ParsedCommand

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
sealed class MessageComposerAction : VectorViewModelAction {
data class SendMessage(val text: CharSequence, val autoMarkdown: Boolean) : MessageComposerAction()
data class SendMessage(val text: CharSequence, val formattedText: String?, val autoMarkdown: Boolean) : MessageComposerAction()
data class EnterEditMode(val eventId: String) : MessageComposerAction()
data class EnterQuoteMode(val eventId: String) : MessageComposerAction()
data class EnterReplyMode(val eventId: String) : MessageComposerAction()

View file

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

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");
* 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
import android.content.Context
import android.net.Uri
import android.text.Editable
import android.util.AttributeSet
import android.view.ViewGroup
import androidx.constraintlayout.widget.ConstraintLayout
import androidx.constraintlayout.widget.ConstraintSet
import androidx.core.text.toSpannable
import androidx.transition.ChangeBounds
import androidx.transition.Fade
import androidx.transition.Transition
import androidx.transition.TransitionManager
import androidx.transition.TransitionSet
import im.vector.app.R
import im.vector.app.core.extensions.setTextIfDifferent
import im.vector.app.databinding.ComposerLayoutBinding
import android.widget.EditText
import android.widget.ImageButton
import android.widget.ImageView
import android.widget.TextView
/**
* Encapsulate the timeline composer UX.
*/
class MessageComposerView @JvmOverloads constructor(
context: Context,
attrs: AttributeSet? = null,
defStyleAttr: Int = 0
) : ConstraintLayout(context, attrs, defStyleAttr) {
interface Callback : ComposerEditText.Callback {
fun onCloseRelatedMessage()
fun onSendMessage(text: CharSequence)
fun onAddAttachment()
fun onExpandOrCompactChange()
}
val views: ComposerLayoutBinding
var callback: Callback? = null
private var currentConstraintSetId: Int = -1
private val animationDuration = 100L
interface MessageComposerView {
val text: Editable?
get() = views.composerEditText.text
val formattedText: String?
val editText: EditText
val emojiButton: ImageButton?
val sendButton: ImageButton
val attachmentButton: ImageButton
val composerRelatedMessageTitle: TextView
val composerRelatedMessageContent: TextView
val composerRelatedMessageImage: ImageView
val composerRelatedMessageActionIcon: ImageView
val composerRelatedMessageAvatar: ImageView
init {
inflate(context, R.layout.composer_layout, this)
views = ComposerLayoutBinding.bind(this)
var callback: PlainTextComposerLayout.Callback?
collapse(false)
var isVisible: Boolean
views.composerEditText.callback = object : ComposerEditText.Callback {
override fun onRichContentSelected(contentUri: Uri): Boolean {
return callback?.onRichContentSelected(contentUri) ?: false
}
fun collapse(animate: Boolean = true, transitionComplete: (() -> Unit)? = null)
fun expand(animate: Boolean = true, transitionComplete: (() -> Unit)? = null)
fun setTextIfDifferent(text: CharSequence?): Boolean
fun replaceFormattedContent(text: CharSequence)
override fun onTextChanged(text: CharSequence) {
callback?.onTextChanged(text)
}
}
views.composerRelatedMessageCloseButton.setOnClickListener {
collapse()
callback?.onCloseRelatedMessage()
}
views.sendButton.setOnClickListener {
val textMessage = text?.toSpannable() ?: ""
callback?.onSendMessage(textMessage)
}
views.attachmentButton.setOnClickListener {
callback?.onAddAttachment()
}
}
fun collapse(animate: Boolean = true, transitionComplete: (() -> Unit)? = null) {
if (currentConstraintSetId == R.layout.composer_layout_constraint_set_compact) {
// ignore we good
return
}
currentConstraintSetId = R.layout.composer_layout_constraint_set_compact
applyNewConstraintSet(animate, transitionComplete)
callback?.onExpandOrCompactChange()
}
fun expand(animate: Boolean = true, transitionComplete: (() -> Unit)? = null) {
if (currentConstraintSetId == R.layout.composer_layout_constraint_set_expanded) {
// ignore we good
return
}
currentConstraintSetId = R.layout.composer_layout_constraint_set_expanded
applyNewConstraintSet(animate, transitionComplete)
callback?.onExpandOrCompactChange()
}
fun setTextIfDifferent(text: CharSequence?): Boolean {
return views.composerEditText.setTextIfDifferent(text)
}
private fun applyNewConstraintSet(animate: Boolean, transitionComplete: (() -> Unit)?) {
// val wasSendButtonInvisible = views.sendButton.isInvisible
if (animate) {
configureAndBeginTransition(transitionComplete)
}
ConstraintSet().also {
it.clone(context, currentConstraintSetId)
it.applyTo(this)
}
// Might be updated by view state just after, but avoid blinks
// views.sendButton.isInvisible = wasSendButtonInvisible
}
private fun configureAndBeginTransition(transitionComplete: (() -> Unit)? = null) {
val transition = TransitionSet().apply {
ordering = TransitionSet.ORDERING_SEQUENTIAL
addTransition(ChangeBounds())
addTransition(Fade(Fade.IN))
duration = animationDuration
addListener(object : Transition.TransitionListener {
override fun onTransitionEnd(transition: Transition) {
transitionComplete?.invoke()
}
override fun onTransitionResume(transition: Transition) {}
override fun onTransitionPause(transition: Transition) {}
override fun onTransitionCancel(transition: Transition) {}
override fun onTransitionStart(transition: Transition) {}
})
}
TransitionManager.beginDelayedTransition((parent as? ViewGroup ?: this), transition)
}
fun setInvisible(isInvisible: Boolean)
}

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

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

View file

@ -71,6 +71,7 @@ class VectorPreferences @Inject constructor(
const val SETTINGS_LABS_PREFERENCE_KEY = "SETTINGS_LABS_PREFERENCE_KEY"
const val SETTINGS_LABS_NEW_APP_LAYOUT_KEY = "SETTINGS_LABS_NEW_APP_LAYOUT_KEY"
const val SETTINGS_LABS_DEFERRED_DM_KEY = "SETTINGS_LABS_DEFERRED_DM_KEY"
const val SETTINGS_LABS_RICH_TEXT_EDITOR_KEY = "SETTINGS_LABS_RICH_TEXT_EDITOR_KEY"
const val SETTINGS_CRYPTOGRAPHY_PREFERENCE_KEY = "SETTINGS_CRYPTOGRAPHY_PREFERENCE_KEY"
const val SETTINGS_CRYPTOGRAPHY_DIVIDER_PREFERENCE_KEY = "SETTINGS_CRYPTOGRAPHY_DIVIDER_PREFERENCE_KEY"
const val SETTINGS_CRYPTOGRAPHY_MANAGE_PREFERENCE_KEY = "SETTINGS_CRYPTOGRAPHY_MANAGE_PREFERENCE_KEY"
@ -1182,4 +1183,8 @@ class VectorPreferences @Inject constructor(
fun showLiveSenderInfo(): Boolean {
return defaultPrefs.getBoolean(SETTINGS_TIMELINE_SHOW_LIVE_SENDER_INFO, getDefault(R.bool.settings_timeline_show_live_sender_info_default))
}
fun isRichTextEditorEnabled(): Boolean {
return defaultPrefs.getBoolean(SETTINGS_LABS_RICH_TEXT_EDITOR_KEY, getDefault(R.bool.settings_labs_rich_text_editor_default))
}
}

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"?>
<im.vector.app.features.home.room.detail.composer.MessageComposerView
<FrameLayout
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">
<im.vector.app.features.home.room.detail.composer.PlainTextComposerLayout
android:id="@+id/composerLayout"
android:layout_width="match_parent"
android:layout_height="wrap_content"
@ -10,4 +14,16 @@
android:minHeight="56dp"
android:transitionName="composer"
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" />
</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"
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>

View file

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