TextComposer: continue reworking. WIP

This commit is contained in:
ganfra 2021-09-29 19:21:11 +02:00
parent 9815dfe449
commit 6b3a407b79
11 changed files with 160 additions and 120 deletions

View File

@ -18,6 +18,7 @@ package im.vector.app.core.extensions
import android.text.Editable
import android.text.InputType
import android.text.Spanned
import android.view.MotionEvent
import android.view.View
import android.view.inputmethod.EditorInfo
@ -57,3 +58,38 @@ fun EditText.setupAsSearch(@DrawableRes searchIconRes: Int = R.drawable.ic_searc
return@OnTouchListener false
})
}
fun EditText.setTextIfDifferent(newText: CharSequence?): Boolean {
if (!isTextDifferent(newText, text)) {
// Previous text is the same. No op
return false
}
setText(newText)
// Since the text changed we move the cursor to the end of the new text.
// This allows us to fill in text programmatically with a different value,
// but if the user is typing and the view is rebound we won't lose their cursor position.
setSelection(newText?.length ?: 0)
return true
}
private fun isTextDifferent(str1: CharSequence?, str2: CharSequence?): Boolean {
if (str1 === str2) {
return false
}
if (str1 == null || str2 == null) {
return true
}
val length = str1.length
if (length != str2.length) {
return true
}
if (str1 is Spanned) {
return str1 != str2
}
for (i in 0 until length) {
if (str1[i] != str2[i]) {
return true
}
}
return false
}

View File

