Browse Source

Draft : introduce DraftService and start using it.

pull/3099/head
ganfra 3 months ago
parent
commit
9aa82b42fd
  1. 9
      features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesNode.kt
  2. 25
      features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/draft/ComposerDraftService.kt
  3. 56
      features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/draft/DefaultComposerDraftService.kt
  4. 1
      features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerEvents.kt
  5. 55
      features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerPresenter.kt
  6. 2
      features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/MessagesPresenterTest.kt
  7. 29
      features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/draft/FakeComposerDraftService.kt
  8. 4
      features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/textcomposer/MessageComposerPresenterTest.kt
  9. 4
      libraries/di/src/main/kotlin/io/element/android/libraries/di/RoomScope.kt
  10. 2
      libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/RustMatrixRoom.kt
  11. 14
      libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/model/TextEditorState.kt

9
features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesNode.kt

@ -26,6 +26,7 @@ import androidx.compose.runtime.saveable.rememberSaveable @@ -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 @@ -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 @@ -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( @@ -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,

25
features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/draft/ComposerDraftService.kt

@ -0,0 +1,25 @@ @@ -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)
}

56
features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/draft/DefaultComposerDraftService.kt

@ -0,0 +1,56 @@ @@ -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")
}
}
}
}

1
features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerEvents.kt

@ -45,4 +45,5 @@ sealed interface MessageComposerEvents { @@ -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
}

55
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 @@ -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 @@ -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( @@ -109,10 +112,10 @@ class MessageComposerPresenter @Inject constructor(
private val permalinkBuilder: PermalinkBuilder,
permissionsPresenterFactory: PermissionsPresenter.Factory,
private val timelineController: TimelineController,
private val draftService: ComposerDraftService,
) : Presenter<MessageComposerState> {
private val cameraPermissionPresenter = permissionsPresenterFactory.create(Manifest.permission.CAMERA)
private var pendingEvent: MessageComposerEvents? = null
private val suggestionSearchTrigger = MutableStateFlow<Suggestion?>(null)
// Used to disable some UI related elements in tests
@ -261,6 +264,10 @@ class MessageComposerPresenter @Inject constructor( @@ -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( @@ -432,6 +439,9 @@ class MessageComposerPresenter @Inject constructor(
}
}
}
MessageComposerEvents.SaveDraft -> {
appCoroutineScope.saveDraft(textEditorState)
}
}
}
@ -582,4 +592,47 @@ class MessageComposerPresenter @Inject constructor( @@ -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)
}
}
}

2
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 @@ -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 { @@ -782,6 +783,7 @@ class MessagesPresenterTest {
permalinkParser = FakePermalinkParser(),
permalinkBuilder = FakePermalinkBuilder(),
timelineController = TimelineController(matrixRoom),
draftService = FakeComposerDraftService(),
).apply {
showTextFormatting = true
isTesting = true

29
features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/draft/FakeComposerDraftService.kt

@ -0,0 +1,29 @@ @@ -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)
}

4
features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/textcomposer/MessageComposerPresenterTest.kt

@ -27,6 +27,8 @@ import app.cash.turbine.test @@ -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 { @@ -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 { @@ -1062,6 +1065,7 @@ class MessageComposerPresenterTest {
permalinkParser = FakePermalinkParser(),
permalinkBuilder = permalinkBuilder,
timelineController = TimelineController(room),
draftService = draftService,
).apply {
isTesting = true
showTextFormatting = isRichTextEditorEnabled

4
libraries/di/src/main/kotlin/io/element/android/libraries/di/RoomScope.kt

@ -16,4 +16,6 @@ @@ -16,4 +16,6 @@
package io.element.android.libraries.di
abstract class RoomScope private constructor()
abstract class RoomScope private constructor(
)

2
libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/RustMatrixRoom.kt

@ -618,7 +618,7 @@ class RustMatrixRoom( @@ -618,7 +618,7 @@ class RustMatrixRoom(
}
override suspend fun clearComposerDraft(): Result<Unit> = runCatching {
Timber.d("clearComposerDraft: for $roomId")
Timber.d("clearComposerDraft for $roomId")
innerRoom.clearComposerDraft()
}

14
libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/model/TextEditorState.kt

@ -45,6 +45,20 @@ sealed interface TextEditorState { @@ -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 -> {

Loading…
Cancel
Save