From 9aa82b42fdd20d6a7f6bae805f0a0b13e82c4636 Mon Sep 17 00:00:00 2001 From: ganfra Date: Thu, 20 Jun 2024 11:26:03 +0200 Subject: [PATCH] Draft : introduce DraftService and start using it. --- .../features/messages/impl/MessagesNode.kt | 9 +++ .../impl/draft/ComposerDraftService.kt | 25 +++++++++ .../impl/draft/DefaultComposerDraftService.kt | 56 +++++++++++++++++++ .../messagecomposer/MessageComposerEvents.kt | 1 + .../MessageComposerPresenter.kt | 55 +++++++++++++++++- .../messages/impl/MessagesPresenterTest.kt | 2 + .../impl/draft/FakeComposerDraftService.kt | 29 ++++++++++ .../MessageComposerPresenterTest.kt | 4 ++ .../element/android/libraries/di/RoomScope.kt | 4 +- .../matrix/impl/room/RustMatrixRoom.kt | 2 +- .../textcomposer/model/TextEditorState.kt | 14 +++++ 11 files changed, 198 insertions(+), 3 deletions(-) create mode 100644 features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/draft/ComposerDraftService.kt create mode 100644 features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/draft/DefaultComposerDraftService.kt create mode 100644 features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/draft/FakeComposerDraftService.kt diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesNode.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesNode.kt index e542dfc563..d1e3f87fe3 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesNode.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesNode.kt @@ -26,6 +26,7 @@ import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext +import androidx.lifecycle.Lifecycle import com.bumble.appyx.core.lifecycle.subscribe import com.bumble.appyx.core.modality.BuildContext import com.bumble.appyx.core.node.Node @@ -35,6 +36,7 @@ import dagger.assisted.Assisted import dagger.assisted.AssistedInject import io.element.android.anvilannotations.ContributesNode import io.element.android.features.messages.impl.attachments.Attachment +import io.element.android.features.messages.impl.messagecomposer.MessageComposerEvents import io.element.android.features.messages.impl.timeline.TimelineController import io.element.android.features.messages.impl.timeline.TimelineEvents import io.element.android.features.messages.impl.timeline.di.LocalTimelineItemPresenterFactories @@ -45,6 +47,7 @@ import io.element.android.libraries.androidutils.system.toast import io.element.android.libraries.architecture.NodeInputs import io.element.android.libraries.architecture.inputs import io.element.android.libraries.core.bool.orFalse +import io.element.android.libraries.designsystem.utils.OnLifecycleEvent import io.element.android.libraries.di.ApplicationContext import io.element.android.libraries.di.RoomScope import io.element.android.libraries.matrix.api.analytics.toAnalyticsViewRoom @@ -195,6 +198,12 @@ class MessagesNode @AssistedInject constructor( LocalTimelineItemPresenterFactories provides timelineItemPresenterFactories, ) { val state = presenter.present() + OnLifecycleEvent { _, event -> + when (event) { + Lifecycle.Event.ON_PAUSE -> state.composerState.eventSink(MessageComposerEvents.SaveDraft) + else -> Unit + } + } MessagesView( state = state, onBackClick = this::navigateUp, diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/draft/ComposerDraftService.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/draft/ComposerDraftService.kt new file mode 100644 index 0000000000..1c14b835f8 --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/draft/ComposerDraftService.kt @@ -0,0 +1,25 @@ +/* + * Copyright (c) 2024 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 io.element.android.features.messages.impl.draft + +import io.element.android.libraries.matrix.api.core.RoomId +import io.element.android.libraries.matrix.api.room.draft.ComposerDraft + +interface ComposerDraftService { + suspend fun loadDraft(roomId: RoomId): ComposerDraft? + suspend fun saveDraft(roomId: RoomId, draft: ComposerDraft) +} diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/draft/DefaultComposerDraftService.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/draft/DefaultComposerDraftService.kt new file mode 100644 index 0000000000..641c36a7ad --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/draft/DefaultComposerDraftService.kt @@ -0,0 +1,56 @@ +/* + * Copyright (c) 2024 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 io.element.android.features.messages.impl.draft + +import com.squareup.anvil.annotations.ContributesBinding +import io.element.android.libraries.di.RoomScope +import io.element.android.libraries.matrix.api.MatrixClient +import io.element.android.libraries.matrix.api.core.RoomId +import io.element.android.libraries.matrix.api.room.draft.ComposerDraft +import timber.log.Timber +import javax.inject.Inject + +@ContributesBinding(RoomScope::class) +class DefaultComposerDraftService @Inject constructor( + private val client: MatrixClient, +) : ComposerDraftService { + + override suspend fun loadDraft(roomId: RoomId): ComposerDraft? { + return client.getRoom(roomId)?.use { room -> + room.loadComposerDraft() + .onFailure { + Timber.e(it, "Failed to load composer draft for room $roomId") + } + .onSuccess { draft -> + room.clearComposerDraft() + Timber.d("Loaded composer draft for room $roomId : $draft") + }.getOrNull() + } + } + + override suspend fun saveDraft(roomId: RoomId, draft: ComposerDraft) { + client.getRoom(roomId)?.use { room -> + room.saveComposerDraft(draft) + .onFailure { + Timber.e(it, "Failed to save composer draft for room $roomId") + } + .onSuccess { + Timber.d("Saved composer draft for room $roomId") + } + } + } +} diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerEvents.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerEvents.kt index 19ca038bd2..1f6ae7c7f4 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerEvents.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerEvents.kt @@ -45,4 +45,5 @@ sealed interface MessageComposerEvents { data class TypingNotice(val isTyping: Boolean) : MessageComposerEvents data class SuggestionReceived(val suggestion: Suggestion?) : MessageComposerEvents data class InsertMention(val mention: ResolvedMentionSuggestion) : MessageComposerEvents + data object SaveDraft : MessageComposerEvents } diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerPresenter.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerPresenter.kt index 27970697bb..48211267ed 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerPresenter.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerPresenter.kt @@ -39,6 +39,7 @@ import im.vector.app.features.analytics.plan.Composer import im.vector.app.features.analytics.plan.Interaction import io.element.android.features.messages.impl.attachments.Attachment import io.element.android.features.messages.impl.attachments.preview.error.sendAttachmentError +import io.element.android.features.messages.impl.draft.ComposerDraftService import io.element.android.features.messages.impl.mentions.MentionSuggestionsProcessor import io.element.android.features.messages.impl.timeline.TimelineController import io.element.android.libraries.architecture.Presenter @@ -54,6 +55,8 @@ import io.element.android.libraries.matrix.api.permalink.PermalinkBuilder import io.element.android.libraries.matrix.api.permalink.PermalinkParser import io.element.android.libraries.matrix.api.room.MatrixRoom import io.element.android.libraries.matrix.api.room.Mention +import io.element.android.libraries.matrix.api.room.draft.ComposerDraft +import io.element.android.libraries.matrix.api.room.draft.ComposerDraftType import io.element.android.libraries.matrix.api.user.CurrentSessionIdHolder import io.element.android.libraries.mediapickers.api.PickerProvider import io.element.android.libraries.mediaupload.api.MediaSender @@ -109,10 +112,10 @@ class MessageComposerPresenter @Inject constructor( private val permalinkBuilder: PermalinkBuilder, permissionsPresenterFactory: PermissionsPresenter.Factory, private val timelineController: TimelineController, + private val draftService: ComposerDraftService, ) : Presenter { private val cameraPermissionPresenter = permissionsPresenterFactory.create(Manifest.permission.CAMERA) private var pendingEvent: MessageComposerEvents? = null - private val suggestionSearchTrigger = MutableStateFlow(null) // Used to disable some UI related elements in tests @@ -261,6 +264,10 @@ class MessageComposerPresenter @Inject constructor( } ) + LaunchedEffect(Unit) { + loadDraft(textEditorState) + } + LaunchedEffect(showTextFormatting) { if (!applyFormattingModeChanges) { applyFormattingModeChanges = true @@ -432,6 +439,9 @@ class MessageComposerPresenter @Inject constructor( } } } + MessageComposerEvents.SaveDraft -> { + appCoroutineScope.saveDraft(textEditorState) + } } } @@ -582,4 +592,47 @@ class MessageComposerPresenter @Inject constructor( snackbarDispatcher.post(snackbarMessage) } } + + private fun CoroutineScope.loadDraft( + textEditorState: TextEditorState, + ) = launch { + val draft = draftService.loadDraft(room.roomId) ?: return@launch + val htmlText = draft.htmlText + val markdownText = draft.plainText + textEditorState.setMarkdown(markdownText) + if (htmlText != null) { + textEditorState.setHtml(htmlText) + showTextFormatting = true + } + when (val draftType = draft.draftType) { + ComposerDraftType.NewMessage -> messageComposerContext.composerMode = MessageComposerMode.Normal + is ComposerDraftType.Edit -> messageComposerContext.composerMode = MessageComposerMode.Edit(draftType.eventId, markdownText, null) + is ComposerDraftType.Reply -> messageComposerContext.composerMode = MessageComposerMode.Normal + } + } + + private fun CoroutineScope.saveDraft( + textEditorState: TextEditorState, + ) = launch { + val html = textEditorState.messageHtml() + val markdown = textEditorState.messageMarkdown(permalinkBuilder) + val draftType = when (val mode = messageComposerContext.composerMode) { + is MessageComposerMode.Normal -> ComposerDraftType.NewMessage + is MessageComposerMode.Edit -> { + mode.eventId?.let { eventId -> ComposerDraftType.Edit(eventId) } + } + is MessageComposerMode.Reply -> ComposerDraftType.Reply(mode.eventId) + is MessageComposerMode.Quote -> null + } + if (draftType == null || markdown.isBlank()) { + return@launch + } else { + val composerDraft = ComposerDraft( + draftType = draftType, + htmlText = html, + plainText = markdown, + ) + draftService.saveDraft(room.roomId, composerDraft) + } + } } diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/MessagesPresenterTest.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/MessagesPresenterTest.kt index b0f7fd5a48..a725fcd2e3 100644 --- a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/MessagesPresenterTest.kt +++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/MessagesPresenterTest.kt @@ -25,6 +25,7 @@ import com.google.common.truth.Truth.assertThat import io.element.android.features.messages.impl.actionlist.ActionListPresenter import io.element.android.features.messages.impl.actionlist.ActionListState import io.element.android.features.messages.impl.actionlist.model.TimelineItemAction +import io.element.android.features.messages.impl.draft.FakeComposerDraftService import io.element.android.features.messages.impl.fixtures.aMessageEvent import io.element.android.features.messages.impl.fixtures.aTimelineItemsFactory import io.element.android.features.messages.impl.messagecomposer.DefaultMessageComposerContext @@ -782,6 +783,7 @@ class MessagesPresenterTest { permalinkParser = FakePermalinkParser(), permalinkBuilder = FakePermalinkBuilder(), timelineController = TimelineController(matrixRoom), + draftService = FakeComposerDraftService(), ).apply { showTextFormatting = true isTesting = true diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/draft/FakeComposerDraftService.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/draft/FakeComposerDraftService.kt new file mode 100644 index 0000000000..bf5d5358f2 --- /dev/null +++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/draft/FakeComposerDraftService.kt @@ -0,0 +1,29 @@ +/* + * Copyright (c) 2024 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 io.element.android.features.messages.impl.draft + +import io.element.android.libraries.matrix.api.core.RoomId +import io.element.android.libraries.matrix.api.room.draft.ComposerDraft + +class FakeComposerDraftService : ComposerDraftService { + + var loadDraftLambda: suspend (RoomId) -> ComposerDraft? = { null } + override suspend fun loadDraft(roomId: RoomId) = loadDraftLambda(roomId) + + var saveDraftLambda: suspend (RoomId, ComposerDraft) -> Unit = { _, _ -> } + override suspend fun saveDraft(roomId: RoomId, draft: ComposerDraft) = saveDraftLambda(roomId, draft) +} diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/textcomposer/MessageComposerPresenterTest.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/textcomposer/MessageComposerPresenterTest.kt index f027612c18..e7dbba99a2 100644 --- a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/textcomposer/MessageComposerPresenterTest.kt +++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/textcomposer/MessageComposerPresenterTest.kt @@ -27,6 +27,8 @@ import app.cash.turbine.test import com.google.common.truth.Truth.assertThat import im.vector.app.features.analytics.plan.Composer import im.vector.app.features.analytics.plan.Interaction +import io.element.android.features.messages.impl.draft.ComposerDraftService +import io.element.android.features.messages.impl.draft.FakeComposerDraftService import io.element.android.features.messages.impl.messagecomposer.AttachmentsState import io.element.android.features.messages.impl.messagecomposer.DefaultMessageComposerContext import io.element.android.features.messages.impl.messagecomposer.MessageComposerEvents @@ -1045,6 +1047,7 @@ class MessageComposerPresenterTest { permissionPresenter: PermissionsPresenter = FakePermissionsPresenter(), permalinkBuilder: PermalinkBuilder = FakePermalinkBuilder(), isRichTextEditorEnabled: Boolean = true, + draftService: ComposerDraftService = FakeComposerDraftService(), ) = MessageComposerPresenter( coroutineScope, room, @@ -1062,6 +1065,7 @@ class MessageComposerPresenterTest { permalinkParser = FakePermalinkParser(), permalinkBuilder = permalinkBuilder, timelineController = TimelineController(room), + draftService = draftService, ).apply { isTesting = true showTextFormatting = isRichTextEditorEnabled diff --git a/libraries/di/src/main/kotlin/io/element/android/libraries/di/RoomScope.kt b/libraries/di/src/main/kotlin/io/element/android/libraries/di/RoomScope.kt index af25c4cda5..0b508c6523 100644 --- a/libraries/di/src/main/kotlin/io/element/android/libraries/di/RoomScope.kt +++ b/libraries/di/src/main/kotlin/io/element/android/libraries/di/RoomScope.kt @@ -16,4 +16,6 @@ package io.element.android.libraries.di -abstract class RoomScope private constructor() +abstract class RoomScope private constructor( + +) diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/RustMatrixRoom.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/RustMatrixRoom.kt index 1bb983db11..463defb284 100644 --- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/RustMatrixRoom.kt +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/RustMatrixRoom.kt @@ -618,7 +618,7 @@ class RustMatrixRoom( } override suspend fun clearComposerDraft(): Result = runCatching { - Timber.d("clearComposerDraft: for $roomId") + Timber.d("clearComposerDraft for $roomId") innerRoom.clearComposerDraft() } diff --git a/libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/model/TextEditorState.kt b/libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/model/TextEditorState.kt index ae7a15fb65..df7adc34dd 100644 --- a/libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/model/TextEditorState.kt +++ b/libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/model/TextEditorState.kt @@ -45,6 +45,20 @@ sealed interface TextEditorState { is Rich -> richTextEditorState.hasFocus } + suspend fun setHtml(html: String) { + when (this) { + is Markdown -> Unit + is Rich -> richTextEditorState.setHtml(html) + } + } + + suspend fun setMarkdown(text: String) { + when (this) { + is Markdown -> state.text.update(text, true) + is Rich -> richTextEditorState.setMarkdown(text) + } + } + suspend fun reset() { when (this) { is Markdown -> {