[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:
jonnyandrew
2023-09-07 16:21:29 +01:00
committed by GitHub
parent 9bb6d9b66d
commit 11c1fca92e
62 changed files with 441 additions and 289 deletions

View File

@@ -22,6 +22,16 @@ jobs:
group: ${{ github.ref == 'refs/heads/main' && format('unit-tests-main-{0}', github.sha) || github.ref == 'refs/heads/develop' && format('unit-tests-develop-{0}', github.sha) || format('unit-tests-{0}', github.ref) }}
cancel-in-progress: true
steps:
# Increase swapfile size to prevent screenshot tests getting terminated
# https://github.com/actions/runner-images/discussions/7188#discussioncomment-6750749
- name: 💽 Increase swapfile size
run: |
sudo swapoff -a
sudo fallocate -l 8G /mnt/swapfile
sudo chmod 600 /mnt/swapfile
sudo mkswap /mnt/swapfile
sudo swapon /mnt/swapfile
sudo swapon --show
- name: ⏬ Checkout with LFS
uses: nschloe/action-cached-lfs-checkout@v1.2.2
with:

2
changelog.d/1172.feature Normal file
View File

@@ -0,0 +1,2 @@
[Rich text editor] Integrate rich text editor library. Note that markdown is now not supported and further formatting support will be introduced through the rich text editor.

View File

@@ -25,5 +25,5 @@ android {
dependencies {
implementation(projects.libraries.architecture)
implementation(projects.libraries.matrix.api)
api(projects.libraries.textcomposer)
api(projects.libraries.textcomposer.impl)
}

View File

@@ -41,7 +41,7 @@ dependencies {
implementation(projects.libraries.matrix.api)
implementation(projects.libraries.matrixui)
implementation(projects.libraries.designsystem)
implementation(projects.libraries.textcomposer)
implementation(projects.libraries.textcomposer.impl)
implementation(projects.libraries.uiStrings)
implementation(projects.libraries.dateformatter.api)
implementation(projects.libraries.eventformatter.api)
@@ -76,6 +76,7 @@ dependencies {
testImplementation(projects.libraries.featureflag.test)
testImplementation(projects.libraries.mediaupload.test)
testImplementation(projects.libraries.mediapickers.test)
testImplementation(projects.libraries.textcomposer.test)
testImplementation(libs.test.mockk)
ksp(libs.showkase.processor)

View File

@@ -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(

View File

@@ -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"),
),

View File

@@ -70,7 +70,7 @@ class ActionListPresenter @Inject constructor(
return ActionListState(
target = target.value,
displayEmojiReactions = displayEmojiReactions,
eventSink = ::handleEvents
eventSink = { handleEvents(it) }
)
}

View File

@@ -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
}

View File

@@ -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,
)
}
}

View File

@@ -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

View File

@@ -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,

View File

@@ -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,
)
}
}

View File

@@ -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()
}
}

View File

@@ -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) }
)
}

View File

@@ -63,7 +63,7 @@ class CustomReactionPresenter @Inject constructor(
return CustomReactionState(
target = target.value,
selectedEmoji = selectedEmoji,
eventSink = ::handleEvents
eventSink = { handleEvents(it) }
)
}
}

View File

@@ -61,7 +61,7 @@ class ReactionSummaryPresenter @Inject constructor(
}
return ReactionSummaryState(
target = targetWithAvatars.value,
eventSink = ::handleEvents
eventSink = { handleEvents(it) }
)
}

View File

@@ -66,7 +66,7 @@ class RetrySendMenuPresenter @Inject constructor(
return RetrySendMenuState(
selectedEvent = selectedEvent,
eventSink = ::handleEvent,
eventSink = { handleEvent(it) },
)
}
}

View File

@@ -22,4 +22,6 @@ sealed interface TimelineItemTextBasedContent : TimelineItemEventContent {
val body: String
val htmlDocument: Document?
val isEdited: Boolean
val htmlBody: String?
get() = htmlDocument?.body()?.html()
}

View File

@@ -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(),

View File

@@ -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,
)

View File

@@ -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)
}
}

View File

