Composer: Edit and reply.

TODO: call renderComposerMode and highlight selected item.
This commit is contained in:
Benoit Marty 2022-11-29 16:52:25 +01:00 committed by Benoit Marty
parent d2ab0872ec
commit 232cabcb27
13 changed files with 152 additions and 14 deletions

View file

@ -40,6 +40,7 @@ import io.element.android.x.features.messages.model.*
import io.element.android.x.features.messages.model.content.* import io.element.android.x.features.messages.model.content.*
import io.element.android.x.features.messages.textcomposer.MessageComposerViewModel import io.element.android.x.features.messages.textcomposer.MessageComposerViewModel
import io.element.android.x.features.messages.textcomposer.MessageComposerViewState import io.element.android.x.features.messages.textcomposer.MessageComposerViewState
import io.element.android.x.textcomposer.MessageComposerMode
import io.element.android.x.textcomposer.TextComposer import io.element.android.x.textcomposer.TextComposer
import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@ -71,6 +72,7 @@ fun MessagesScreen(
val timelineItems by viewModel.collectAsState(MessagesViewState::timelineItems) val timelineItems by viewModel.collectAsState(MessagesViewState::timelineItems)
val hasMoreToLoad by viewModel.collectAsState(MessagesViewState::hasMoreToLoad) val hasMoreToLoad by viewModel.collectAsState(MessagesViewState::hasMoreToLoad)
val snackBarContent by viewModel.collectAsState(MessagesViewState::snackbarContent) val snackBarContent by viewModel.collectAsState(MessagesViewState::snackbarContent)
val composerMode by viewModel.collectAsState(MessagesViewState::composerMode)
val composerFullScreen by composerViewModel.collectAsState(MessageComposerViewState::isFullScreen) val composerFullScreen by composerViewModel.collectAsState(MessageComposerViewState::isFullScreen)
val composerCanSendMessage by composerViewModel.collectAsState(MessageComposerViewState::isSendButtonVisible) val composerCanSendMessage by composerViewModel.collectAsState(MessageComposerViewState::isSendButtonVisible)
val composerText by composerViewModel.collectAsState(MessageComposerViewState::text) val composerText by composerViewModel.collectAsState(MessageComposerViewState::text)
@ -86,6 +88,8 @@ fun MessagesScreen(
composerFullScreen = composerFullScreen, composerFullScreen = composerFullScreen,
onComposerFullScreenChange = composerViewModel::onComposerFullScreenChange, onComposerFullScreenChange = composerViewModel::onComposerFullScreenChange,
onComposerTextChange = composerViewModel::updateText, onComposerTextChange = composerViewModel::updateText,
composerMode = composerMode,
onCloseSpecialMode = viewModel::setNormalMode,
composerCanSendMessage = composerCanSendMessage, composerCanSendMessage = composerCanSendMessage,
composerText = composerText, composerText = composerText,
onClick = { onClick = {
@ -106,6 +110,16 @@ fun MessagesScreen(
onActionClicked = { onActionClicked = {
viewModel.handleItemAction(it) viewModel.handleItemAction(it)
coroutineScope.launch { coroutineScope.launch {
val targetEvent = viewModel.getTargetEvent()
when (it) {
is MessagesItemAction.Edit -> {
// Entering Edit mode, update the text in the composer.
val newComposerText =
(targetEvent?.content as? MessagesTimelineItemTextBasedContent)?.body.orEmpty()
composerViewModel.updateText(newComposerText)
}
else -> Unit
}
actionsSheetState.hide() actionsSheetState.hide()
} }
} }
@ -132,6 +146,8 @@ fun MessagesScreenContent(
composerFullScreen: Boolean, composerFullScreen: Boolean,
onComposerFullScreenChange: () -> Unit, onComposerFullScreenChange: () -> Unit,
onComposerTextChange: (CharSequence) -> Unit, onComposerTextChange: (CharSequence) -> Unit,
composerMode: MessageComposerMode,
onCloseSpecialMode: () -> Unit,
composerCanSendMessage: Boolean, composerCanSendMessage: Boolean,
composerText: StableCharSequence?, composerText: StableCharSequence?,
snackbarHostState: SnackbarHostState, snackbarHostState: SnackbarHostState,
@ -154,6 +170,8 @@ fun MessagesScreenContent(
onSendMessage = onSendMessage, onSendMessage = onSendMessage,
onClick = onClick, onClick = onClick,
onLongClick = onLongClick, onLongClick = onLongClick,
composerMode = composerMode,
onCloseSpecialMode = onCloseSpecialMode,
composerFullScreen = composerFullScreen, composerFullScreen = composerFullScreen,
onComposerFullScreenChange = onComposerFullScreenChange, onComposerFullScreenChange = onComposerFullScreenChange,
onComposerTextChange = onComposerTextChange, onComposerTextChange = onComposerTextChange,
@ -173,6 +191,8 @@ fun MessagesContent(
onSendMessage: (String) -> Unit, onSendMessage: (String) -> Unit,
onClick: (MessagesTimelineItemState.MessageEvent) -> Unit, onClick: (MessagesTimelineItemState.MessageEvent) -> Unit,
onLongClick: (MessagesTimelineItemState.MessageEvent) -> Unit, onLongClick: (MessagesTimelineItemState.MessageEvent) -> Unit,
composerMode: MessageComposerMode,
onCloseSpecialMode: () -> Unit,
composerFullScreen: Boolean, composerFullScreen: Boolean,
onComposerFullScreenChange: () -> Unit, onComposerFullScreenChange: () -> Unit,
onComposerTextChange: (CharSequence) -> Unit, onComposerTextChange: (CharSequence) -> Unit,
@ -201,6 +221,8 @@ fun MessagesContent(
onSendMessage = onSendMessage, onSendMessage = onSendMessage,
fullscreen = composerFullScreen, fullscreen = composerFullScreen,
onFullscreenToggle = onComposerFullScreenChange, onFullscreenToggle = onComposerFullScreenChange,
composerMode = composerMode,
onCloseSpecialMode = onCloseSpecialMode,
onComposerTextChange = onComposerTextChange, onComposerTextChange = onComposerTextChange,
composerCanSendMessage = composerCanSendMessage, composerCanSendMessage = composerCanSendMessage,
composerText = composerText?.charSequence?.toString(), composerText = composerText?.charSequence?.toString(),

View file

@ -10,11 +10,13 @@ import io.element.android.x.features.messages.model.MessagesItemActionsSheetStat
import io.element.android.x.features.messages.model.MessagesTimelineItemState import io.element.android.x.features.messages.model.MessagesTimelineItemState
import io.element.android.x.features.messages.model.MessagesViewState import io.element.android.x.features.messages.model.MessagesViewState
import io.element.android.x.features.messages.model.content.MessagesTimelineItemRedactedContent import io.element.android.x.features.messages.model.content.MessagesTimelineItemRedactedContent
import io.element.android.x.features.messages.model.content.MessagesTimelineItemTextBasedContent
import io.element.android.x.matrix.MatrixClient import io.element.android.x.matrix.MatrixClient
import io.element.android.x.matrix.MatrixInstance import io.element.android.x.matrix.MatrixInstance
import io.element.android.x.matrix.media.MediaResolver import io.element.android.x.matrix.media.MediaResolver
import io.element.android.x.matrix.room.MatrixRoom import io.element.android.x.matrix.room.MatrixRoom
import io.element.android.x.matrix.timeline.MatrixTimeline import io.element.android.x.matrix.timeline.MatrixTimeline
import io.element.android.x.textcomposer.MessageComposerMode
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
@ -66,25 +68,72 @@ class MessagesViewModel(
} }
fun sendMessage(text: String) { fun sendMessage(text: String) {
viewModelScope.launch { withState { state ->
timeline.sendMessage(text) viewModelScope.launch {
when (state.composerMode) {
is MessageComposerMode.Normal -> timeline.sendMessage(text)
is MessageComposerMode.Edit -> timeline.editMessage(
state.composerMode.eventId,
text
)
is MessageComposerMode.Quote -> TODO()
is MessageComposerMode.Reply -> timeline.replyMessage(
state.composerMode.eventId,
text
)
}
// Reset composer
setNormalMode()
}
} }
} }
suspend fun getTargetEvent(): MessagesTimelineItemState.MessageEvent? {
val currentState = awaitState()
return currentState.itemActionsSheetState.invoke()?.targetItem
}
fun handleItemAction(action: MessagesItemAction) { fun handleItemAction(action: MessagesItemAction) {
viewModelScope.launch(Dispatchers.Default) { viewModelScope.launch(Dispatchers.Default) {
val currentState = awaitState() val currentState = awaitState()
Timber.v("Handle $action for ${currentState.itemActionsSheetState}") Timber.v("Handle $action for ${currentState.itemActionsSheetState}")
val targetEvent = val targetEvent = getTargetEvent()
currentState.itemActionsSheetState.invoke()?.targetItem ?: return@launch ?: return@launch
when (action) { when (action) {
MessagesItemAction.Copy -> notImplementedYet() MessagesItemAction.Copy -> notImplementedYet()
MessagesItemAction.Forward -> notImplementedYet() MessagesItemAction.Forward -> notImplementedYet()
MessagesItemAction.Redact -> handleActionRedact(targetEvent) MessagesItemAction.Redact -> handleActionRedact(targetEvent)
MessagesItemAction.Edit -> handleActionEdit(targetEvent)
MessagesItemAction.Reply -> handleActionReply(targetEvent)
} }
} }
} }
fun setNormalMode() {
setComposerMode(MessageComposerMode.Normal(""))
}
private fun handleActionEdit(targetEvent: MessagesTimelineItemState.MessageEvent) {
setComposerMode(
MessageComposerMode.Edit(
targetEvent.id,
(targetEvent.content as? MessagesTimelineItemTextBasedContent)?.body.orEmpty()
)
)
}
private fun handleActionReply(targetEvent: MessagesTimelineItemState.MessageEvent) {
setComposerMode(MessageComposerMode.Reply(targetEvent.safeSenderName, targetEvent.id, ""))
}
private fun setComposerMode(mode: MessageComposerMode) {
setState {
copy(
composerMode = mode
)
}
}
private fun notImplementedYet() { private fun notImplementedYet() {
setSnackbarContent("Not implemented yet!") setSnackbarContent("Not implemented yet!")
} }
@ -110,10 +159,12 @@ class MessagesViewModel(
emptyList() emptyList()
} else { } else {
mutableListOf( mutableListOf(
MessagesItemAction.Reply,
MessagesItemAction.Forward, MessagesItemAction.Forward,
MessagesItemAction.Copy, MessagesItemAction.Copy,
).also { ).also {
if (messagesTimelineItemState.isMine) { if (messagesTimelineItemState.isMine) {
it.add(MessagesItemAction.Edit)
it.add(MessagesItemAction.Redact) it.add(MessagesItemAction.Redact)
} }
} }

View file

@ -13,4 +13,6 @@ sealed class MessagesItemAction(
object Forward : MessagesItemAction("Forward", VectorIcons.ArrowForward) object Forward : MessagesItemAction("Forward", VectorIcons.ArrowForward)
object Copy : MessagesItemAction("Copy", VectorIcons.Copy) object Copy : MessagesItemAction("Copy", VectorIcons.Copy)
object Redact : MessagesItemAction("Redact", VectorIcons.Delete, destructive = true) object Redact : MessagesItemAction("Redact", VectorIcons.Delete, destructive = true)
object Reply : MessagesItemAction("Reply", VectorIcons.Reply)
object Edit : MessagesItemAction("Edit", VectorIcons.Edit)
} }

View file

@ -4,6 +4,7 @@ import com.airbnb.mvrx.Async
import com.airbnb.mvrx.MavericksState import com.airbnb.mvrx.MavericksState
import com.airbnb.mvrx.Uninitialized import com.airbnb.mvrx.Uninitialized
import io.element.android.x.designsystem.components.avatar.AvatarData import io.element.android.x.designsystem.components.avatar.AvatarData
import io.element.android.x.textcomposer.MessageComposerMode
data class MessagesViewState( data class MessagesViewState(
val roomId: String, val roomId: String,
@ -13,6 +14,8 @@ data class MessagesViewState(
val hasMoreToLoad: Boolean = true, val hasMoreToLoad: Boolean = true,
val itemActionsSheetState: Async<MessagesItemActionsSheetState> = Uninitialized, val itemActionsSheetState: Async<MessagesItemActionsSheetState> = Uninitialized,
val snackbarContent: String? = null, val snackbarContent: String? = null,
// TODO Highlight item in reply / edit in the timeline
val composerMode: MessageComposerMode = MessageComposerMode.Normal(""),
) : MavericksState { ) : MavericksState {
@Suppress("unused") @Suppress("unused")

View file

@ -6,4 +6,6 @@ object VectorIcons {
val Copy = R.drawable.ic_content_copy val Copy = R.drawable.ic_content_copy
val ArrowForward = R.drawable.ic_content_arrow_forward val ArrowForward = R.drawable.ic_content_arrow_forward
val Delete = R.drawable.ic_baseline_delete_outline_24 val Delete = R.drawable.ic_baseline_delete_outline_24
val Reply = R.drawable.ic_baseline_reply_24
val Edit = R.drawable.ic_baseline_edit_24
} }

View file

@ -0,0 +1,5 @@
<vector android:height="24dp" android:tint="#000000"
android:viewportHeight="24" android:viewportWidth="24"
android:width="24dp" xmlns:android="http://schemas.android.com/apk/res/android">
<path android:fillColor="@android:color/white" android:pathData="M3,17.25V21h3.75L17.81,9.94l-3.75,-3.75L3,17.25zM20.71,7.04c0.39,-0.39 0.39,-1.02 0,-1.41l-2.34,-2.34c-0.39,-0.39 -1.02,-0.39 -1.41,0l-1.83,1.83 3.75,3.75 1.83,-1.83z"/>
</vector>

View file

@ -0,0 +1,5 @@
<vector android:autoMirrored="true" android:height="24dp"
android:tint="#000000" android:viewportHeight="24"
android:viewportWidth="24" android:width="24dp" xmlns:android="http://schemas.android.com/apk/res/android">
<path android:fillColor="@android:color/white" android:pathData="M10,9V5l-7,7 7,7v-4.1c5,0 8.5,1.6 11,5.1 -1,-5 -4,-10 -11,-11z"/>
</vector>

View file

@ -82,6 +82,22 @@ class MatrixRoom(
} }
} }
suspend fun editMessage(originalEventId: String, message: String): Result<Unit> = withContext(coroutineDispatchers.io) {
val transactionId = genTransactionId()
val content = messageEventContentFromMarkdown(message)
runCatching {
room.edit(/* TODO use content */ message, originalEventId, transactionId)
}
}
suspend fun replyMessage(eventId: String, message: String): Result<Unit> = withContext(coroutineDispatchers.io) {
val transactionId = genTransactionId()
val content = messageEventContentFromMarkdown(message)
runCatching {
room.sendReply(/* TODO use content */ message, eventId, transactionId)
}
}
suspend fun redactEvent(eventId: String, reason: String? = null, ) = withContext(coroutineDispatchers.io) { suspend fun redactEvent(eventId: String, reason: String? = null, ) = withContext(coroutineDispatchers.io) {
val transactionId = genTransactionId() val transactionId = genTransactionId()
runCatching { runCatching {

View file

@ -124,6 +124,14 @@ class MatrixTimeline(
return matrixRoom.sendMessage(message) return matrixRoom.sendMessage(message)
} }
suspend fun editMessage(originalEventId: String, message: String): Result<Unit> {
return matrixRoom.editMessage(originalEventId, message = message)
}
suspend fun replyMessage(inReplyToEventId: String, message: String): Result<Unit> {
return matrixRoom.replyMessage(inReplyToEventId, message)
}
override fun onUpdate(update: TimelineDiff) { override fun onUpdate(update: TimelineDiff) {
coroutineScope.launch { coroutineScope.launch {
updateTimelineItems { updateTimelineItems {

View file

@ -19,8 +19,18 @@ package io.element.android.x.textcomposer
sealed interface MessageComposerMode { sealed interface MessageComposerMode {
data class Normal(val content: CharSequence?) : MessageComposerMode data class Normal(val content: CharSequence?) : MessageComposerMode
sealed class Special(open val event: Any /* TODO set correct type here */, open val defaultContent: CharSequence) : MessageComposerMode sealed class Special(open val eventId: String, open val defaultContent: CharSequence) :
data class Edit(override val event: Any, override val defaultContent: CharSequence) : Special(event, defaultContent) MessageComposerMode
class Quote(override val event: Any, override val defaultContent: CharSequence) : Special(event, defaultContent)
class Reply(override val event: Any, override val defaultContent: CharSequence) : Special(event, defaultContent) data class Edit(override val eventId: String, override val defaultContent: CharSequence) :
Special(eventId, defaultContent)
class Quote(override val eventId: String, override val defaultContent: CharSequence) :
Special(eventId, defaultContent)
class Reply(
val senderName: String,
override val eventId: String,
override val defaultContent: CharSequence
) : Special(eventId, defaultContent)
} }

View file

@ -506,10 +506,7 @@ class RichTextComposerLayout @JvmOverloads constructor(
views.composerModeIconView.setImageResource(R.drawable.ic_quote) views.composerModeIconView.setImageResource(R.drawable.ic_quote)
} }
is MessageComposerMode.Reply -> { is MessageComposerMode.Reply -> {
// TODO We need sender info val userName = mode.senderName
// val senderInfo = mode.event.senderInfo
val userName =
"TODO Sender name" // senderInfo.displayName ?: senderInfo.disambiguatedDisplayName
views.composerModeTitleView.text = views.composerModeTitleView.text =
resources.getString(R.string.replying_to, userName) resources.getString(R.string.replying_to, userName)
views.composerModeIconView.setImageResource(R.drawable.ic_reply) views.composerModeIconView.setImageResource(R.drawable.ic_reply)

View file

@ -26,6 +26,8 @@ fun TextComposer(
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
fullscreen: Boolean, fullscreen: Boolean,
onFullscreenToggle: () -> Unit, onFullscreenToggle: () -> Unit,
composerMode: MessageComposerMode,
onCloseSpecialMode: () -> Unit,
onComposerTextChange: (CharSequence) -> Unit, onComposerTextChange: (CharSequence) -> Unit,
composerCanSendMessage: Boolean, composerCanSendMessage: Boolean,
composerText: String?, composerText: String?,
@ -50,6 +52,7 @@ fun TextComposer(
} }
override fun onCloseRelatedMessage() { override fun onCloseRelatedMessage() {
onCloseSpecialMode()
} }
override fun onSendMessage(text: CharSequence) { override fun onSendMessage(text: CharSequence) {
@ -70,7 +73,7 @@ fun TextComposer(
} }
setFullScreen(fullscreen, true) setFullScreen(fullscreen, true)
(this as MessageComposerView).apply { (this as MessageComposerView).apply {
setup(isInDarkMode) setup(isInDarkMode, composerMode)
} }
} }
}, },
@ -83,6 +86,7 @@ fun TextComposer(
// Example of Compose -> View communication // Example of Compose -> View communication
val messageComposerView = (view as MessageComposerView) val messageComposerView = (view as MessageComposerView)
view.setFullScreen(fullscreen, false) view.setFullScreen(fullscreen, false)
// TODO messageComposerView.renderComposerMode(composerMode)
messageComposerView.sendButton.isInvisible = !composerCanSendMessage messageComposerView.sendButton.isInvisible = !composerCanSendMessage
messageComposerView.setTextIfDifferent(composerText ?: "") messageComposerView.setTextIfDifferent(composerText ?: "")
} }
@ -107,7 +111,7 @@ private fun FakeComposer(modifier: Modifier) {
} }
} }
private fun MessageComposerView.setup(isDarkMode: Boolean) { private fun MessageComposerView.setup(isDarkMode: Boolean, composerMode: MessageComposerMode) {
val editTextColor = if (isDarkMode) { val editTextColor = if (isDarkMode) {
Color.WHITE Color.WHITE
} else { } else {
@ -118,6 +122,7 @@ private fun MessageComposerView.setup(isDarkMode: Boolean) {
editText.setHint(ElementR.string.room_message_placeholder) editText.setHint(ElementR.string.room_message_placeholder)
emojiButton?.isVisible = true emojiButton?.isVisible = true
sendButton.isVisible = true sendButton.isVisible = true
// TODO renderComposerMode(composerMode)
} }
@Preview @Preview
@ -128,6 +133,8 @@ fun TextComposerPreview() {
fullscreen = false, fullscreen = false,
onFullscreenToggle = { }, onFullscreenToggle = { },
onComposerTextChange = {}, onComposerTextChange = {},
composerMode = MessageComposerMode.Normal(""),
onCloseSpecialMode = {},
composerCanSendMessage = true, composerCanSendMessage = true,
composerText = "Message", composerText = "Message",
) )

View file

@ -8,6 +8,16 @@
android:orientation="vertical" android:orientation="vertical"
android:background="@drawable/bg_composer_rich_bottom_sheet"> android:background="@drawable/bg_composer_rich_bottom_sheet">
<!--
There are issues here:
View class androidx.appcompat.widget.AppCompatImageView is an AppCompat widget that can only be used with a Theme.AppCompat theme (or descendant).
View class io.element.android.wysiwyg.EditorEditText is an AppCompat widget that can only be used with a Theme.AppCompat theme (or descendant).
layout_constraintHeight_default="wrap" is deprecated. Use layout_height="WRAP_CONTENT" and layout_constrainedHeight="true" instead.
View class com.google.android.material.textfield.TextInputEditText is an AppCompat widget that can only be used with a Theme.AppCompat theme (or descendant).
layout_constraintHeight_default="wrap" is deprecated. Use layout_height="WRAP_CONTENT" and layout_constrainedHeight="true" instead.
-->
<FrameLayout <FrameLayout
android:id="@+id/bottomSheetHandle" android:id="@+id/bottomSheetHandle"
android:layout_width="match_parent" android:layout_width="match_parent"