@ -133,6 +133,7 @@ import im.vector.app.features.command.Command
import im.vector.app.features.crypto.keysbackup.restore.KeysBackupRestoreActivity
import im.vector.app.features.crypto.verification.VerificationBottomSheet
import im.vector.app.features.home.AvatarRenderer
import im.vector.app.features.home.room.detail.composer.SendMode
import im.vector.app.features.home.room.detail.composer.TextComposerAction
import im.vector.app.features.home.room.detail.composer.TextComposerView
import im.vector.app.features.home.room.detail.composer.TextComposerViewEvents
@ -191,7 +192,6 @@ import nl.dionsegijn.konfetti.models.Shape
import nl.dionsegijn.konfetti.models.Size
import org.billcarsonfr.jsonviewer.JSonViewerDialog
import org.commonmark.parser.Parser
import org.matrix.android.sdk.api.extensions.orFalse
import org.matrix.android.sdk.api.session.Session
import org.matrix.android.sdk.api.session.content.ContentAttachmentData
import org.matrix.android.sdk.api.session.events.model.toModel
@ -381,11 +381,11 @@ class RoomDetailFragment @Inject constructor(
invalidateOptionsMenu()
}
roomDetailViewModel.selectSubscribe(RoomDetailViewState::canShowJumpToReadMarker, RoomDetailViewState::unreadState) { _, _ ->
roomDetailViewModel.selectSubscribe(this, RoomDetailViewState::canShowJumpToReadMarker, RoomDetailViewState::unreadState) { _, _ ->
updateJumpToReadMarkerViewVisibility()
}
textComposerViewModel.selectSubscribe(TextComposerViewState::sendMode, TextComposerViewState::canSendMessage) { mode, canSend ->
textComposerViewModel.selectSubscribe(this, TextComposerViewState::sendMode, TextComposerViewState::canSendMessage) { mode, canSend ->
if (!canSend) {
return@selectSubscribe
}
@ -397,8 +397,8 @@ class RoomDetailFragment @Inject constructor(
}
}
roomDetailViewModel.selectSubscribe(
this,
RoomDetailViewState::syncState,
RoomDetailViewState::incrementalSyncStatus,
RoomDetailViewState::pushCounter
@ -412,11 +412,12 @@ class RoomDetailFragment @Inject constructor(
}
textComposerViewModel.observeViewEvents {
when(it){
is TextComposerViewEvents.JoinRoomCommandSuccess -> handleJoinedToAnotherRoom(it)
is TextComposerViewEvents.SendMessageResult -> renderSendMessageResult(it)
is TextComposerViewEvents.ShowMessage -> showSnackWithMessage(it.message)
is TextComposerViewEvents.ShowRoomUpgradeDialog -> handleShowRoomUpgradeDialog(it)
when (it) {
is TextComposerViewEvents.JoinRoomCommandSuccess -> handleJoinedToAnotherRoom(it)
is TextComposerViewEvents.SendMessageResult -> renderSendMessageResult(it)
is TextComposerViewEvents.ShowMessage -> showSnackWithMessage(it.message)
is TextComposerViewEvents.ShowRoomUpgradeDialog -> handleShowRoomUpgradeDialog(it)
is TextComposerViewEvents.OnSendButtonVisibilityChanged -> handleOnSendButtonVisibilityChanged(it)
}.exhaustive
}
@ -467,6 +468,21 @@ class RoomDetailFragment @Inject constructor(
}
}
private fun handleOnSendButtonVisibilityChanged(event: TextComposerViewEvents.OnSendButtonVisibilityChanged) {
Timber.v("Handle on SendButtonVisibility: $event")
if (event.isVisible) {
views.voiceMessageRecorderView.isVisible = false
views.composerLayout.views.sendButton.alpha = 0f
views.composerLayout.views.sendButton.isVisible = true
views.composerLayout.views.sendButton.animate().alpha(1f).setDuration(150).start()
} else {
views.composerLayout.views.sendButton.isInvisible = true
views.voiceMessageRecorderView.alpha = 0f
views.voiceMessageRecorderView.isVisible = true
views.voiceMessageRecorderView.animate().alpha(1f).setDuration(150).start()
}
}
private fun setupRemoveJitsiWidgetView() {
views.removeJitsiWidgetView.onCompleteSliding = {
withState(roomDetailViewModel) {
@ -669,8 +685,8 @@ class RoomDetailFragment @Inject constructor(
views.voiceMessageRecorderView.callback = object : VoiceMessageRecorderView.Callback {
override fun onVoiceRecordingStarted(): Boolean {
return if (checkPermissions(PERMISSIONS_FOR_VOICE_MESSAGE, requireActivity(), permissionVoiceMessageLauncher)) {
views.composerLayout.isInvisible = true
roomDetailViewModel.handle(RoomDetailAction.StartRecordingVoiceMessage)
textComposerViewModel.handle(TextComposerAction.OnVoiceRecordingStateChanged(true))
vibrate(requireContext())
true
} else {
@ -680,8 +696,8 @@ class RoomDetailFragment @Inject constructor(
}
override fun onVoiceRecordingEnded(isCancelled: Boolean) {
views.composerLayout.isInvisible = false
roomDetailViewModel.handle(RoomDetailAction.EndRecordingVoiceMessage(isCancelled))
textComposerViewModel.handle(TextComposerAction.OnVoiceRecordingStateChanged(false))
}
override fun onVoiceRecordingPlaybackModeOn() {
@ -767,7 +783,7 @@ class RoomDetailFragment @Inject constructor(
}
private fun handleJoinedToAnotherRoom(action: TextComposerViewEvents.JoinRoomCommandSuccess) {
updateComposerText("")
views.composerLayout.setTextIfDifferent("")
lockSendButton = false
navigator.openRoom(vectorBaseActivity, action.roomId)
}
@ -993,8 +1009,7 @@ class RoomDetailFragment @Inject constructor(
private fun renderRegularMode(text: String) {
autoCompleter.exitSpecialMode()
views.composerLayout.collapse()
views.voiceMessageRecorderView.isVisible = text.isBlank()
updateComposerText(text)
views.composerLayout.setTextIfDifferent(text)
views.composerLayout.views.sendButton.contentDescription = getString(R.string.send)
}
@ -1033,7 +1048,7 @@ class RoomDetailFragment @Inject constructor(
false
}
updateComposerText(defaultContent)
views.composerLayout.setTextIfDifferent(defaultContent)
views.composerLayout.views.composerRelatedMessageActionIcon.setImageDrawable(ContextCompat.getDrawable(requireContext(), iconRes))
views.composerLayout.views.sendButton.contentDescription = getString(descriptionRes)
@ -1045,21 +1060,11 @@ class RoomDetailFragment @Inject constructor(
// need to do it here also when not using quick reply
focusComposerAndShowKeyboard()
views.composerLayout.views.composerRelatedMessageImage.isVisible = isImageVisible
views.voiceMessageRecorderView.isVisible = false
}
}
focusComposerAndShowKeyboard()
}
private fun updateComposerText(text: String) {
// Do not update if this is the same text to avoid the cursor to move
if (text != views.composerLayout.text.toString()) {
// Ignore update to avoid saving a draft
views.composerLayout.views.composerEditText.setText(text)
views.composerLayout.views.composerEditText.setSelection(views.composerLayout.text?.length ?: 0)
}
}
override fun onResume() {
super.onResume()
notificationDrawerManager.setCurrentRoom(roomDetailArgs.roomId)
@ -1321,17 +1326,9 @@ class RoomDetailFragment @Inject constructor(
return sendUri(contentUri)
}
override fun onTextBlankStateChanged(isBlank: Boolean) {
if (!views.composerLayout.views.sendButton.isVisible) {
// Animate alpha to prevent overlapping with the animation of the send button
views.voiceMessageRecorderView.alpha = 0f
views.voiceMessageRecorderView.isVisible = true
views.voiceMessageRecorderView.animate().alpha(1f).setDuration(300).start()
} else {
views.voiceMessageRecorderView.isVisible = false
}
override fun onTextChanged(text: CharSequence) {
textComposerViewModel.handle(TextComposerAction.OnTextChanged(text))
}
}
}
@ -1393,16 +1390,14 @@ class RoomDetailFragment @Inject constructor(
timelineEventController.update(mainState)
lazyLoadedViews.inviteView(false)?.isVisible = false
if (mainState.tombstoneEvent == null) {
views.composerLayout.isInvisible = !textComposerState.isComposerVisible
views.voiceMessageRecorderView.isVisible = !textComposerState.isSendButtonVisible
views.composerLayout.views.sendButton.isInvisible = !textComposerState.isSendButtonVisible
views.composerLayout.setRoomEncrypted(summary.isEncrypted)
//views.composerLayout.alwaysShowSendButton = false
if (textComposerState.canSendMessage) {
if (!views.voiceMessageRecorderView.isActive()) {
views.composerLayout.isVisible = true
views.voiceMessageRecorderView.isVisible = views.composerLayout.text?.isBlank().orFalse()
views.composerLayout.setRoomEncrypted(summary.isEncrypted)
views.notificationAreaView.render(NotificationAreaView.State.Hidden)
views.composerLayout.alwaysShowSendButton = false
}
views.notificationAreaView.render(NotificationAreaView.State.Hidden)
} else {
views.hideComposerViews()
views.notificationAreaView.render(NotificationAreaView.State.NoPermissionToPost)
}
} else {
@ -1468,7 +1463,7 @@ class RoomDetailFragment @Inject constructor(
displayCommandError(getString(R.string.unrecognized_command, sendMessageResult.command))
}
is TextComposerViewEvents.SlashCommandResultOk -> {
updateComposerText("")
views.composerLayout.setTextIfDifferent("")
}
is TextComposerViewEvents.SlashCommandResultError -> {
displayCommandError(errorFormatter.toHumanReadable(sendMessageResult.throwable))

View File

@ -30,26 +30,6 @@ import org.matrix.android.sdk.api.session.sync.SyncState
import org.matrix.android.sdk.api.session.widgets.model.Widget
import org.matrix.android.sdk.api.session.widgets.model.WidgetType
/**
* Describes the current send mode:
* REGULAR: sends the text as a regular message
* QUOTE: User is currently quoting a message
* EDIT: User is currently editing an existing message
*
* Depending on the state the bottom toolbar will change (icons/preview/actions...)
*/
sealed class SendMode(open val text: String) {
data class REGULAR(
override val text: String,
val fromSharing: Boolean,
// This is necessary for forcing refresh on selectSubscribe
private val ts: Long = System.currentTimeMillis()
) : SendMode(text)
data class QUOTE(val timelineEvent: TimelineEvent, override val text: String) : SendMode(text)
data class EDIT(val timelineEvent: TimelineEvent, override val text: String) : SendMode(text)
data class REPLY(val timelineEvent: TimelineEvent, override val text: String) : SendMode(text)
}
sealed class UnreadState {
object Unknown : UnreadState()
@ -74,7 +54,6 @@ data class RoomDetailViewState(
val asyncRoomSummary: Async<RoomSummary> = Uninitialized,
val activeRoomWidgets: Async<List<Widget>> = Uninitialized,
val formattedTypingUsers: String? = null,
val sendMode: SendMode = SendMode.REGULAR("", false),
val tombstoneEvent: Event? = null,
val joinUpgradedRoomAsync: Async<String> = Uninitialized,
val syncState: SyncState = SyncState.Idle,

View File

@ -37,11 +37,10 @@ class ComposerEditText @JvmOverloads constructor(context: Context, attrs: Attrib
interface Callback {
fun onRichContentSelected(contentUri: Uri): Boolean
fun onTextBlankStateChanged(isBlank: Boolean)
fun onTextChanged(text: CharSequence)
}
var callback: Callback? = null
private var isBlankText = true
override fun onCreateInputConnection(editorInfo: EditorInfo): InputConnection? {
val ic = super.onCreateInputConnection(editorInfo) ?: return null
@ -95,11 +94,7 @@ class ComposerEditText @JvmOverloads constructor(context: Context, attrs: Attrib
}
spanToRemove = null
}
// Report blank status of EditText to be able to arrange other elements of the composer
if (s.isBlank() != isBlankText) {
isBlankText = !isBlankText
callback?.onTextBlankStateChanged(isBlankText)
}
callback?.onTextChanged(s.toString())
}
}
)

View File

@ -27,4 +27,6 @@ sealed class TextComposerAction : VectorViewModelAction {
data class EnterReplyMode(val eventId: String, val text: String) : TextComposerAction()
data class EnterRegularMode(val text: String, val fromSharing: Boolean) : TextComposerAction()
data class UserIsTyping(val isTyping: Boolean) : TextComposerAction()
data class OnTextChanged(val text: CharSequence) : TextComposerAction()
data class OnVoiceRecordingStateChanged(val isRecording: Boolean) : TextComposerAction()
}

View File

@ -25,16 +25,14 @@ 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.AutoTransition
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 org.matrix.android.sdk.api.extensions.orFalse
/**
* Encapsulate the timeline composer UX.
@ -61,13 +59,6 @@ class TextComposerView @JvmOverloads constructor(
val text: Editable?
get() = views.composerEditText.text
var alwaysShowSendButton = false
set(value) {
field = value
val shouldShowSendButton = currentConstraintSetId == R.layout.composer_layout_constraint_set_expanded || text?.isNotBlank().orFalse() || value
views.sendButton.isInvisible = !shouldShowSendButton
}
init {
inflate(context, R.layout.composer_layout, this)
views = ComposerLayoutBinding.bind(this)
@ -79,17 +70,8 @@ class TextComposerView @JvmOverloads constructor(
return callback?.onRichContentSelected(contentUri) ?: false
}
override fun onTextBlankStateChanged(isBlank: Boolean) {
val shouldShowSendButton = currentConstraintSetId == R.layout.composer_layout_constraint_set_expanded || !isBlank || alwaysShowSendButton
TransitionManager.endTransitions(this@TextComposerView)
if (views.sendButton.isVisible != shouldShowSendButton) {
TransitionManager.beginDelayedTransition(
this@TextComposerView,
AutoTransition().also { it.duration = 150 }
)
views.sendButton.isInvisible = !shouldShowSendButton
}
callback?.onTextBlankStateChanged(isBlank)
override fun onTextChanged(text: CharSequence) {
callback?.onTextChanged(text)
}
}
views.composerRelatedMessageCloseButton.setOnClickListener {
@ -114,9 +96,6 @@ class TextComposerView @JvmOverloads constructor(
}
currentConstraintSetId = R.layout.composer_layout_constraint_set_compact
applyNewConstraintSet(animate, transitionComplete)
val shouldShowSendButton = !views.composerEditText.text.isNullOrEmpty() || alwaysShowSendButton
views.sendButton.isInvisible = !shouldShowSendButton
}
fun expand(animate: Boolean = true, transitionComplete: (() -> Unit)? = null) {
@ -126,10 +105,14 @@ class TextComposerView @JvmOverloads constructor(
}
currentConstraintSetId = R.layout.composer_layout_constraint_set_expanded
applyNewConstraintSet(animate, transitionComplete)
views.sendButton.isInvisible = false
}
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)
}
@ -137,6 +120,8 @@ class TextComposerView @JvmOverloads constructor(
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) {

View File

@ -22,6 +22,8 @@ import im.vector.app.features.command.Command
sealed class TextComposerViewEvents : VectorViewEvents {
data class OnSendButtonVisibilityChanged(val isVisible: Boolean): TextComposerViewEvents()
data class ShowMessage(val message: String) : TextComposerViewEvents()
abstract class SendMessageResult : TextComposerViewEvents()

View File

@ -17,29 +17,20 @@
package im.vector.app.features.home.room.detail.composer
import androidx.lifecycle.viewModelScope
import com.airbnb.mvrx.Async
import com.airbnb.mvrx.Fail
import com.airbnb.mvrx.FragmentViewModelContext
import com.airbnb.mvrx.Loading
import com.airbnb.mvrx.MvRxViewModelFactory
import com.airbnb.mvrx.Success
import com.airbnb.mvrx.Uninitialized
import com.airbnb.mvrx.ViewModelContext
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
import im.vector.app.BuildConfig
import im.vector.app.R
import im.vector.app.core.extensions.exhaustive
import im.vector.app.core.platform.VectorViewModel
import im.vector.app.core.resources.StringProvider
import im.vector.app.features.command.CommandParser
import im.vector.app.features.command.ParsedCommand
import im.vector.app.features.crypto.keysrequest.OutboundSessionKeySharingStrategy
import im.vector.app.features.home.room.detail.ChatEffect
import im.vector.app.features.home.room.detail.RoomDetailAction
import im.vector.app.features.home.room.detail.RoomDetailFragment
import im.vector.app.features.home.room.detail.SendMode
import im.vector.app.features.home.room.detail.composer.rainbow.RainbowGenerator
import im.vector.app.features.home.room.detail.toMessageType
import im.vector.app.features.powerlevel.PowerLevelsObservableFactory
@ -74,24 +65,57 @@ class TextComposerViewModel @AssistedInject constructor(
private val room = session.getRoom(initialState.roomId)!!
// Keep it out of state to avoid invalidate being called
private var currentComposerText: CharSequence = ""
init {
loadDraftIfAny()
observePowerLevel()
subscribeToStateInternal()
}
override fun handle(action: TextComposerAction) {
Timber.v("Handle action: $action")
when (action) {
is TextComposerAction.EnterEditMode -> handleEnterEditMode(action)
is TextComposerAction.EnterQuoteMode -> handleEnterQuoteMode(action)
is TextComposerAction.EnterRegularMode -> handleEnterRegularMode(action)
is TextComposerAction.EnterReplyMode -> handleEnterReplyMode(action)
is TextComposerAction.SaveDraft -> handleSaveDraft(action)
is TextComposerAction.SendMessage -> handleSendMessage(action)
is TextComposerAction.UserIsTyping -> handleUserIsTyping(action)
is TextComposerAction.EnterEditMode -> handleEnterEditMode(action)
is TextComposerAction.EnterQuoteMode -> handleEnterQuoteMode(action)
is TextComposerAction.EnterRegularMode -> handleEnterRegularMode(action)
is TextComposerAction.EnterReplyMode -> handleEnterReplyMode(action)
is TextComposerAction.SaveDraft -> handleSaveDraft(action)
is TextComposerAction.SendMessage -> handleSendMessage(action)
is TextComposerAction.UserIsTyping -> handleUserIsTyping(action)
is TextComposerAction.OnTextChanged -> handleOnTextChanged(action)
is TextComposerAction.OnVoiceRecordingStateChanged -> handleOnVoiceRecordingStateChanged(action)
}
}
private fun handleOnVoiceRecordingStateChanged(action: TextComposerAction.OnVoiceRecordingStateChanged) = setState {
copy(isVoiceRecording = action.isRecording)
}
private fun handleOnTextChanged(action: TextComposerAction.OnTextChanged) {
setState {
// Makes sure currentComposerText is upToDate when accessing further setState
currentComposerText = action.text
this
}
updateIsSendButtonVisibility()
}
private fun subscribeToStateInternal() {
selectSubscribe(TextComposerViewState::sendMode, TextComposerViewState::canSendMessage, TextComposerViewState::isVoiceRecording) { _, _, _ ->
updateIsSendButtonVisibility()
}
}
private fun updateIsSendButtonVisibility() = setState {
val isSendButtonVisible = isComposerVisible && (sendMode !is SendMode.REGULAR || currentComposerText.isNotBlank())
if (this.isSendButtonVisible != isSendButtonVisible) {
_viewEvents.post(TextComposerViewEvents.OnSendButtonVisibilityChanged(isSendButtonVisible))
}
copy(isSendButtonVisible = isSendButtonVisible)
}
private fun handleEnterRegularMode(action: TextComposerAction.EnterRegularMode) = setState {
copy(sendMode = SendMode.REGULAR(action.text, action.fromSharing))
}
@ -442,7 +466,6 @@ class TextComposerViewModel @AssistedInject constructor(
}
}
private fun sendChatEffect(sendChatEffect: ParsedCommand.SendChatEffect) {
// If message is blank, convert to an emote, with default message
if (sendChatEffect.message.isBlank()) {
@ -573,7 +596,6 @@ class TextComposerViewModel @AssistedInject constructor(
}
}
private fun launchSlashCommandFlowSuspendable(block: suspend () -> Unit) {
_viewEvents.post(TextComposerViewEvents.SlashCommandHandled())
viewModelScope.launch {

View File

@ -18,16 +18,40 @@ package im.vector.app.features.home.room.detail.composer
import com.airbnb.mvrx.MvRxState
import im.vector.app.features.home.room.detail.RoomDetailArgs
import im.vector.app.features.home.room.detail.SendMode
import org.matrix.android.sdk.api.session.room.timeline.TimelineEvent
/**
* Describes the current send mode:
* REGULAR: sends the text as a regular message
* QUOTE: User is currently quoting a message
* EDIT: User is currently editing an existing message
*
* Depending on the state the bottom toolbar will change (icons/preview/actions...)
*/
sealed class SendMode(open val text: String) {
data class REGULAR(
override val text: String,
val fromSharing: Boolean,
// This is necessary for forcing refresh on selectSubscribe
private val ts: Long = System.currentTimeMillis()
) : SendMode(text)
data class QUOTE(val timelineEvent: TimelineEvent, override val text: String) : SendMode(text)
data class EDIT(val timelineEvent: TimelineEvent, override val text: String) : SendMode(text)
data class REPLY(val timelineEvent: TimelineEvent, override val text: String) : SendMode(text)
}
data class TextComposerViewState(
val roomId: String,
val canSendMessage: Boolean = true,
val isSendButtonVisible: Boolean = false,
val isRecordingVoiceMessage: Boolean = false,
val isVoiceRecording: Boolean = false,
val isSendButtonVisible : Boolean = false,
val sendMode: SendMode = SendMode.REGULAR("", false),
) : MvRxState {
val isComposerVisible: Boolean
get() = canSendMessage && !isVoiceRecording
constructor(args: RoomDetailArgs) : this(
roomId = args.roomId,
)

View File

@ -172,7 +172,7 @@
android:contentDescription="@string/send"
android:scaleType="center"
android:src="@drawable/ic_send"
android:visibility="gone"
android:visibility="invisible"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
tools:ignore="MissingPrefix"

View File

@ -184,7 +184,7 @@
android:contentDescription="@string/send"
android:scaleType="center"
android:src="@drawable/ic_send"
android:visibility="gone"
android:visibility="invisible"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toBottomOf="@id/composer_preview_barrier"