diff --git a/features/messages/src/main/java/io/element/android/x/features/messages/MessagesScreen.kt b/features/messages/src/main/java/io/element/android/x/features/messages/MessagesScreen.kt index c6a1b58d65..91c758a28a 100644 --- a/features/messages/src/main/java/io/element/android/x/features/messages/MessagesScreen.kt +++ b/features/messages/src/main/java/io/element/android/x/features/messages/MessagesScreen.kt @@ -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.textcomposer.MessageComposerViewModel import io.element.android.x.features.messages.textcomposer.MessageComposerViewState +import io.element.android.x.textcomposer.MessageComposerMode import io.element.android.x.textcomposer.TextComposer import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.launch @@ -71,6 +72,7 @@ fun MessagesScreen( val timelineItems by viewModel.collectAsState(MessagesViewState::timelineItems) val hasMoreToLoad by viewModel.collectAsState(MessagesViewState::hasMoreToLoad) val snackBarContent by viewModel.collectAsState(MessagesViewState::snackbarContent) + val composerMode by viewModel.collectAsState(MessagesViewState::composerMode) val composerFullScreen by composerViewModel.collectAsState(MessageComposerViewState::isFullScreen) val composerCanSendMessage by composerViewModel.collectAsState(MessageComposerViewState::isSendButtonVisible) val composerText by composerViewModel.collectAsState(MessageComposerViewState::text) @@ -86,6 +88,8 @@ fun MessagesScreen( composerFullScreen = composerFullScreen, onComposerFullScreenChange = composerViewModel::onComposerFullScreenChange, onComposerTextChange = composerViewModel::updateText, + composerMode = composerMode, + onCloseSpecialMode = viewModel::setNormalMode, composerCanSendMessage = composerCanSendMessage, composerText = composerText, onClick = { @@ -106,6 +110,16 @@ fun MessagesScreen( onActionClicked = { viewModel.handleItemAction(it) 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() } } @@ -132,6 +146,8 @@ fun MessagesScreenContent( composerFullScreen: Boolean, onComposerFullScreenChange: () -> Unit, onComposerTextChange: (CharSequence) -> Unit, + composerMode: MessageComposerMode, + onCloseSpecialMode: () -> Unit, composerCanSendMessage: Boolean, composerText: StableCharSequence?, snackbarHostState: SnackbarHostState, @@ -154,6 +170,8 @@ fun MessagesScreenContent( onSendMessage = onSendMessage, onClick = onClick, onLongClick = onLongClick, + composerMode = composerMode, + onCloseSpecialMode = onCloseSpecialMode, composerFullScreen = composerFullScreen, onComposerFullScreenChange = onComposerFullScreenChange, onComposerTextChange = onComposerTextChange, @@ -173,6 +191,8 @@ fun MessagesContent( onSendMessage: (String) -> Unit, onClick: (MessagesTimelineItemState.MessageEvent) -> Unit, onLongClick: (MessagesTimelineItemState.MessageEvent) -> Unit, + composerMode: MessageComposerMode, + onCloseSpecialMode: () -> Unit, composerFullScreen: Boolean, onComposerFullScreenChange: () -> Unit, onComposerTextChange: (CharSequence) -> Unit, @@ -201,6 +221,8 @@ fun MessagesContent( onSendMessage = onSendMessage, fullscreen = composerFullScreen, onFullscreenToggle = onComposerFullScreenChange, + composerMode = composerMode, + onCloseSpecialMode = onCloseSpecialMode, onComposerTextChange = onComposerTextChange, composerCanSendMessage = composerCanSendMessage, composerText = composerText?.charSequence?.toString(), diff --git a/features/messages/src/main/java/io/element/android/x/features/messages/MessagesViewModel.kt b/features/messages/src/main/java/io/element/android/x/features/messages/MessagesViewModel.kt index 430ceb6d6a..43bf852318 100644 --- a/features/messages/src/main/java/io/element/android/x/features/messages/MessagesViewModel.kt +++ b/features/messages/src/main/java/io/element/android/x/features/messages/MessagesViewModel.kt @@ -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.MessagesViewState 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.MatrixInstance import io.element.android.x.matrix.media.MediaResolver import io.element.android.x.matrix.room.MatrixRoom import io.element.android.x.matrix.timeline.MatrixTimeline +import io.element.android.x.textcomposer.MessageComposerMode import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.map @@ -66,25 +68,72 @@ class MessagesViewModel( } fun sendMessage(text: String) { - viewModelScope.launch { - timeline.sendMessage(text) + withState { state -> + 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) { viewModelScope.launch(Dispatchers.Default) { val currentState = awaitState() Timber.v("Handle $action for ${currentState.itemActionsSheetState}") - val targetEvent = - currentState.itemActionsSheetState.invoke()?.targetItem ?: return@launch + val targetEvent = getTargetEvent() + ?: return@launch when (action) { MessagesItemAction.Copy -> notImplementedYet() MessagesItemAction.Forward -> notImplementedYet() 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() { setSnackbarContent("Not implemented yet!") } @@ -110,10 +159,12 @@ class MessagesViewModel( emptyList() } else { mutableListOf( + MessagesItemAction.Reply, MessagesItemAction.Forward, MessagesItemAction.Copy, ).also { if (messagesTimelineItemState.isMine) { + it.add(MessagesItemAction.Edit) it.add(MessagesItemAction.Redact) } } diff --git a/features/messages/src/main/java/io/element/android/x/features/messages/model/MessagesItemAction.kt b/features/messages/src/main/java/io/element/android/x/features/messages/model/MessagesItemAction.kt index 740740fdcc..0c040831a1 100644 --- a/features/messages/src/main/java/io/element/android/x/features/messages/model/MessagesItemAction.kt +++ b/features/messages/src/main/java/io/element/android/x/features/messages/model/MessagesItemAction.kt @@ -13,4 +13,6 @@ sealed class MessagesItemAction( object Forward : MessagesItemAction("Forward", VectorIcons.ArrowForward) object Copy : MessagesItemAction("Copy", VectorIcons.Copy) object Redact : MessagesItemAction("Redact", VectorIcons.Delete, destructive = true) + object Reply : MessagesItemAction("Reply", VectorIcons.Reply) + object Edit : MessagesItemAction("Edit", VectorIcons.Edit) } \ No newline at end of file diff --git a/features/messages/src/main/java/io/element/android/x/features/messages/model/MessagesViewState.kt b/features/messages/src/main/java/io/element/android/x/features/messages/model/MessagesViewState.kt index 6ca5352db1..d77dc08c81 100644 --- a/features/messages/src/main/java/io/element/android/x/features/messages/model/MessagesViewState.kt +++ b/features/messages/src/main/java/io/element/android/x/features/messages/model/MessagesViewState.kt @@ -4,6 +4,7 @@ import com.airbnb.mvrx.Async import com.airbnb.mvrx.MavericksState import com.airbnb.mvrx.Uninitialized import io.element.android.x.designsystem.components.avatar.AvatarData +import io.element.android.x.textcomposer.MessageComposerMode data class MessagesViewState( val roomId: String, @@ -13,6 +14,8 @@ data class MessagesViewState( val hasMoreToLoad: Boolean = true, val itemActionsSheetState: Async = Uninitialized, val snackbarContent: String? = null, + // TODO Highlight item in reply / edit in the timeline + val composerMode: MessageComposerMode = MessageComposerMode.Normal(""), ) : MavericksState { @Suppress("unused") diff --git a/libraries/designsystem/src/main/java/io/element/android/x/designsystem/Icons.kt b/libraries/designsystem/src/main/java/io/element/android/x/designsystem/Icons.kt index 26881ddcef..bd1d941e3e 100644 --- a/libraries/designsystem/src/main/java/io/element/android/x/designsystem/Icons.kt +++ b/libraries/designsystem/src/main/java/io/element/android/x/designsystem/Icons.kt @@ -6,4 +6,6 @@ object VectorIcons { val Copy = R.drawable.ic_content_copy val ArrowForward = R.drawable.ic_content_arrow_forward 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 } \ No newline at end of file diff --git a/libraries/designsystem/src/main/res/drawable/ic_baseline_edit_24.xml b/libraries/designsystem/src/main/res/drawable/ic_baseline_edit_24.xml new file mode 100644 index 0000000000..1c9bd3e6bd --- /dev/null +++ b/libraries/designsystem/src/main/res/drawable/ic_baseline_edit_24.xml @@ -0,0 +1,5 @@ + + + diff --git a/libraries/designsystem/src/main/res/drawable/ic_baseline_reply_24.xml b/libraries/designsystem/src/main/res/drawable/ic_baseline_reply_24.xml new file mode 100644 index 0000000000..c5fba99883 --- /dev/null +++ b/libraries/designsystem/src/main/res/drawable/ic_baseline_reply_24.xml @@ -0,0 +1,5 @@ + + + diff --git a/libraries/matrix/src/main/java/io/element/android/x/matrix/room/MatrixRoom.kt b/libraries/matrix/src/main/java/io/element/android/x/matrix/room/MatrixRoom.kt index c343fc2da0..940c3018cf 100644 --- a/libraries/matrix/src/main/java/io/element/android/x/matrix/room/MatrixRoom.kt +++ b/libraries/matrix/src/main/java/io/element/android/x/matrix/room/MatrixRoom.kt @@ -82,6 +82,22 @@ class MatrixRoom( } } + suspend fun editMessage(originalEventId: String, message: String): Result = 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 = 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) { val transactionId = genTransactionId() runCatching { diff --git a/libraries/matrix/src/main/java/io/element/android/x/matrix/timeline/MatrixTimeline.kt b/libraries/matrix/src/main/java/io/element/android/x/matrix/timeline/MatrixTimeline.kt index f4a5e27c8d..7007640fcd 100644 --- a/libraries/matrix/src/main/java/io/element/android/x/matrix/timeline/MatrixTimeline.kt +++ b/libraries/matrix/src/main/java/io/element/android/x/matrix/timeline/MatrixTimeline.kt @@ -124,6 +124,14 @@ class MatrixTimeline( return matrixRoom.sendMessage(message) } + suspend fun editMessage(originalEventId: String, message: String): Result { + return matrixRoom.editMessage(originalEventId, message = message) + } + + suspend fun replyMessage(inReplyToEventId: String, message: String): Result { + return matrixRoom.replyMessage(inReplyToEventId, message) + } + override fun onUpdate(update: TimelineDiff) { coroutineScope.launch { updateTimelineItems { diff --git a/libraries/textcomposer/src/main/java/io/element/android/x/textcomposer/MessageComposerMode.kt b/libraries/textcomposer/src/main/java/io/element/android/x/textcomposer/MessageComposerMode.kt index 86e35d5ad0..835abd0100 100644 --- a/libraries/textcomposer/src/main/java/io/element/android/x/textcomposer/MessageComposerMode.kt +++ b/libraries/textcomposer/src/main/java/io/element/android/x/textcomposer/MessageComposerMode.kt @@ -19,8 +19,18 @@ package io.element.android.x.textcomposer sealed interface 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 - data class Edit(override val event: Any, override val defaultContent: CharSequence) : Special(event, defaultContent) - 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) + sealed class Special(open val eventId: String, open val defaultContent: CharSequence) : + MessageComposerMode + + 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) } diff --git a/libraries/textcomposer/src/main/java/io/element/android/x/textcomposer/RichTextComposerLayout.kt b/libraries/textcomposer/src/main/java/io/element/android/x/textcomposer/RichTextComposerLayout.kt index 99e300096a..95fab61f47 100644 --- a/libraries/textcomposer/src/main/java/io/element/android/x/textcomposer/RichTextComposerLayout.kt +++ b/libraries/textcomposer/src/main/java/io/element/android/x/textcomposer/RichTextComposerLayout.kt @@ -506,10 +506,7 @@ class RichTextComposerLayout @JvmOverloads constructor( views.composerModeIconView.setImageResource(R.drawable.ic_quote) } is MessageComposerMode.Reply -> { - // TODO We need sender info - // val senderInfo = mode.event.senderInfo - val userName = - "TODO Sender name" // senderInfo.displayName ?: senderInfo.disambiguatedDisplayName + val userName = mode.senderName views.composerModeTitleView.text = resources.getString(R.string.replying_to, userName) views.composerModeIconView.setImageResource(R.drawable.ic_reply) diff --git a/libraries/textcomposer/src/main/java/io/element/android/x/textcomposer/TextComposer.kt b/libraries/textcomposer/src/main/java/io/element/android/x/textcomposer/TextComposer.kt index 50a344cbcd..093cdc858e 100644 --- a/libraries/textcomposer/src/main/java/io/element/android/x/textcomposer/TextComposer.kt +++ b/libraries/textcomposer/src/main/java/io/element/android/x/textcomposer/TextComposer.kt @@ -26,6 +26,8 @@ fun TextComposer( modifier: Modifier = Modifier, fullscreen: Boolean, onFullscreenToggle: () -> Unit, + composerMode: MessageComposerMode, + onCloseSpecialMode: () -> Unit, onComposerTextChange: (CharSequence) -> Unit, composerCanSendMessage: Boolean, composerText: String?, @@ -50,6 +52,7 @@ fun TextComposer( } override fun onCloseRelatedMessage() { + onCloseSpecialMode() } override fun onSendMessage(text: CharSequence) { @@ -70,7 +73,7 @@ fun TextComposer( } setFullScreen(fullscreen, true) (this as MessageComposerView).apply { - setup(isInDarkMode) + setup(isInDarkMode, composerMode) } } }, @@ -83,6 +86,7 @@ fun TextComposer( // Example of Compose -> View communication val messageComposerView = (view as MessageComposerView) view.setFullScreen(fullscreen, false) + // TODO messageComposerView.renderComposerMode(composerMode) messageComposerView.sendButton.isInvisible = !composerCanSendMessage 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) { Color.WHITE } else { @@ -118,6 +122,7 @@ private fun MessageComposerView.setup(isDarkMode: Boolean) { editText.setHint(ElementR.string.room_message_placeholder) emojiButton?.isVisible = true sendButton.isVisible = true + // TODO renderComposerMode(composerMode) } @Preview @@ -128,6 +133,8 @@ fun TextComposerPreview() { fullscreen = false, onFullscreenToggle = { }, onComposerTextChange = {}, + composerMode = MessageComposerMode.Normal(""), + onCloseSpecialMode = {}, composerCanSendMessage = true, composerText = "Message", ) diff --git a/libraries/textcomposer/src/main/res/layout/composer_rich_text_layout.xml b/libraries/textcomposer/src/main/res/layout/composer_rich_text_layout.xml index 88f96c528e..f04884f603 100644 --- a/libraries/textcomposer/src/main/res/layout/composer_rich_text_layout.xml +++ b/libraries/textcomposer/src/main/res/layout/composer_rich_text_layout.xml @@ -8,6 +8,16 @@ android:orientation="vertical" android:background="@drawable/bg_composer_rich_bottom_sheet"> + +