mirror of
https://github.com/element-hq/element-android
synced 2024-11-27 03:48:12 +03:00
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:
parent
2fe636e93b
commit
def67b2e7d
36 changed files with 1316 additions and 232 deletions
|
@ -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
1
changelog.d/7288.feature
Normal file
|
@ -0,0 +1 @@
|
|||
Add WYSIWYG editor.
|
10
changelog.d/7288.sdk
Normal file
10
changelog.d/7288.sdk
Normal 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.
|
|
@ -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",
|
||||
|
|
|
@ -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',
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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.
|
||||
|
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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(
|
||||
|
|
|
@ -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
|
||||
)
|
||||
|
|
|
@ -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
|
||||
)
|
||||
}
|
||||
|
|
|
@ -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 -->
|
||||
|
|
|
@ -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
|
||||
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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()
|
||||
|
|
|
@ -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()
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
}
|
|
@ -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
|
||||
}
|
||||
}
|
|
@ -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() {
|
||||
|
|
|
@ -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))
|
||||
}
|
||||
}
|
||||
|
|
10
vector/src/main/res/drawable/ic_composer_bold.xml
Normal file
10
vector/src/main/res/drawable/ic_composer_bold.xml
Normal 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>
|
10
vector/src/main/res/drawable/ic_composer_italic.xml
Normal file
10
vector/src/main/res/drawable/ic_composer_italic.xml
Normal 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>
|
12
vector/src/main/res/drawable/ic_composer_strikethrough.xml
Normal file
12
vector/src/main/res/drawable/ic_composer_strikethrough.xml
Normal 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>
|
13
vector/src/main/res/drawable/ic_composer_underlined.xml
Normal file
13
vector/src/main/res/drawable/ic_composer_underlined.xml
Normal 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>
|
156
vector/src/main/res/layout/composer_rich_text_layout.xml
Normal file
156
vector/src/main/res/layout/composer_rich_text_layout.xml
Normal 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>
|
|
@ -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>
|
|
@ -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>
|
|
@ -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>
|
||||
|
|
10
vector/src/main/res/layout/view_rich_text_menu_button.xml
Normal file
10
vector/src/main/res/layout/view_rich_text_menu_button.xml
Normal 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>
|
|
@ -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>
|
||||
|
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
Loading…
Reference in a new issue