@@ -46,6 +46,7 @@ dependencyanalysis = "1.21.0"
stem = "2.3.0"
sqldelight = "1.5.5"
telephoto = "0.6.0"
wysiwyg = "2.9.0"
# DI
dagger = "2.48"
@@ -147,6 +148,8 @@ appyx_core = { module = "com.bumble.appyx:core", version.ref = "appyx" }
molecule-runtime = { module = "app.cash.molecule:molecule-runtime", version.ref = "molecule" }
timber = "com.jakewharton.timber:timber:5.0.1"
matrix_sdk = "org.matrix.rustcomponents:sdk-android:0.1.49"
matrix_richtexteditor = { module = "io.element.android:wysiwyg", version.ref = "wysiwyg" }
matrix_richtexteditor_compose = { module = "io.element.android:wysiwyg-compose", version.ref = "wysiwyg" }
sqldelight-driver-android = { module = "com.squareup.sqldelight:android-driver", version.ref = "sqldelight" }
sqldelight-driver-jvm = { module = "com.squareup.sqldelight:sqlite-driver", version.ref = "sqldelight" }
sqldelight-coroutines = { module = "com.squareup.sqldelight:coroutines-extensions", version.ref = "sqldelight" }

View File

@@ -79,11 +79,11 @@ interface MatrixRoom : Closeable {
suspend fun userAvatarUrl(userId: UserId): Result<String?>
suspend fun sendMessage(message: String): Result<Unit>
suspend fun sendMessage(body: String, htmlBody: String): Result<Unit>
suspend fun editMessage(originalEventId: EventId?, transactionId: TransactionId?, message: String): Result<Unit>
suspend fun editMessage(originalEventId: EventId?, transactionId: TransactionId?, body: String, htmlBody: String): Result<Unit>
suspend fun replyMessage(eventId: EventId, message: String): Result<Unit>
suspend fun replyMessage(eventId: EventId, body: String, htmlBody: String): Result<Unit>
suspend fun redactEvent(eventId: EventId, reason: String? = null): Result<Unit>

View File

@@ -66,7 +66,7 @@ import org.matrix.rustcomponents.sdk.RoomMember
import org.matrix.rustcomponents.sdk.RoomSubscription
import org.matrix.rustcomponents.sdk.SendAttachmentJoinHandle
import org.matrix.rustcomponents.sdk.genTransactionId
import org.matrix.rustcomponents.sdk.messageEventContentFromMarkdown
import org.matrix.rustcomponents.sdk.messageEventContentFromHtml
import timber.log.Timber
import java.io.File
@@ -227,31 +227,32 @@ class RustMatrixRoom(
}
}
override suspend fun sendMessage(message: String): Result<Unit> = withContext(roomDispatcher) {
override suspend fun sendMessage(body: String, htmlBody: String): Result<Unit> = withContext(roomDispatcher) {
val transactionId = genTransactionId()
messageEventContentFromMarkdown(message).use { content ->
messageEventContentFromHtml(body, htmlBody).use { content ->
runCatching {
innerRoom.send(content, transactionId)
}
}
}
override suspend fun editMessage(originalEventId: EventId?, transactionId: TransactionId?, message: String): Result<Unit> = withContext(roomDispatcher) {
if (originalEventId != null) {
runCatching {
innerRoom.edit(messageEventContentFromMarkdown(message), originalEventId.value, transactionId?.value)
}
} else {
runCatching {
transactionId?.let { cancelSend(it) }
innerRoom.send(messageEventContentFromMarkdown(message), genTransactionId())
override suspend fun editMessage(originalEventId: EventId?, transactionId: TransactionId?, body: String, htmlBody: String): Result<Unit> =
withContext(roomDispatcher) {
if (originalEventId != null) {
runCatching {
innerRoom.edit(messageEventContentFromHtml(body, htmlBody), originalEventId.value, transactionId?.value)
}
} else {
runCatching {
transactionId?.let { cancelSend(it) }
innerRoom.send(messageEventContentFromHtml(body, htmlBody), genTransactionId())
}
}
}
}
override suspend fun replyMessage(eventId: EventId, message: String): Result<Unit> = withContext(roomDispatcher) {
override suspend fun replyMessage(eventId: EventId, body: String, htmlBody: String): Result<Unit> = withContext(roomDispatcher) {
runCatching {
innerRoom.sendReply(messageEventContentFromMarkdown(message), eventId.value, genTransactionId())
innerRoom.sendReply(messageEventContentFromHtml(body, htmlBody), eventId.value, genTransactionId())
}
}

View File

@@ -92,7 +92,7 @@ class FakeMatrixRoom(
private var sendPollResponseResult = Result.success(Unit)
private var endPollResult = Result.success(Unit)
private var progressCallbackValues = emptyList<Pair<Long, Long>>()
val editMessageCalls = mutableListOf<String>()
val editMessageCalls = mutableListOf<Pair<String, String>>()
var sendMediaCount = 0
private set
@@ -171,7 +171,7 @@ class FakeMatrixRoom(
userAvatarUrlResult
}
override suspend fun sendMessage(message: String): Result<Unit> = simulateLongTask {
override suspend fun sendMessage(body: String, htmlBody: String) = simulateLongTask {
Result.success(Unit)
}
@@ -200,16 +200,16 @@ class FakeMatrixRoom(
return cancelSendResult
}
override suspend fun editMessage(originalEventId: EventId?, transactionId: TransactionId?, message: String): Result<Unit> {
editMessageCalls += message
override suspend fun editMessage(originalEventId: EventId?, transactionId: TransactionId?, body: String, htmlBody: String): Result<Unit> {
editMessageCalls += body to htmlBody
return Result.success(Unit)
}
var replyMessageParameter: String? = null
var replyMessageParameter: Pair<String, String>? = null
private set
override suspend fun replyMessage(eventId: EventId, message: String): Result<Unit> {
replyMessageParameter = message
override suspend fun replyMessage(eventId: EventId, body: String, htmlBody: String): Result<Unit> {
replyMessageParameter = body to htmlBody
return Result.success(Unit)
}

View File

@@ -124,7 +124,7 @@ class DefaultPermissionsPresenter @AssistedInject constructor(
showDialog = showDialog.value,
permissionAlreadyAsked = isAlreadyAsked,
permissionAlreadyDenied = isAlreadyDenied,
eventSink = ::handleEvents
eventSink = { handleEvents(it) }
)
}

View File

@@ -1,5 +1,5 @@
/*
* Copyright (c) 2022 New Vector Ltd
* 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.
@@ -31,5 +31,9 @@ dependencies {
implementation(projects.libraries.matrix.api)
implementation(projects.libraries.matrixui)
implementation(projects.libraries.designsystem)
implementation(libs.matrix.richtexteditor)
api(libs.matrix.richtexteditor.compose)
ksp(libs.showkase.processor)
}

View File

@@ -0,0 +1,22 @@
/*
* 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.libraries.textcomposer
data class Message(
val html: String,
val markdown: String,
)

View File

@@ -37,38 +37,25 @@ import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.BasicTextField
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Close
import androidx.compose.material.ripple.rememberRipple
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.LocalContentColor
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.TextFieldDefaults
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.ExperimentalComposeUiApi
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.focus.onFocusEvent
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.ColorFilter
import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.input.KeyboardCapitalization
import androidx.compose.ui.text.input.VisualTransformation
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
@@ -88,23 +75,23 @@ import io.element.android.libraries.matrix.ui.components.AttachmentThumbnailInfo
import io.element.android.libraries.matrix.ui.components.AttachmentThumbnailType
import io.element.android.libraries.theme.ElementTheme
import io.element.android.libraries.ui.strings.CommonStrings
import io.element.android.wysiwyg.compose.RichTextEditor
import io.element.android.wysiwyg.compose.RichTextEditorDefaults
import io.element.android.wysiwyg.compose.RichTextEditorState
import kotlinx.coroutines.android.awaitFrame
@OptIn(ExperimentalMaterial3Api::class, ExperimentalComposeUiApi::class)
@Composable
fun TextComposer(
composerText: String?,
state: RichTextEditorState,
composerMode: MessageComposerMode,
composerCanSendMessage: Boolean,
canSendMessage: Boolean,
modifier: Modifier = Modifier,
focusRequester: FocusRequester = FocusRequester(),
onSendMessage: (String) -> Unit = {},
onRequestFocus: () -> Unit = {},
onSendMessage: (Message) -> Unit = {},
onResetComposerMode: () -> Unit = {},
onComposerTextChange: (String) -> Unit = {},
onAddAttachment: () -> Unit = {},
onFocusChanged: (Boolean) -> Unit = {},
onError: (Throwable) -> Unit = {},
) {
val text = composerText.orEmpty()
Row(
modifier.padding(
horizontal = 12.dp,
@@ -115,10 +102,9 @@ fun TextComposer(
Spacer(modifier = Modifier.width(12.dp))
val roundCornerSmall = 20.dp.applyScaleUp()
val roundCornerLarge = 28.dp.applyScaleUp()
var lineCount by remember { mutableIntStateOf(0) }
val roundedCornerSize = remember(lineCount, composerMode) {
if (lineCount > 1 || composerMode is MessageComposerMode.Special) {
val roundedCornerSize = remember(state.lineCount, composerMode) {
if (state.lineCount > 1 || composerMode is MessageComposerMode.Special) {
roundCornerSmall
} else {
roundCornerLarge
@@ -132,10 +118,15 @@ fun TextComposer(
)
val roundedCorners = RoundedCornerShape(roundedCornerSizeState.value)
val minHeight = 42.dp.applyScaleUp()
val bgColor = ElementTheme.colors.bgSubtleSecondary
// Change border color depending on focus
var hasFocus by remember { mutableStateOf(false) }
val borderColor = if (hasFocus) ElementTheme.colors.borderDisabled else bgColor
val colors = ElementTheme.colors
val bgColor = colors.bgSubtleSecondary
val borderColor by remember(state.hasFocus, colors) {
derivedStateOf {
if (state.hasFocus) colors.borderDisabled else bgColor
}
}
Column(
modifier = Modifier
.fillMaxWidth()
@@ -147,66 +138,56 @@ fun TextComposer(
ComposerModeView(composerMode = composerMode, onResetComposerMode = onResetComposerMode)
}
val defaultTypography = ElementTheme.typography.fontBodyLgRegular
Box {
BasicTextField(
Box(
modifier = Modifier
.fillMaxWidth()
.heightIn(min = minHeight)
.focusRequester(focusRequester)
.onFocusEvent {
hasFocus = it.hasFocus
onFocusChanged(it.hasFocus)
},
value = text,
onValueChange = { onComposerTextChange(it) },
onTextLayout = {
lineCount = it.lineCount
},
keyboardOptions = KeyboardOptions(
capitalization = KeyboardCapitalization.Sentences,
),
textStyle = defaultTypography.copy(color = MaterialTheme.colorScheme.primary),
cursorBrush = SolidColor(ElementTheme.colors.iconAccentTertiary),
decorationBox = { innerTextField ->
TextFieldDefaults.DecorationBox(
value = text,
innerTextField = innerTextField,
enabled = true,
singleLine = false,
visualTransformation = VisualTransformation.None,
shape = roundedCorners,
contentPadding = PaddingValues(
top = 10.dp.applyScaleUp(),
bottom = 10.dp.applyScaleUp(),
.background(color = bgColor, shape = roundedCorners)
.padding(
PaddingValues(
top = 4.dp.applyScaleUp(),
bottom = 4.dp.applyScaleUp(),
start = 12.dp.applyScaleUp(),
end = 42.dp.applyScaleUp(),
),
interactionSource = remember { MutableInteractionSource() },
placeholder = {
Text(stringResource(CommonStrings.common_message), style = defaultTypography)
},
colors = TextFieldDefaults.colors(
unfocusedTextColor = MaterialTheme.colorScheme.secondary,
focusedTextColor = MaterialTheme.colorScheme.primary,
unfocusedPlaceholderColor = ElementTheme.colors.textDisabled,
focusedPlaceholderColor = ElementTheme.colors.textDisabled,
unfocusedIndicatorColor = Color.Transparent,
focusedIndicatorColor = Color.Transparent,
disabledIndicatorColor = Color.Transparent,
errorIndicatorColor = Color.Transparent,
unfocusedContainerColor = bgColor,
focusedContainerColor = bgColor,
errorContainerColor = bgColor,
disabledContainerColor = bgColor,
end = 42.dp.applyScaleUp()
)
),
contentAlignment = Alignment.CenterStart,
) {
// Placeholder
if (state.messageHtml.isEmpty()) {
Text(
stringResource(CommonStrings.common_message),
style = defaultTypography.copy(
color = ElementTheme.colors.textDisabled,
),
)
}
)
RichTextEditor(
state = state,
modifier = Modifier
.fillMaxWidth(),
style = RichTextEditorDefaults.style(
text = RichTextEditorDefaults.textStyle(
color = if (state.hasFocus) {
MaterialTheme.colorScheme.primary
} else {
MaterialTheme.colorScheme.secondary
}
),
cursor = RichTextEditorDefaults.cursorStyle(
color = ElementTheme.colors.iconAccentTertiary,
)
),
onError = onError
)
}
SendButton(
text = text,
canSendMessage = composerCanSendMessage,
onSendMessage = onSendMessage,
canSendMessage = canSendMessage,
onClick = { onSendMessage(Message(html = state.messageHtml, markdown = state.messageMarkdown)) },
composerMode = composerMode,
modifier = Modifier.padding(end = 6.dp.applyScaleUp(), bottom = 6.dp.applyScaleUp())
)
@@ -218,7 +199,7 @@ fun TextComposer(
val keyboard = LocalSoftwareKeyboardController.current
LaunchedEffect(composerMode) {
if (composerMode is MessageComposerMode.Special) {
focusRequester.requestFocus()
onRequestFocus()
keyboard?.let {
awaitFrame()
it.show()
@@ -241,7 +222,7 @@ private fun ComposerModeView(
ReplyToModeView(
modifier = modifier.padding(8.dp),
senderName = composerMode.senderName,
text = composerMode.defaultContent.toString(),
text = composerMode.defaultContent,
attachmentThumbnailInfo = composerMode.attachmentThumbnailInfo,
onResetComposerMode = onResetComposerMode,
)
@@ -385,9 +366,8 @@ private fun AttachmentButton(
@Composable
private fun BoxScope.SendButton(
text: String,
canSendMessage: Boolean,
onSendMessage: (String) -> Unit,
onClick: () -> Unit,
composerMode: MessageComposerMode,
modifier: Modifier = Modifier,
) {
@@ -405,9 +385,8 @@ private fun BoxScope.SendButton(
enabled = canSendMessage,
interactionSource = interactionSource,
indication = rememberRipple(bounded = false),
onClick = {
onSendMessage(text)
}),
onClick = onClick,
),
contentAlignment = Alignment.Center,
) {
val iconId = when (composerMode) {
@@ -433,28 +412,37 @@ private fun BoxScope.SendButton(
internal fun TextComposerSimplePreview() = ElementPreview {
Column {
TextComposer(
RichTextEditorState("", fake = true).apply { requestFocus() },
canSendMessage = false,
onSendMessage = {},
onComposerTextChange = {},
composerMode = MessageComposerMode.Normal(""),
onResetComposerMode = {},
composerCanSendMessage = false,
composerText = "",
)
TextComposer(
RichTextEditorState("A message", fake = true).apply { requestFocus() },
canSendMessage = true,
onSendMessage = {},
onComposerTextChange = {},
composerMode = MessageComposerMode.Normal(""),
onResetComposerMode = {},
composerCanSendMessage = true,
composerText = "A message",
)
TextComposer(
RichTextEditorState(
"A message\nWith several lines\nTo preview larger textfields and long lines with overflow",
fake = true
).apply {
requestFocus()
},
canSendMessage = true,
onSendMessage = {},
composerMode = MessageComposerMode.Normal(""),
onResetComposerMode = {},
)
TextComposer(
RichTextEditorState("A message without focus", fake = true),
canSendMessage = true,
onSendMessage = {},
onComposerTextChange = {},
composerMode = MessageComposerMode.Normal(""),
onResetComposerMode = {},
composerCanSendMessage = true,
composerText = "A message\nWith several lines\nTo preview larger textfields and long lines with overflow",
)
}
}
@@ -463,12 +451,11 @@ internal fun TextComposerSimplePreview() = ElementPreview {
@Composable
internal fun TextComposerEditPreview() = ElementPreview {
TextComposer(
RichTextEditorState("A message", fake = true).apply { requestFocus() },
canSendMessage = true,
onSendMessage = {},
onComposerTextChange = {},
composerMode = MessageComposerMode.Edit(EventId("$1234"), "Some text", TransactionId("1234")),
onResetComposerMode = {},
composerCanSendMessage = true,
composerText = "A message",
)
}
@@ -477,8 +464,9 @@ internal fun TextComposerEditPreview() = ElementPreview {
internal fun TextComposerReplyPreview() = ElementPreview {
Column {
TextComposer(
RichTextEditorState("", fake = true),
canSendMessage = false,
onSendMessage = {},
onComposerTextChange = {},
composerMode = MessageComposerMode.Reply(
senderName = "Alice",
eventId = EventId("$1234"),
@@ -488,12 +476,11 @@ internal fun TextComposerReplyPreview() = ElementPreview {
"To preview larger textfields and long lines with overflow"
),
onResetComposerMode = {},
composerCanSendMessage = true,
composerText = "A message",
)
TextComposer(
RichTextEditorState("A message", fake = true),
canSendMessage = true,
onSendMessage = {},
onComposerTextChange = {},
composerMode = MessageComposerMode.Reply(
senderName = "Alice",
eventId = EventId("$1234"),
@@ -506,12 +493,11 @@ internal fun TextComposerReplyPreview() = ElementPreview {
defaultContent = "image.jpg"
),
onResetComposerMode = {},
composerCanSendMessage = true,
composerText = "A message",
)
TextComposer(
RichTextEditorState("A message", fake = true),
canSendMessage = true,
onSendMessage = {},
onComposerTextChange = {},
composerMode = MessageComposerMode.Reply(
senderName = "Alice",
eventId = EventId("$1234"),
@@ -524,12 +510,11 @@ internal fun TextComposerReplyPreview() = ElementPreview {
defaultContent = "video.mp4"
),
onResetComposerMode = {},
composerCanSendMessage = true,
composerText = "A message",
)
TextComposer(
RichTextEditorState("A message", fake = true),
canSendMessage = true,
onSendMessage = {},
onComposerTextChange = {},
composerMode = MessageComposerMode.Reply(
senderName = "Alice",
eventId = EventId("$1234"),
@@ -542,12 +527,11 @@ internal fun TextComposerReplyPreview() = ElementPreview {
defaultContent = "logs.txt"
),
onResetComposerMode = {},
composerCanSendMessage = true,
composerText = "A message",
)
TextComposer(
RichTextEditorState("A message", fake = true).apply { requestFocus() },
canSendMessage = true,
onSendMessage = {},
onComposerTextChange = {},
composerMode = MessageComposerMode.Reply(
senderName = "Alice",
eventId = EventId("$1234"),
@@ -560,8 +544,6 @@ internal fun TextComposerReplyPreview() = ElementPreview {
defaultContent = "Shared location"
),
onResetComposerMode = {},
composerCanSendMessage = true,
composerText = "A message",
)
}
}

View File

@@ -0,0 +1,28 @@
/*
* 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.
*/
plugins {
id("io.element.android-compose-library")
}
android {
namespace = "io.element.android.libraries.textcomposer.test"
}
dependencies {
api(projects.libraries.textcomposer.impl)
implementation(projects.tests.testutils)
}

View File

@@ -99,7 +99,7 @@ fun DependencyHandlerScope.allLibrariesImpl() {
implementation(project(":libraries:mediapickers:impl"))
implementation(project(":libraries:mediaupload:impl"))
implementation(project(":libraries:usersearch:impl"))
implementation(project(":libraries:textcomposer"))
implementation(project(":libraries:textcomposer:impl"))
}
fun DependencyHandlerScope.allServicesImpl() {

View File

@@ -32,6 +32,7 @@ class FakeAnalyticsService(
private val isEnabledFlow = MutableStateFlow(isEnabled)
private val didAskUserConsentFlow = MutableStateFlow(didAskUserConsent)
val capturedEvents = mutableListOf<VectorAnalyticsEvent>()
val trackedErrors = mutableListOf<Throwable>()
override fun getAvailableAnalyticsProviders(): Set<AnalyticsProvider> = emptySet()
@@ -66,6 +67,7 @@ class FakeAnalyticsService(
}
override fun trackError(throwable: Throwable) {
trackedErrors += throwable
}
override suspend fun reset() {

View File

@@ -45,7 +45,7 @@
]
},
{
"name": ":libraries:textcomposer",
"name": ":libraries:textcomposer:impl",
"includeRegex": [
"rich_text_editor_.*"
]