[Rich text editor] Integrate rich text editor library (#1172)
* Integrate rich text editor * Also increase swapfile size in test CI Fixes issue where screenshot tests are terminated due to lack of CI resources. See https://github.com/actions/runner-images/discussions/7188#discussioncomment-6750749 --------- Co-authored-by: ElementBot <benoitm+elementbot@element.io>
This commit is contained in:
parent
f96ba8c183
commit
f214493c9d
62 changed files with 441 additions and 289 deletions
|
|
@ -175,7 +175,7 @@ class MessagesPresenter @AssistedInject constructor(
|
|||
snackbarMessage = snackbarMessage,
|
||||
showReinvitePrompt = showReinvitePrompt,
|
||||
inviteProgress = inviteProgress.value,
|
||||
eventSink = ::handleEvents
|
||||
eventSink = { handleEvents(it) }
|
||||
)
|
||||
}
|
||||
|
||||
|
|
@ -250,7 +250,9 @@ class MessagesPresenter @AssistedInject constructor(
|
|||
private fun handleActionEdit(targetEvent: TimelineItem.Event, composerState: MessageComposerState) {
|
||||
val composerMode = MessageComposerMode.Edit(
|
||||
targetEvent.eventId,
|
||||
(targetEvent.content as? TimelineItemTextBasedContent)?.body.orEmpty(),
|
||||
(targetEvent.content as? TimelineItemTextBasedContent)?.let {
|
||||
it.htmlBody ?: it.body
|
||||
}.orEmpty(),
|
||||
targetEvent.transactionId,
|
||||
)
|
||||
composerState.eventSink(
|
||||
|
|
|
|||
|
|
@ -30,6 +30,7 @@ import io.element.android.libraries.designsystem.components.avatar.AvatarData
|
|||
import io.element.android.libraries.designsystem.components.avatar.AvatarSize
|
||||
import io.element.android.libraries.matrix.api.core.RoomId
|
||||
import io.element.android.libraries.textcomposer.MessageComposerMode
|
||||
import io.element.android.wysiwyg.compose.RichTextEditorState
|
||||
import kotlinx.collections.immutable.persistentSetOf
|
||||
|
||||
open class MessagesStateProvider : PreviewParameterProvider<MessagesState> {
|
||||
|
|
@ -54,7 +55,9 @@ fun aMessagesState() = MessagesState(
|
|||
userHasPermissionToSendMessage = true,
|
||||
userHasPermissionToRedact = false,
|
||||
composerState = aMessageComposerState().copy(
|
||||
text = "Hello",
|
||||
richTextEditorState = RichTextEditorState("Hello", fake = true).apply {
|
||||
requestFocus()
|
||||
},
|
||||
isFullScreen = false,
|
||||
mode = MessageComposerMode.Normal("Hello"),
|
||||
),
|
||||
|
|
|
|||
|
|
@ -70,7 +70,7 @@ class ActionListPresenter @Inject constructor(
|
|||
return ActionListState(
|
||||
target = target.value,
|
||||
displayEmojiReactions = displayEmojiReactions,
|
||||
eventSink = ::handleEvents
|
||||
eventSink = { handleEvents(it) }
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -17,16 +17,15 @@
|
|||
package io.element.android.features.messages.impl.messagecomposer
|
||||
|
||||
import androidx.compose.runtime.Immutable
|
||||
import io.element.android.libraries.textcomposer.Message
|
||||
import io.element.android.libraries.textcomposer.MessageComposerMode
|
||||
|
||||
@Immutable
|
||||
sealed interface MessageComposerEvents {
|
||||
data object ToggleFullScreenState : MessageComposerEvents
|
||||
data class FocusChanged(val hasFocus: Boolean) : MessageComposerEvents
|
||||
data class SendMessage(val message: String) : MessageComposerEvents
|
||||
data class SendMessage(val message: Message) : MessageComposerEvents
|
||||
data object CloseSpecialMode : MessageComposerEvents
|
||||
data class SetMode(val composerMode: MessageComposerMode) : MessageComposerEvents
|
||||
data class UpdateText(val text: String) : MessageComposerEvents
|
||||
data object AddAttachment : MessageComposerEvents
|
||||
data object DismissAttachmentMenu : MessageComposerEvents
|
||||
sealed interface PickAttachmentSource : MessageComposerEvents {
|
||||
|
|
@ -38,4 +37,5 @@ sealed interface MessageComposerEvents {
|
|||
data object Poll : PickAttachmentSource
|
||||
}
|
||||
data object CancelSendAttachment : MessageComposerEvents
|
||||
data class Error(val error: Throwable) : MessageComposerEvents
|
||||
}
|
||||
|
|
|
|||
|
|
@ -44,8 +44,10 @@ import io.element.android.libraries.matrix.api.core.ProgressCallback
|
|||
import io.element.android.libraries.matrix.api.room.MatrixRoom
|
||||
import io.element.android.libraries.mediapickers.api.PickerProvider
|
||||
import io.element.android.libraries.mediaupload.api.MediaSender
|
||||
import io.element.android.libraries.textcomposer.Message
|
||||
import io.element.android.libraries.textcomposer.MessageComposerMode
|
||||
import io.element.android.services.analytics.api.AnalyticsService
|
||||
import io.element.android.wysiwyg.compose.RichTextEditorState
|
||||
import kotlinx.collections.immutable.persistentListOf
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
|
|
@ -67,6 +69,7 @@ class MessageComposerPresenter @Inject constructor(
|
|||
private val snackbarDispatcher: SnackbarDispatcher,
|
||||
private val analyticsService: AnalyticsService,
|
||||
private val messageComposerContext: MessageComposerContextImpl,
|
||||
private val richTextEditorStateFactory: RichTextEditorStateFactory,
|
||||
) : Presenter<MessageComposerState> {
|
||||
|
||||
@SuppressLint("UnsafeOptInUsageError")
|
||||
|
|
@ -103,19 +106,15 @@ class MessageComposerPresenter @Inject constructor(
|
|||
val isFullScreen = rememberSaveable {
|
||||
mutableStateOf(false)
|
||||
}
|
||||
val hasFocus = remember {
|
||||
mutableStateOf(false)
|
||||
}
|
||||
val text: MutableState<String> = rememberSaveable {
|
||||
mutableStateOf("")
|
||||
}
|
||||
val richTextEditorState = richTextEditorStateFactory.create()
|
||||
val ongoingSendAttachmentJob = remember { mutableStateOf<Job?>(null) }
|
||||
|
||||
var showAttachmentSourcePicker: Boolean by remember { mutableStateOf(false) }
|
||||
|
||||
LaunchedEffect(messageComposerContext.composerMode) {
|
||||
when (val modeValue = messageComposerContext.composerMode) {
|
||||
is MessageComposerMode.Edit -> text.value = modeValue.defaultContent
|
||||
is MessageComposerMode.Edit ->
|
||||
richTextEditorState.setHtml(modeValue.defaultContent)
|
||||
else -> Unit
|
||||
}
|
||||
}
|
||||
|
|
@ -136,18 +135,15 @@ class MessageComposerPresenter @Inject constructor(
|
|||
when (event) {
|
||||
MessageComposerEvents.ToggleFullScreenState -> isFullScreen.value = !isFullScreen.value
|
||||
|
||||
is MessageComposerEvents.FocusChanged -> hasFocus.value = event.hasFocus
|
||||
|
||||
is MessageComposerEvents.UpdateText -> text.value = event.text
|
||||
MessageComposerEvents.CloseSpecialMode -> {
|
||||
text.value = ""
|
||||
richTextEditorState.setHtml("")
|
||||
messageComposerContext.composerMode = MessageComposerMode.Normal("")
|
||||
}
|
||||
|
||||
is MessageComposerEvents.SendMessage -> appCoroutineScope.sendMessage(
|
||||
text = event.message,
|
||||
message = event.message,
|
||||
updateComposerMode = { messageComposerContext.composerMode = it },
|
||||
textState = text
|
||||
richTextEditorState = richTextEditorState,
|
||||
)
|
||||
is MessageComposerEvents.SetMode -> {
|
||||
messageComposerContext.composerMode = event.composerMode
|
||||
|
|
@ -194,43 +190,46 @@ class MessageComposerPresenter @Inject constructor(
|
|||
ongoingSendAttachmentJob.value == null
|
||||
}
|
||||
}
|
||||
is MessageComposerEvents.Error -> {
|
||||
analyticsService.trackError(event.error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return MessageComposerState(
|
||||
text = text.value,
|
||||
richTextEditorState = richTextEditorState,
|
||||
isFullScreen = isFullScreen.value,
|
||||
hasFocus = hasFocus.value,
|
||||
mode = messageComposerContext.composerMode,
|
||||
showAttachmentSourcePicker = showAttachmentSourcePicker,
|
||||
canShareLocation = canShareLocation.value,
|
||||
canCreatePoll = canCreatePoll.value,
|
||||
attachmentsState = attachmentsState.value,
|
||||
eventSink = ::handleEvents
|
||||
eventSink = { handleEvents(it) }
|
||||
)
|
||||
}
|
||||
|
||||
private fun CoroutineScope.sendMessage(
|
||||
text: String,
|
||||
message: Message,
|
||||
updateComposerMode: (newComposerMode: MessageComposerMode) -> Unit,
|
||||
textState: MutableState<String>
|
||||
richTextEditorState: RichTextEditorState,
|
||||
) = launch {
|
||||
val capturedMode = messageComposerContext.composerMode
|
||||
// Reset composer right away
|
||||
textState.value = ""
|
||||
richTextEditorState.setHtml("")
|
||||
updateComposerMode(MessageComposerMode.Normal(""))
|
||||
when (capturedMode) {
|
||||
is MessageComposerMode.Normal -> room.sendMessage(text)
|
||||
is MessageComposerMode.Normal -> room.sendMessage(body = message.markdown, htmlBody = message.html)
|
||||
is MessageComposerMode.Edit -> {
|
||||
val eventId = capturedMode.eventId
|
||||
val transactionId = capturedMode.transactionId
|
||||
room.editMessage(eventId, transactionId, text)
|
||||
room.editMessage(eventId, transactionId, message.markdown, message.html)
|
||||
}
|
||||
|
||||
is MessageComposerMode.Quote -> TODO()
|
||||
is MessageComposerMode.Reply -> room.replyMessage(
|
||||
capturedMode.eventId,
|
||||
text
|
||||
message.markdown,
|
||||
message.html,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -19,21 +19,22 @@ package io.element.android.features.messages.impl.messagecomposer
|
|||
import androidx.compose.runtime.Immutable
|
||||
import io.element.android.features.messages.impl.attachments.Attachment
|
||||
import io.element.android.libraries.textcomposer.MessageComposerMode
|
||||
import io.element.android.wysiwyg.compose.RichTextEditorState
|
||||
import kotlinx.collections.immutable.ImmutableList
|
||||
|
||||
@Immutable
|
||||
data class MessageComposerState(
|
||||
val text: String?,
|
||||
val richTextEditorState: RichTextEditorState,
|
||||
val isFullScreen: Boolean,
|
||||
val hasFocus: Boolean,
|
||||
val mode: MessageComposerMode,
|
||||
val showAttachmentSourcePicker: Boolean,
|
||||
val canShareLocation: Boolean,
|
||||
val canCreatePoll: Boolean,
|
||||
val attachmentsState: AttachmentsState,
|
||||
val eventSink: (MessageComposerEvents) -> Unit
|
||||
val eventSink: (MessageComposerEvents) -> Unit,
|
||||
) {
|
||||
val isSendButtonVisible: Boolean = text.isNullOrEmpty().not()
|
||||
val canSendMessage: Boolean = richTextEditorState.messageHtml.isNotEmpty()
|
||||
val hasFocus: Boolean = richTextEditorState.hasFocus
|
||||
}
|
||||
|
||||
@Immutable
|
||||
|
|
|
|||
|
|
@ -18,6 +18,7 @@ package io.element.android.features.messages.impl.messagecomposer
|
|||
|
||||
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
|
||||
import io.element.android.libraries.textcomposer.MessageComposerMode
|
||||
import io.element.android.wysiwyg.compose.RichTextEditorState
|
||||
|
||||
open class MessageComposerStateProvider : PreviewParameterProvider<MessageComposerState> {
|
||||
override val values: Sequence<MessageComposerState>
|
||||
|
|
@ -27,18 +28,17 @@ open class MessageComposerStateProvider : PreviewParameterProvider<MessageCompos
|
|||
}
|
||||
|
||||
fun aMessageComposerState(
|
||||
text: String = "",
|
||||
requestFocus: Boolean = true,
|
||||
composerState: RichTextEditorState = RichTextEditorState("", fake = true),
|
||||
isFullScreen: Boolean = false,
|
||||
hasFocus: Boolean = false,
|
||||
mode: MessageComposerMode = MessageComposerMode.Normal(content = ""),
|
||||
showAttachmentSourcePicker: Boolean = false,
|
||||
canShareLocation: Boolean = true,
|
||||
canCreatePoll: Boolean = true,
|
||||
attachmentsState: AttachmentsState = AttachmentsState.None,
|
||||
) = MessageComposerState(
|
||||
text = text,
|
||||
richTextEditorState = composerState.apply { if(requestFocus) requestFocus() },
|
||||
isFullScreen = isFullScreen,
|
||||
hasFocus = hasFocus,
|
||||
mode = mode,
|
||||
showAttachmentSourcePicker = showAttachmentSourcePicker,
|
||||
canShareLocation = canShareLocation,
|
||||
|
|
|
|||
|
|
@ -23,6 +23,7 @@ import androidx.compose.ui.tooling.preview.Preview
|
|||
import androidx.compose.ui.tooling.preview.PreviewParameter
|
||||
import io.element.android.libraries.designsystem.preview.ElementPreviewDark
|
||||
import io.element.android.libraries.designsystem.preview.ElementPreviewLight
|
||||
import io.element.android.libraries.textcomposer.Message
|
||||
import io.element.android.libraries.textcomposer.TextComposer
|
||||
|
||||
@Composable
|
||||
|
|
@ -36,7 +37,7 @@ fun MessageComposerView(
|
|||
state.eventSink(MessageComposerEvents.ToggleFullScreenState)
|
||||
}
|
||||
|
||||
fun sendMessage(message: String) {
|
||||
fun sendMessage(message: Message) {
|
||||
state.eventSink(MessageComposerEvents.SendMessage(message))
|
||||
}
|
||||
|
||||
|
|
@ -48,12 +49,8 @@ fun MessageComposerView(
|
|||
state.eventSink(MessageComposerEvents.CloseSpecialMode)
|
||||
}
|
||||
|
||||
fun onComposerTextChange(text: String) {
|
||||
state.eventSink(MessageComposerEvents.UpdateText(text))
|
||||
}
|
||||
|
||||
fun onFocusChanged(hasFocus: Boolean) {
|
||||
state.eventSink(MessageComposerEvents.FocusChanged(hasFocus))
|
||||
fun onError(error: Throwable) {
|
||||
state.eventSink(MessageComposerEvents.Error(error))
|
||||
}
|
||||
|
||||
Box(modifier = modifier) {
|
||||
|
|
@ -64,14 +61,14 @@ fun MessageComposerView(
|
|||
)
|
||||
|
||||
TextComposer(
|
||||
state = state.richTextEditorState,
|
||||
canSendMessage = state.canSendMessage,
|
||||
onRequestFocus = { state.richTextEditorState.requestFocus() },
|
||||
onSendMessage = ::sendMessage,
|
||||
composerMode = state.mode,
|
||||
onResetComposerMode = ::onCloseSpecialMode,
|
||||
onComposerTextChange = ::onComposerTextChange,
|
||||
onAddAttachment = ::onAddAttachment,
|
||||
onFocusChanged = ::onFocusChanged,
|
||||
composerCanSendMessage = state.isSendButtonVisible,
|
||||
composerText = state.text
|
||||
onError = ::onError,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,38 @@
|
|||
/*
|
||||
* Copyright (c) 2023 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.messagecomposer
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import com.squareup.anvil.annotations.ContributesBinding
|
||||
import io.element.android.libraries.di.AppScope
|
||||
import io.element.android.wysiwyg.compose.RichTextEditorState
|
||||
import io.element.android.wysiwyg.compose.rememberRichTextEditorState
|
||||
import javax.inject.Inject
|
||||
|
||||
interface RichTextEditorStateFactory {
|
||||
@Composable
|
||||
fun create(): RichTextEditorState
|
||||
}
|
||||
|
||||
@ContributesBinding(AppScope::class)
|
||||
class DefaultRichTextEditorStateFactory @Inject constructor() : RichTextEditorStateFactory {
|
||||
@Composable
|
||||
override fun create(): RichTextEditorState {
|
||||
return rememberRichTextEditorState()
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -21,6 +21,7 @@ import androidx.compose.runtime.LaunchedEffect
|
|||
import androidx.compose.runtime.MutableState
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableIntStateOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
|
|
@ -61,7 +62,7 @@ class TimelinePresenter @Inject constructor(
|
|||
mutableStateOf(null)
|
||||
}
|
||||
|
||||
val lastReadReceiptIndex = rememberSaveable { mutableStateOf(Int.MAX_VALUE) }
|
||||
val lastReadReceiptIndex = rememberSaveable { mutableIntStateOf(Int.MAX_VALUE) }
|
||||
val lastReadReceiptId = rememberSaveable { mutableStateOf<EventId?>(null) }
|
||||
|
||||
val timelineItems by timelineItemsFactory.collectItemsAsState()
|
||||
|
|
@ -119,7 +120,7 @@ class TimelinePresenter @Inject constructor(
|
|||
paginationState = paginationState,
|
||||
timelineItems = timelineItems,
|
||||
hasNewItems = hasNewItems.value,
|
||||
eventSink = ::handleEvents
|
||||
eventSink = { handleEvents(it) }
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -63,7 +63,7 @@ class CustomReactionPresenter @Inject constructor(
|
|||
return CustomReactionState(
|
||||
target = target.value,
|
||||
selectedEmoji = selectedEmoji,
|
||||
eventSink = ::handleEvents
|
||||
eventSink = { handleEvents(it) }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -61,7 +61,7 @@ class ReactionSummaryPresenter @Inject constructor(
|
|||
}
|
||||
return ReactionSummaryState(
|
||||
target = targetWithAvatars.value,
|
||||
eventSink = ::handleEvents
|
||||
eventSink = { handleEvents(it) }
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -66,7 +66,7 @@ class RetrySendMenuPresenter @Inject constructor(
|
|||
|
||||
return RetrySendMenuState(
|
||||
selectedEvent = selectedEvent,
|
||||
eventSink = ::handleEvent,
|
||||
eventSink = { handleEvent(it) },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -22,4 +22,6 @@ sealed interface TimelineItemTextBasedContent : TimelineItemEventContent {
|
|||
val body: String
|
||||
val htmlDocument: Document?
|
||||
val isEdited: Boolean
|
||||
val htmlBody: String?
|
||||
get() = htmlDocument?.body()?.html()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -30,7 +30,6 @@ 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.messagecomposer.MessageComposerContextImpl
|
||||
import io.element.android.features.messages.impl.messagecomposer.MessageComposerEvents
|
||||
import io.element.android.features.messages.impl.messagecomposer.MessageComposerPresenter
|
||||
import io.element.android.features.messages.impl.timeline.TimelinePresenter
|
||||
import io.element.android.features.messages.impl.timeline.components.customreaction.CustomReactionPresenter
|
||||
|
|
@ -41,6 +40,7 @@ import io.element.android.features.messages.impl.timeline.model.event.TimelineIt
|
|||
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemTextContent
|
||||
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemVideoContent
|
||||
import io.element.android.features.messages.media.FakeLocalMediaFactory
|
||||
import io.element.android.features.messages.textcomposer.TestRichTextEditorStateFactory
|
||||
import io.element.android.features.messages.timeline.components.customreaction.FakeEmojibaseProvider
|
||||
import io.element.android.features.messages.utils.messagesummary.FakeMessageSummaryFormatter
|
||||
import io.element.android.features.networkmonitor.test.FakeNetworkMonitor
|
||||
|
|
@ -325,6 +325,7 @@ class MessagesPresenterTest {
|
|||
initialState.eventSink.invoke(MessagesEvents.HandleAction(TimelineItemAction.Redact, aMessageEvent()))
|
||||
assertThat(matrixRoom.redactEventEventIdParam).isEqualTo(AN_EVENT_ID)
|
||||
assertThat(awaitItem().actionListState.target).isEqualTo(ActionListState.Target.None)
|
||||
skipItems(1) // back paginating
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -381,7 +382,7 @@ class MessagesPresenterTest {
|
|||
// Initially the composer doesn't have focus, so we don't show the alert
|
||||
assertThat(initialState.showReinvitePrompt).isFalse()
|
||||
// When the input field is focused we show the alert
|
||||
initialState.composerState.eventSink(MessageComposerEvents.FocusChanged(true))
|
||||
initialState.composerState.richTextEditorState.requestFocus()
|
||||
val focusedState = consumeItemsUntilPredicate(timeout = 250.milliseconds) { state ->
|
||||
state.showReinvitePrompt
|
||||
}.last()
|
||||
|
|
@ -405,7 +406,7 @@ class MessagesPresenterTest {
|
|||
skipItems(1)
|
||||
val initialState = awaitItem()
|
||||
assertThat(initialState.showReinvitePrompt).isFalse()
|
||||
initialState.composerState.eventSink(MessageComposerEvents.FocusChanged(true))
|
||||
initialState.composerState.richTextEditorState.requestFocus()
|
||||
val focusedState = awaitItem()
|
||||
assertThat(focusedState.showReinvitePrompt).isFalse()
|
||||
}
|
||||
|
|
@ -421,7 +422,7 @@ class MessagesPresenterTest {
|
|||
skipItems(1)
|
||||
val initialState = awaitItem()
|
||||
assertThat(initialState.showReinvitePrompt).isFalse()
|
||||
initialState.composerState.eventSink(MessageComposerEvents.FocusChanged(true))
|
||||
initialState.composerState.richTextEditorState.requestFocus()
|
||||
val focusedState = awaitItem()
|
||||
assertThat(focusedState.showReinvitePrompt).isFalse()
|
||||
}
|
||||
|
|
@ -605,6 +606,8 @@ class MessagesPresenterTest {
|
|||
snackbarDispatcher = SnackbarDispatcher(),
|
||||
analyticsService = FakeAnalyticsService(),
|
||||
messageComposerContext = MessageComposerContextImpl(),
|
||||
richTextEditorStateFactory = TestRichTextEditorStateFactory(),
|
||||
|
||||
)
|
||||
val timelinePresenter = TimelinePresenter(
|
||||
timelineItemsFactory = aTimelineItemsFactory(),
|
||||
|
|
|
|||
|
|
@ -53,6 +53,7 @@ import io.element.android.libraries.mediaupload.api.MediaPreProcessor
|
|||
import io.element.android.libraries.mediaupload.api.MediaSender
|
||||
import io.element.android.libraries.mediaupload.api.MediaUploadInfo
|
||||
import io.element.android.libraries.mediaupload.test.FakeMediaPreProcessor
|
||||
import io.element.android.libraries.textcomposer.Message
|
||||
import io.element.android.libraries.textcomposer.MessageComposerMode
|
||||
import io.element.android.services.analytics.test.FakeAnalyticsService
|
||||
import io.element.android.tests.testutils.WarmUpRule
|
||||
|
|
@ -80,6 +81,7 @@ class MessageComposerPresenterTest {
|
|||
private val snackbarDispatcher = SnackbarDispatcher()
|
||||
private val mockMediaUrl: Uri = mockk("localMediaUri")
|
||||
private val localMediaFactory = FakeLocalMediaFactory(mockMediaUrl)
|
||||
private val analyticsService = FakeAnalyticsService()
|
||||
|
||||
@Test
|
||||
fun `present - initial state`() = runTest {
|
||||
|
|
@ -90,12 +92,12 @@ class MessageComposerPresenterTest {
|
|||
skipItems(1)
|
||||
val initialState = awaitItem()
|
||||
assertThat(initialState.isFullScreen).isFalse()
|
||||
assertThat(initialState.text).isEqualTo("")
|
||||
assertThat(initialState.richTextEditorState.messageHtml).isEqualTo("")
|
||||
assertThat(initialState.mode).isEqualTo(MessageComposerMode.Normal(""))
|
||||
assertThat(initialState.showAttachmentSourcePicker).isFalse()
|
||||
assertThat(initialState.canShareLocation).isTrue()
|
||||
assertThat(initialState.attachmentsState).isEqualTo(AttachmentsState.None)
|
||||
assertThat(initialState.isSendButtonVisible).isFalse()
|
||||
assertThat(initialState.canSendMessage).isFalse()
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -124,14 +126,14 @@ class MessageComposerPresenterTest {
|
|||
}.test {
|
||||
skipItems(1)
|
||||
val initialState = awaitItem()
|
||||
initialState.eventSink.invoke(MessageComposerEvents.UpdateText(A_MESSAGE))
|
||||
initialState.richTextEditorState.setHtml(A_MESSAGE)
|
||||
val withMessageState = awaitItem()
|
||||
assertThat(withMessageState.text).isEqualTo(A_MESSAGE)
|
||||
assertThat(withMessageState.isSendButtonVisible).isTrue()
|
||||
withMessageState.eventSink.invoke(MessageComposerEvents.UpdateText(""))
|
||||
assertThat(withMessageState.richTextEditorState.messageHtml).isEqualTo(A_MESSAGE)
|
||||
assertThat(withMessageState.canSendMessage).isTrue()
|
||||
withMessageState.richTextEditorState.setHtml("")
|
||||
val withEmptyMessageState = awaitItem()
|
||||
assertThat(withEmptyMessageState.text).isEqualTo("")
|
||||
assertThat(withEmptyMessageState.isSendButtonVisible).isFalse()
|
||||
assertThat(withEmptyMessageState.richTextEditorState.messageHtml).isEqualTo("")
|
||||
assertThat(withEmptyMessageState.canSendMessage).isFalse()
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -148,8 +150,8 @@ class MessageComposerPresenterTest {
|
|||
state = awaitItem()
|
||||
assertThat(state.mode).isEqualTo(mode)
|
||||
state = awaitItem()
|
||||
assertThat(state.text).isEqualTo(A_MESSAGE)
|
||||
assertThat(state.isSendButtonVisible).isTrue()
|
||||
assertThat(state.richTextEditorState.messageHtml).isEqualTo(A_MESSAGE)
|
||||
assertThat(state.canSendMessage).isTrue()
|
||||
backToNormalMode(state, skipCount = 1)
|
||||
}
|
||||
}
|
||||
|
|
@ -166,8 +168,8 @@ class MessageComposerPresenterTest {
|
|||
state.eventSink.invoke(MessageComposerEvents.SetMode(mode))
|
||||
state = awaitItem()
|
||||
assertThat(state.mode).isEqualTo(mode)
|
||||
assertThat(state.text).isEqualTo("")
|
||||
assertThat(state.isSendButtonVisible).isFalse()
|
||||
assertThat(state.richTextEditorState.messageHtml).isEqualTo("")
|
||||
assertThat(state.canSendMessage).isFalse()
|
||||
backToNormalMode(state)
|
||||
}
|
||||
}
|
||||
|
|
@ -184,8 +186,8 @@ class MessageComposerPresenterTest {
|
|||
state.eventSink.invoke(MessageComposerEvents.SetMode(mode))
|
||||
state = awaitItem()
|
||||
assertThat(state.mode).isEqualTo(mode)
|
||||
assertThat(state.text).isEqualTo("")
|
||||
assertThat(state.isSendButtonVisible).isFalse()
|
||||
assertThat(state.richTextEditorState.messageHtml).isEqualTo("")
|
||||
assertThat(state.canSendMessage).isFalse()
|
||||
backToNormalMode(state)
|
||||
}
|
||||
}
|
||||
|
|
@ -198,14 +200,14 @@ class MessageComposerPresenterTest {
|
|||
}.test {
|
||||
skipItems(1)
|
||||
val initialState = awaitItem()
|
||||
initialState.eventSink.invoke(MessageComposerEvents.UpdateText(A_MESSAGE))
|
||||
initialState.richTextEditorState.setHtml(A_MESSAGE)
|
||||
val withMessageState = awaitItem()
|
||||
assertThat(withMessageState.text).isEqualTo(A_MESSAGE)
|
||||
assertThat(withMessageState.isSendButtonVisible).isTrue()
|
||||
withMessageState.eventSink.invoke(MessageComposerEvents.SendMessage(A_MESSAGE))
|
||||
assertThat(withMessageState.richTextEditorState.messageHtml).isEqualTo(A_MESSAGE)
|
||||
assertThat(withMessageState.canSendMessage).isTrue()
|
||||
withMessageState.eventSink.invoke(MessageComposerEvents.SendMessage(A_MESSAGE.toMessage()))
|
||||
val messageSentState = awaitItem()
|
||||
assertThat(messageSentState.text).isEqualTo("")
|
||||
assertThat(messageSentState.isSendButtonVisible).isFalse()
|
||||
assertThat(messageSentState.richTextEditorState.messageHtml).isEqualTo("")
|
||||
assertThat(messageSentState.canSendMessage).isFalse()
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -221,23 +223,23 @@ class MessageComposerPresenterTest {
|
|||
}.test {
|
||||
skipItems(1)
|
||||
val initialState = awaitItem()
|
||||
assertThat(initialState.text).isEqualTo("")
|
||||
assertThat(initialState.richTextEditorState.messageHtml).isEqualTo("")
|
||||
val mode = anEditMode()
|
||||
initialState.eventSink.invoke(MessageComposerEvents.SetMode(mode))
|
||||
skipItems(1)
|
||||
val withMessageState = awaitItem()
|
||||
assertThat(withMessageState.mode).isEqualTo(mode)
|
||||
assertThat(withMessageState.text).isEqualTo(A_MESSAGE)
|
||||
assertThat(withMessageState.isSendButtonVisible).isTrue()
|
||||
withMessageState.eventSink.invoke(MessageComposerEvents.UpdateText(ANOTHER_MESSAGE))
|
||||
assertThat(withMessageState.richTextEditorState.messageHtml).isEqualTo(A_MESSAGE)
|
||||
assertThat(withMessageState.canSendMessage).isTrue()
|
||||
withMessageState.richTextEditorState.setHtml(ANOTHER_MESSAGE)
|
||||
val withEditedMessageState = awaitItem()
|
||||
assertThat(withEditedMessageState.text).isEqualTo(ANOTHER_MESSAGE)
|
||||
withEditedMessageState.eventSink.invoke(MessageComposerEvents.SendMessage(ANOTHER_MESSAGE))
|
||||
assertThat(withEditedMessageState.richTextEditorState.messageHtml).isEqualTo(ANOTHER_MESSAGE)
|
||||
withEditedMessageState.eventSink.invoke(MessageComposerEvents.SendMessage(ANOTHER_MESSAGE.toMessage()))
|
||||
skipItems(1)
|
||||
val messageSentState = awaitItem()
|
||||
assertThat(messageSentState.text).isEqualTo("")
|
||||
assertThat(messageSentState.isSendButtonVisible).isFalse()
|
||||
assertThat(fakeMatrixRoom.editMessageCalls.first()).isEqualTo(ANOTHER_MESSAGE)
|
||||
assertThat(messageSentState.richTextEditorState.messageHtml).isEqualTo("")
|
||||
assertThat(messageSentState.canSendMessage).isFalse()
|
||||
assertThat(fakeMatrixRoom.editMessageCalls.first()).isEqualTo(ANOTHER_MESSAGE to ANOTHER_MESSAGE)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -253,23 +255,23 @@ class MessageComposerPresenterTest {
|
|||
}.test {
|
||||
skipItems(1)
|
||||
val initialState = awaitItem()
|
||||
assertThat(initialState.text).isEqualTo("")
|
||||
assertThat(initialState.richTextEditorState.messageHtml).isEqualTo("")
|
||||
val mode = anEditMode(eventId = null, transactionId = A_TRANSACTION_ID)
|
||||
initialState.eventSink.invoke(MessageComposerEvents.SetMode(mode))
|
||||
skipItems(1)
|
||||
val withMessageState = awaitItem()
|
||||
assertThat(withMessageState.mode).isEqualTo(mode)
|
||||
assertThat(withMessageState.text).isEqualTo(A_MESSAGE)
|
||||
assertThat(withMessageState.isSendButtonVisible).isTrue()
|
||||
withMessageState.eventSink.invoke(MessageComposerEvents.UpdateText(ANOTHER_MESSAGE))
|
||||
assertThat(withMessageState.richTextEditorState.messageHtml).isEqualTo(A_MESSAGE)
|
||||
assertThat(withMessageState.canSendMessage).isTrue()
|
||||
withMessageState.richTextEditorState.setHtml(ANOTHER_MESSAGE)
|
||||
val withEditedMessageState = awaitItem()
|
||||
assertThat(withEditedMessageState.text).isEqualTo(ANOTHER_MESSAGE)
|
||||
withEditedMessageState.eventSink.invoke(MessageComposerEvents.SendMessage(ANOTHER_MESSAGE))
|
||||
assertThat(withEditedMessageState.richTextEditorState.messageHtml).isEqualTo(ANOTHER_MESSAGE)
|
||||
withEditedMessageState.eventSink.invoke(MessageComposerEvents.SendMessage(ANOTHER_MESSAGE.toMessage()))
|
||||
skipItems(1)
|
||||
val messageSentState = awaitItem()
|
||||
assertThat(messageSentState.text).isEqualTo("")
|
||||
assertThat(messageSentState.isSendButtonVisible).isFalse()
|
||||
assertThat(fakeMatrixRoom.editMessageCalls.first()).isEqualTo(ANOTHER_MESSAGE)
|
||||
assertThat(messageSentState.richTextEditorState.messageHtml).isEqualTo("")
|
||||
assertThat(messageSentState.canSendMessage).isFalse()
|
||||
assertThat(fakeMatrixRoom.editMessageCalls.first()).isEqualTo(ANOTHER_MESSAGE to ANOTHER_MESSAGE)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -285,23 +287,23 @@ class MessageComposerPresenterTest {
|
|||
}.test {
|
||||
skipItems(1)
|
||||
val initialState = awaitItem()
|
||||
assertThat(initialState.text).isEqualTo("")
|
||||
assertThat(initialState.richTextEditorState.messageHtml).isEqualTo("")
|
||||
val mode = aReplyMode()
|
||||
initialState.eventSink.invoke(MessageComposerEvents.SetMode(mode))
|
||||
val state = awaitItem()
|
||||
assertThat(state.mode).isEqualTo(mode)
|
||||
assertThat(state.text).isEqualTo("")
|
||||
assertThat(state.isSendButtonVisible).isFalse()
|
||||
initialState.eventSink.invoke(MessageComposerEvents.UpdateText(A_REPLY))
|
||||
assertThat(state.richTextEditorState.messageHtml).isEqualTo("")
|
||||
assertThat(state.canSendMessage).isFalse()
|
||||
state.richTextEditorState.setHtml(A_REPLY)
|
||||
val withMessageState = awaitItem()
|
||||
assertThat(withMessageState.text).isEqualTo(A_REPLY)
|
||||
assertThat(withMessageState.isSendButtonVisible).isTrue()
|
||||
withMessageState.eventSink.invoke(MessageComposerEvents.SendMessage(A_REPLY))
|
||||
assertThat(withMessageState.richTextEditorState.messageHtml).isEqualTo(A_REPLY)
|
||||
assertThat(withMessageState.canSendMessage).isTrue()
|
||||
withMessageState.eventSink.invoke(MessageComposerEvents.SendMessage(A_REPLY.toMessage()))
|
||||
skipItems(1)
|
||||
val messageSentState = awaitItem()
|
||||
assertThat(messageSentState.text).isEqualTo("")
|
||||
assertThat(messageSentState.isSendButtonVisible).isFalse()
|
||||
assertThat(fakeMatrixRoom.replyMessageParameter).isEqualTo(A_REPLY)
|
||||
assertThat(messageSentState.richTextEditorState.messageHtml).isEqualTo("")
|
||||
assertThat(messageSentState.canSendMessage).isFalse()
|
||||
assertThat(fakeMatrixRoom.replyMessageParameter).isEqualTo(A_REPLY to A_REPLY)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -523,13 +525,27 @@ class MessageComposerPresenterTest {
|
|||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `present - errors are tracked`() = runTest {
|
||||
val testException = Exception("Test error")
|
||||
val presenter = createPresenter(this)
|
||||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
presenter.present()
|
||||
}.test {
|
||||
skipItems(1)
|
||||
val initialState = awaitItem()
|
||||
initialState.eventSink(MessageComposerEvents.Error(testException))
|
||||
assertThat(analyticsService.trackedErrors).containsExactly(testException)
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun ReceiveTurbine<MessageComposerState>.backToNormalMode(state: MessageComposerState, skipCount: Int = 0) {
|
||||
state.eventSink.invoke(MessageComposerEvents.CloseSpecialMode)
|
||||
skipItems(skipCount)
|
||||
val normalState = awaitItem()
|
||||
assertThat(normalState.mode).isEqualTo(MessageComposerMode.Normal(""))
|
||||
assertThat(normalState.text).isEqualTo("")
|
||||
assertThat(normalState.isSendButtonVisible).isFalse()
|
||||
assertThat(normalState.richTextEditorState.messageHtml).isEqualTo("")
|
||||
assertThat(normalState.canSendMessage).isFalse()
|
||||
}
|
||||
|
||||
private fun createPresenter(
|
||||
|
|
@ -547,8 +563,9 @@ class MessageComposerPresenterTest {
|
|||
localMediaFactory,
|
||||
MediaSender(mediaPreProcessor, room),
|
||||
snackbarDispatcher,
|
||||
FakeAnalyticsService(),
|
||||
analyticsService,
|
||||
MessageComposerContextImpl(),
|
||||
TestRichTextEditorStateFactory(),
|
||||
)
|
||||
}
|
||||
|
||||
|
|
@ -560,3 +577,8 @@ fun anEditMode(
|
|||
|
||||
fun aReplyMode() = MessageComposerMode.Reply(A_USER_NAME, null, AN_EVENT_ID, A_MESSAGE)
|
||||
fun aQuoteMode() = MessageComposerMode.Quote(AN_EVENT_ID, A_MESSAGE)
|
||||
|
||||
private fun String.toMessage() = Message(
|
||||
html = this,
|
||||
markdown = this,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -0,0 +1,29 @@
|
|||
/*
|
||||
* Copyright (c) 2023 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.textcomposer
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import io.element.android.features.messages.impl.messagecomposer.RichTextEditorStateFactory
|
||||
import io.element.android.wysiwyg.compose.RichTextEditorState
|
||||
import io.element.android.wysiwyg.compose.rememberRichTextEditorState
|
||||
|
||||
class TestRichTextEditorStateFactory : RichTextEditorStateFactory {
|
||||
@Composable
|
||||
override fun create(): RichTextEditorState {
|
||||
return rememberRichTextEditorState("", fake = true)
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue