Browse Source

[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>
pull/1255/head
jonnyandrew 1 year ago committed by GitHub
parent
commit
f214493c9d
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
  1. 10
      .github/workflows/tests.yml
  2. 2
      changelog.d/1172.feature
  3. 2
      features/messages/api/build.gradle.kts
  4. 3
      features/messages/impl/build.gradle.kts
  5. 6
      features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesPresenter.kt
  6. 5
      features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesStateProvider.kt
  7. 2
      features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/actionlist/ActionListPresenter.kt
  8. 6
      features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerEvents.kt
  9. 43
      features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerPresenter.kt
  10. 9
      features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerState.kt
  11. 8
      features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerStateProvider.kt
  12. 19
      features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerView.kt
  13. 38
      features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/RichTextEditorStateFactory.kt
  14. 5
      features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelinePresenter.kt
  15. 2
      features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/customreaction/CustomReactionPresenter.kt
  16. 2
      features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/reactionsummary/ReactionSummaryPresenter.kt
  17. 2
      features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/retrysendmenu/RetrySendMenuPresenter.kt
  18. 2
      features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemTextBasedContent.kt
  19. 11
      features/messages/impl/src/test/kotlin/io/element/android/features/messages/MessagesPresenterTest.kt
  20. 124
      features/messages/impl/src/test/kotlin/io/element/android/features/messages/textcomposer/MessageComposerPresenterTest.kt
  21. 29
      features/messages/impl/src/test/kotlin/io/element/android/features/messages/textcomposer/TestRichTextEditorStateFactory.kt
  22. 3
      gradle/libs.versions.toml
  23. 6
      libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/MatrixRoom.kt
  24. 31
      libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/RustMatrixRoom.kt
  25. 14
      libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/room/FakeMatrixRoom.kt
  26. 2
      libraries/permissions/impl/src/main/kotlin/io/element/android/libraries/permissions/impl/DefaultPermissionsPresenter.kt
  27. 6
      libraries/textcomposer/impl/build.gradle.kts
  28. 22
      libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/Message.kt
  29. 0
      libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/MessageComposerMode.kt
  30. 210
      libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/TextComposer.kt
  31. 0
      libraries/textcomposer/impl/src/main/res/drawable/ic_add_attachment.xml
  32. 0
      libraries/textcomposer/impl/src/main/res/drawable/ic_send.xml
  33. 0
      libraries/textcomposer/impl/src/main/res/drawable/ic_tick.xml
  34. 0
      libraries/textcomposer/impl/src/main/res/values-cs/translations.xml
  35. 0
      libraries/textcomposer/impl/src/main/res/values-de/translations.xml
  36. 0
      libraries/textcomposer/impl/src/main/res/values-ro/translations.xml
  37. 0
      libraries/textcomposer/impl/src/main/res/values-ru/translations.xml
  38. 0
      libraries/textcomposer/impl/src/main/res/values-sk/translations.xml
  39. 0
      libraries/textcomposer/impl/src/main/res/values-zh-rTW/translations.xml
  40. 0
      libraries/textcomposer/impl/src/main/res/values/localazy.xml
  41. 28
      libraries/textcomposer/test/build.gradle.kts
  42. 2
      plugins/src/main/kotlin/extension/DependencyHandleScope.kt
  43. 2
      services/analytics/test/src/main/kotlin/io/element/android/services/analytics/test/FakeAnalyticsService.kt
  44. BIN
      tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl.messagecomposer_null_MessageComposerViewDark_0_null_0,NEXUS_5,1.0,en].png
  45. BIN
      tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl.messagecomposer_null_MessageComposerViewLight_0_null_0,NEXUS_5,1.0,en].png
  46. BIN
      tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl_null_MessagesViewDark_0_null_0,NEXUS_5,1.0,en].png
  47. BIN
      tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl_null_MessagesViewDark_0_null_1,NEXUS_5,1.0,en].png
  48. BIN
      tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl_null_MessagesViewDark_0_null_2,NEXUS_5,1.0,en].png
  49. BIN
      tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl_null_MessagesViewDark_0_null_4,NEXUS_5,1.0,en].png
  50. BIN
      tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl_null_MessagesViewDark_0_null_5,NEXUS_5,1.0,en].png
  51. BIN
      tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl_null_MessagesViewLight_0_null_0,NEXUS_5,1.0,en].png
  52. BIN
      tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl_null_MessagesViewLight_0_null_1,NEXUS_5,1.0,en].png
  53. BIN
      tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl_null_MessagesViewLight_0_null_2,NEXUS_5,1.0,en].png
  54. BIN
      tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl_null_MessagesViewLight_0_null_4,NEXUS_5,1.0,en].png
  55. BIN
      tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl_null_MessagesViewLight_0_null_5,NEXUS_5,1.0,en].png
  56. BIN
      tests/uitests/src/test/snapshots/images/ui_S_t[l.textcomposer_null_TextComposerEdit-D-1_2_null,NEXUS_5,1.0,en].png
  57. BIN
      tests/uitests/src/test/snapshots/images/ui_S_t[l.textcomposer_null_TextComposerEdit-N-1_3_null,NEXUS_5,1.0,en].png
  58. BIN
      tests/uitests/src/test/snapshots/images/ui_S_t[l.textcomposer_null_TextComposerReply-D-2_3_null,NEXUS_5,1.0,en].png
  59. BIN
      tests/uitests/src/test/snapshots/images/ui_S_t[l.textcomposer_null_TextComposerReply-N-2_4_null,NEXUS_5,1.0,en].png
  60. BIN
      tests/uitests/src/test/snapshots/images/ui_S_t[l.textcomposer_null_TextComposerSimple-D-0_1_null,NEXUS_5,1.0,en].png
  61. BIN
      tests/uitests/src/test/snapshots/images/ui_S_t[l.textcomposer_null_TextComposerSimple-N-0_2_null,NEXUS_5,1.0,en].png
  62. 2
      tools/localazy/config.json

10
.github/workflows/tests.yml

@ -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) }} 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 cancel-in-progress: true
steps: 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 - name: ⏬ Checkout with LFS
uses: nschloe/action-cached-lfs-checkout@v1.2.2 uses: nschloe/action-cached-lfs-checkout@v1.2.2
with: with:

2
changelog.d/1172.feature

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

2
features/messages/api/build.gradle.kts

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

3
features/messages/impl/build.gradle.kts

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

6
features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesPresenter.kt

@ -175,7 +175,7 @@ class MessagesPresenter @AssistedInject constructor(
snackbarMessage = snackbarMessage, snackbarMessage = snackbarMessage,
showReinvitePrompt = showReinvitePrompt, showReinvitePrompt = showReinvitePrompt,
inviteProgress = inviteProgress.value, inviteProgress = inviteProgress.value,
eventSink = ::handleEvents eventSink = { handleEvents(it) }
) )
} }
@ -250,7 +250,9 @@ class MessagesPresenter @AssistedInject constructor(
private fun handleActionEdit(targetEvent: TimelineItem.Event, composerState: MessageComposerState) { private fun handleActionEdit(targetEvent: TimelineItem.Event, composerState: MessageComposerState) {
val composerMode = MessageComposerMode.Edit( val composerMode = MessageComposerMode.Edit(
targetEvent.eventId, targetEvent.eventId,
(targetEvent.content as? TimelineItemTextBasedContent)?.body.orEmpty(), (targetEvent.content as? TimelineItemTextBasedContent)?.let {
it.htmlBody ?: it.body
}.orEmpty(),
targetEvent.transactionId, targetEvent.transactionId,
) )
composerState.eventSink( composerState.eventSink(

5
features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesStateProvider.kt

@ -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.designsystem.components.avatar.AvatarSize
import io.element.android.libraries.matrix.api.core.RoomId import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.textcomposer.MessageComposerMode import io.element.android.libraries.textcomposer.MessageComposerMode
import io.element.android.wysiwyg.compose.RichTextEditorState
import kotlinx.collections.immutable.persistentSetOf import kotlinx.collections.immutable.persistentSetOf
open class MessagesStateProvider : PreviewParameterProvider<MessagesState> { open class MessagesStateProvider : PreviewParameterProvider<MessagesState> {
@ -54,7 +55,9 @@ fun aMessagesState() = MessagesState(
userHasPermissionToSendMessage = true, userHasPermissionToSendMessage = true,
userHasPermissionToRedact = false, userHasPermissionToRedact = false,
composerState = aMessageComposerState().copy( composerState = aMessageComposerState().copy(
text = "Hello", richTextEditorState = RichTextEditorState("Hello", fake = true).apply {
requestFocus()
},
isFullScreen = false, isFullScreen = false,
mode = MessageComposerMode.Normal("Hello"), mode = MessageComposerMode.Normal("Hello"),
), ),

2
features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/actionlist/ActionListPresenter.kt

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

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

@ -17,16 +17,15 @@
package io.element.android.features.messages.impl.messagecomposer package io.element.android.features.messages.impl.messagecomposer
import androidx.compose.runtime.Immutable import androidx.compose.runtime.Immutable
import io.element.android.libraries.textcomposer.Message
import io.element.android.libraries.textcomposer.MessageComposerMode import io.element.android.libraries.textcomposer.MessageComposerMode
@Immutable @Immutable
sealed interface MessageComposerEvents { sealed interface MessageComposerEvents {
data object ToggleFullScreenState : MessageComposerEvents data object ToggleFullScreenState : MessageComposerEvents
data class FocusChanged(val hasFocus: Boolean) : MessageComposerEvents data class SendMessage(val message: Message) : MessageComposerEvents
data class SendMessage(val message: String) : MessageComposerEvents
data object CloseSpecialMode : MessageComposerEvents data object CloseSpecialMode : MessageComposerEvents
data class SetMode(val composerMode: MessageComposerMode) : MessageComposerEvents data class SetMode(val composerMode: MessageComposerMode) : MessageComposerEvents
data class UpdateText(val text: String) : MessageComposerEvents
data object AddAttachment : MessageComposerEvents data object AddAttachment : MessageComposerEvents
data object DismissAttachmentMenu : MessageComposerEvents data object DismissAttachmentMenu : MessageComposerEvents
sealed interface PickAttachmentSource : MessageComposerEvents { sealed interface PickAttachmentSource : MessageComposerEvents {
@ -38,4 +37,5 @@ sealed interface MessageComposerEvents {
data object Poll : PickAttachmentSource data object Poll : PickAttachmentSource
} }
data object CancelSendAttachment : MessageComposerEvents data object CancelSendAttachment : MessageComposerEvents
data class Error(val error: Throwable) : MessageComposerEvents
} }

43
features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerPresenter.kt

@ -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.matrix.api.room.MatrixRoom
import io.element.android.libraries.mediapickers.api.PickerProvider import io.element.android.libraries.mediapickers.api.PickerProvider
import io.element.android.libraries.mediaupload.api.MediaSender 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.libraries.textcomposer.MessageComposerMode
import io.element.android.services.analytics.api.AnalyticsService import io.element.android.services.analytics.api.AnalyticsService
import io.element.android.wysiwyg.compose.RichTextEditorState
import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.persistentListOf
import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
@ -67,6 +69,7 @@ class MessageComposerPresenter @Inject constructor(
private val snackbarDispatcher: SnackbarDispatcher, private val snackbarDispatcher: SnackbarDispatcher,
private val analyticsService: AnalyticsService, private val analyticsService: AnalyticsService,
private val messageComposerContext: MessageComposerContextImpl, private val messageComposerContext: MessageComposerContextImpl,
private val richTextEditorStateFactory: RichTextEditorStateFactory,
) : Presenter<MessageComposerState> { ) : Presenter<MessageComposerState> {
@SuppressLint("UnsafeOptInUsageError") @SuppressLint("UnsafeOptInUsageError")
@ -103,19 +106,15 @@ class MessageComposerPresenter @Inject constructor(
val isFullScreen = rememberSaveable { val isFullScreen = rememberSaveable {
mutableStateOf(false) mutableStateOf(false)
} }
val hasFocus = remember { val richTextEditorState = richTextEditorStateFactory.create()
mutableStateOf(false)
}
val text: MutableState<String> = rememberSaveable {
mutableStateOf("")
}
val ongoingSendAttachmentJob = remember { mutableStateOf<Job?>(null) } val ongoingSendAttachmentJob = remember { mutableStateOf<Job?>(null) }
var showAttachmentSourcePicker: Boolean by remember { mutableStateOf(false) } var showAttachmentSourcePicker: Boolean by remember { mutableStateOf(false) }
LaunchedEffect(messageComposerContext.composerMode) { LaunchedEffect(messageComposerContext.composerMode) {
when (val modeValue = messageComposerContext.composerMode) { when (val modeValue = messageComposerContext.composerMode) {
is MessageComposerMode.Edit -> text.value = modeValue.defaultContent is MessageComposerMode.Edit ->
richTextEditorState.setHtml(modeValue.defaultContent)
else -> Unit else -> Unit
} }
} }
@ -136,18 +135,15 @@ class MessageComposerPresenter @Inject constructor(
when (event) { when (event) {
MessageComposerEvents.ToggleFullScreenState -> isFullScreen.value = !isFullScreen.value MessageComposerEvents.ToggleFullScreenState -> isFullScreen.value = !isFullScreen.value
is MessageComposerEvents.FocusChanged -> hasFocus.value = event.hasFocus
is MessageComposerEvents.UpdateText -> text.value = event.text
MessageComposerEvents.CloseSpecialMode -> { MessageComposerEvents.CloseSpecialMode -> {
text.value = "" richTextEditorState.setHtml("")
messageComposerContext.composerMode = MessageComposerMode.Normal("") messageComposerContext.composerMode = MessageComposerMode.Normal("")
} }
is MessageComposerEvents.SendMessage -> appCoroutineScope.sendMessage( is MessageComposerEvents.SendMessage -> appCoroutineScope.sendMessage(
text = event.message, message = event.message,
updateComposerMode = { messageComposerContext.composerMode = it }, updateComposerMode = { messageComposerContext.composerMode = it },
textState = text richTextEditorState = richTextEditorState,
) )
is MessageComposerEvents.SetMode -> { is MessageComposerEvents.SetMode -> {
messageComposerContext.composerMode = event.composerMode messageComposerContext.composerMode = event.composerMode
@ -194,43 +190,46 @@ class MessageComposerPresenter @Inject constructor(
ongoingSendAttachmentJob.value == null ongoingSendAttachmentJob.value == null
} }
} }
is MessageComposerEvents.Error -> {
analyticsService.trackError(event.error)
}
} }
} }
return MessageComposerState( return MessageComposerState(
text = text.value, richTextEditorState = richTextEditorState,
isFullScreen = isFullScreen.value, isFullScreen = isFullScreen.value,
hasFocus = hasFocus.value,
mode = messageComposerContext.composerMode, mode = messageComposerContext.composerMode,
showAttachmentSourcePicker = showAttachmentSourcePicker, showAttachmentSourcePicker = showAttachmentSourcePicker,
canShareLocation = canShareLocation.value, canShareLocation = canShareLocation.value,
canCreatePoll = canCreatePoll.value, canCreatePoll = canCreatePoll.value,
attachmentsState = attachmentsState.value, attachmentsState = attachmentsState.value,
eventSink = ::handleEvents eventSink = { handleEvents(it) }
) )
} }
private fun CoroutineScope.sendMessage( private fun CoroutineScope.sendMessage(
text: String, message: Message,
updateComposerMode: (newComposerMode: MessageComposerMode) -> Unit, updateComposerMode: (newComposerMode: MessageComposerMode) -> Unit,
textState: MutableState<String> richTextEditorState: RichTextEditorState,
) = launch { ) = launch {
val capturedMode = messageComposerContext.composerMode val capturedMode = messageComposerContext.composerMode
// Reset composer right away // Reset composer right away
textState.value = "" richTextEditorState.setHtml("")
updateComposerMode(MessageComposerMode.Normal("")) updateComposerMode(MessageComposerMode.Normal(""))
when (capturedMode) { when (capturedMode) {
is MessageComposerMode.Normal -> room.sendMessage(text) is MessageComposerMode.Normal -> room.sendMessage(body = message.markdown, htmlBody = message.html)
is MessageComposerMode.Edit -> { is MessageComposerMode.Edit -> {
val eventId = capturedMode.eventId val eventId = capturedMode.eventId
val transactionId = capturedMode.transactionId val transactionId = capturedMode.transactionId
room.editMessage(eventId, transactionId, text) room.editMessage(eventId, transactionId, message.markdown, message.html)
} }
is MessageComposerMode.Quote -> TODO() is MessageComposerMode.Quote -> TODO()
is MessageComposerMode.Reply -> room.replyMessage( is MessageComposerMode.Reply -> room.replyMessage(
capturedMode.eventId, capturedMode.eventId,
text message.markdown,
message.html,
) )
} }
} }

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

@ -19,21 +19,22 @@ package io.element.android.features.messages.impl.messagecomposer
import androidx.compose.runtime.Immutable import androidx.compose.runtime.Immutable
import io.element.android.features.messages.impl.attachments.Attachment import io.element.android.features.messages.impl.attachments.Attachment
import io.element.android.libraries.textcomposer.MessageComposerMode import io.element.android.libraries.textcomposer.MessageComposerMode
import io.element.android.wysiwyg.compose.RichTextEditorState
import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.ImmutableList
@Immutable @Immutable
data class MessageComposerState( data class MessageComposerState(
val text: String?, val richTextEditorState: RichTextEditorState,
val isFullScreen: Boolean, val isFullScreen: Boolean,
val hasFocus: Boolean,
val mode: MessageComposerMode, val mode: MessageComposerMode,
val showAttachmentSourcePicker: Boolean, val showAttachmentSourcePicker: Boolean,
val canShareLocation: Boolean, val canShareLocation: Boolean,
val canCreatePoll: Boolean, val canCreatePoll: Boolean,
val attachmentsState: AttachmentsState, 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 @Immutable

8
features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerStateProvider.kt

@ -18,6 +18,7 @@ package io.element.android.features.messages.impl.messagecomposer
import androidx.compose.ui.tooling.preview.PreviewParameterProvider import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import io.element.android.libraries.textcomposer.MessageComposerMode import io.element.android.libraries.textcomposer.MessageComposerMode
import io.element.android.wysiwyg.compose.RichTextEditorState
open class MessageComposerStateProvider : PreviewParameterProvider<MessageComposerState> { open class MessageComposerStateProvider : PreviewParameterProvider<MessageComposerState> {
override val values: Sequence<MessageComposerState> override val values: Sequence<MessageComposerState>
@ -27,18 +28,17 @@ open class MessageComposerStateProvider : PreviewParameterProvider<MessageCompos
} }
fun aMessageComposerState( fun aMessageComposerState(
text: String = "", requestFocus: Boolean = true,
composerState: RichTextEditorState = RichTextEditorState("", fake = true),
isFullScreen: Boolean = false, isFullScreen: Boolean = false,
hasFocus: Boolean = false,
mode: MessageComposerMode = MessageComposerMode.Normal(content = ""), mode: MessageComposerMode = MessageComposerMode.Normal(content = ""),
showAttachmentSourcePicker: Boolean = false, showAttachmentSourcePicker: Boolean = false,
canShareLocation: Boolean = true, canShareLocation: Boolean = true,
canCreatePoll: Boolean = true, canCreatePoll: Boolean = true,
attachmentsState: AttachmentsState = AttachmentsState.None, attachmentsState: AttachmentsState = AttachmentsState.None,
) = MessageComposerState( ) = MessageComposerState(
text = text, richTextEditorState = composerState.apply { if(requestFocus) requestFocus() },
isFullScreen = isFullScreen, isFullScreen = isFullScreen,
hasFocus = hasFocus,
mode = mode, mode = mode,
showAttachmentSourcePicker = showAttachmentSourcePicker, showAttachmentSourcePicker = showAttachmentSourcePicker,
canShareLocation = canShareLocation, canShareLocation = canShareLocation,

19
features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerView.kt

@ -23,6 +23,7 @@ import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.tooling.preview.PreviewParameter import androidx.compose.ui.tooling.preview.PreviewParameter
import io.element.android.libraries.designsystem.preview.ElementPreviewDark import io.element.android.libraries.designsystem.preview.ElementPreviewDark
import io.element.android.libraries.designsystem.preview.ElementPreviewLight import io.element.android.libraries.designsystem.preview.ElementPreviewLight
import io.element.android.libraries.textcomposer.Message
import io.element.android.libraries.textcomposer.TextComposer import io.element.android.libraries.textcomposer.TextComposer
@Composable @Composable
@ -36,7 +37,7 @@ fun MessageComposerView(
state.eventSink(MessageComposerEvents.ToggleFullScreenState) state.eventSink(MessageComposerEvents.ToggleFullScreenState)
} }
fun sendMessage(message: String) { fun sendMessage(message: Message) {
state.eventSink(MessageComposerEvents.SendMessage(message)) state.eventSink(MessageComposerEvents.SendMessage(message))
} }
@ -48,12 +49,8 @@ fun MessageComposerView(
state.eventSink(MessageComposerEvents.CloseSpecialMode) state.eventSink(MessageComposerEvents.CloseSpecialMode)
} }
fun onComposerTextChange(text: String) { fun onError(error: Throwable) {
state.eventSink(MessageComposerEvents.UpdateText(text)) state.eventSink(MessageComposerEvents.Error(error))
}
fun onFocusChanged(hasFocus: Boolean) {
state.eventSink(MessageComposerEvents.FocusChanged(hasFocus))
} }
Box(modifier = modifier) { Box(modifier = modifier) {
@ -64,14 +61,14 @@ fun MessageComposerView(
) )
TextComposer( TextComposer(
state = state.richTextEditorState,
canSendMessage = state.canSendMessage,
onRequestFocus = { state.richTextEditorState.requestFocus() },
onSendMessage = ::sendMessage, onSendMessage = ::sendMessage,
composerMode = state.mode, composerMode = state.mode,
onResetComposerMode = ::onCloseSpecialMode, onResetComposerMode = ::onCloseSpecialMode,
onComposerTextChange = ::onComposerTextChange,
onAddAttachment = ::onAddAttachment, onAddAttachment = ::onAddAttachment,
onFocusChanged = ::onFocusChanged, onError = ::onError,
composerCanSendMessage = state.isSendButtonVisible,
composerText = state.text
) )
} }
} }

38
features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/RichTextEditorStateFactory.kt

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

5
features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelinePresenter.kt

@ -21,6 +21,7 @@ import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.MutableState import androidx.compose.runtime.MutableState
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.rememberCoroutineScope
@ -61,7 +62,7 @@ class TimelinePresenter @Inject constructor(
mutableStateOf(null) mutableStateOf(null)
} }
val lastReadReceiptIndex = rememberSaveable { mutableStateOf(Int.MAX_VALUE) } val lastReadReceiptIndex = rememberSaveable { mutableIntStateOf(Int.MAX_VALUE) }
val lastReadReceiptId = rememberSaveable { mutableStateOf<EventId?>(null) } val lastReadReceiptId = rememberSaveable { mutableStateOf<EventId?>(null) }
val timelineItems by timelineItemsFactory.collectItemsAsState() val timelineItems by timelineItemsFactory.collectItemsAsState()
@ -119,7 +120,7 @@ class TimelinePresenter @Inject constructor(
paginationState = paginationState, paginationState = paginationState,
timelineItems = timelineItems, timelineItems = timelineItems,
hasNewItems = hasNewItems.value, hasNewItems = hasNewItems.value,
eventSink = ::handleEvents eventSink = { handleEvents(it) }
) )
} }

2
features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/customreaction/CustomReactionPresenter.kt

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

2
features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/reactionsummary/ReactionSummaryPresenter.kt

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

2
features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/retrysendmenu/RetrySendMenuPresenter.kt

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

2
features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemTextBasedContent.kt

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

11
features/messages/impl/src/test/kotlin/io/element/android/features/messages/MessagesPresenterTest.kt

@ -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.ActionListState
import io.element.android.features.messages.impl.actionlist.model.TimelineItemAction 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.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.messagecomposer.MessageComposerPresenter
import io.element.android.features.messages.impl.timeline.TimelinePresenter import io.element.android.features.messages.impl.timeline.TimelinePresenter
import io.element.android.features.messages.impl.timeline.components.customreaction.CustomReactionPresenter 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.TimelineItemTextContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemVideoContent 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.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.timeline.components.customreaction.FakeEmojibaseProvider
import io.element.android.features.messages.utils.messagesummary.FakeMessageSummaryFormatter import io.element.android.features.messages.utils.messagesummary.FakeMessageSummaryFormatter
import io.element.android.features.networkmonitor.test.FakeNetworkMonitor import io.element.android.features.networkmonitor.test.FakeNetworkMonitor
@ -325,6 +325,7 @@ class MessagesPresenterTest {
initialState.eventSink.invoke(MessagesEvents.HandleAction(TimelineItemAction.Redact, aMessageEvent())) initialState.eventSink.invoke(MessagesEvents.HandleAction(TimelineItemAction.Redact, aMessageEvent()))
assertThat(matrixRoom.redactEventEventIdParam).isEqualTo(AN_EVENT_ID) assertThat(matrixRoom.redactEventEventIdParam).isEqualTo(AN_EVENT_ID)
assertThat(awaitItem().actionListState.target).isEqualTo(ActionListState.Target.None) 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 // Initially the composer doesn't have focus, so we don't show the alert
assertThat(initialState.showReinvitePrompt).isFalse() assertThat(initialState.showReinvitePrompt).isFalse()
// When the input field is focused we show the alert // 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 -> val focusedState = consumeItemsUntilPredicate(timeout = 250.milliseconds) { state ->
state.showReinvitePrompt state.showReinvitePrompt
}.last() }.last()
@ -405,7 +406,7 @@ class MessagesPresenterTest {
skipItems(1) skipItems(1)
val initialState = awaitItem() val initialState = awaitItem()
assertThat(initialState.showReinvitePrompt).isFalse() assertThat(initialState.showReinvitePrompt).isFalse()
initialState.composerState.eventSink(MessageComposerEvents.FocusChanged(true)) initialState.composerState.richTextEditorState.requestFocus()
val focusedState = awaitItem() val focusedState = awaitItem()
assertThat(focusedState.showReinvitePrompt).isFalse() assertThat(focusedState.showReinvitePrompt).isFalse()
} }
@ -421,7 +422,7 @@ class MessagesPresenterTest {
skipItems(1) skipItems(1)
val initialState = awaitItem() val initialState = awaitItem()
assertThat(initialState.showReinvitePrompt).isFalse() assertThat(initialState.showReinvitePrompt).isFalse()
initialState.composerState.eventSink(MessageComposerEvents.FocusChanged(true)) initialState.composerState.richTextEditorState.requestFocus()
val focusedState = awaitItem() val focusedState = awaitItem()
assertThat(focusedState.showReinvitePrompt).isFalse() assertThat(focusedState.showReinvitePrompt).isFalse()
} }
@ -605,6 +606,8 @@ class MessagesPresenterTest {
snackbarDispatcher = SnackbarDispatcher(), snackbarDispatcher = SnackbarDispatcher(),
analyticsService = FakeAnalyticsService(), analyticsService = FakeAnalyticsService(),
messageComposerContext = MessageComposerContextImpl(), messageComposerContext = MessageComposerContextImpl(),
richTextEditorStateFactory = TestRichTextEditorStateFactory(),
) )
val timelinePresenter = TimelinePresenter( val timelinePresenter = TimelinePresenter(
timelineItemsFactory = aTimelineItemsFactory(), timelineItemsFactory = aTimelineItemsFactory(),

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

@ -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.MediaSender
import io.element.android.libraries.mediaupload.api.MediaUploadInfo import io.element.android.libraries.mediaupload.api.MediaUploadInfo
import io.element.android.libraries.mediaupload.test.FakeMediaPreProcessor 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.libraries.textcomposer.MessageComposerMode
import io.element.android.services.analytics.test.FakeAnalyticsService import io.element.android.services.analytics.test.FakeAnalyticsService
import io.element.android.tests.testutils.WarmUpRule import io.element.android.tests.testutils.WarmUpRule
@ -80,6 +81,7 @@ class MessageComposerPresenterTest {
private val snackbarDispatcher = SnackbarDispatcher() private val snackbarDispatcher = SnackbarDispatcher()
private val mockMediaUrl: Uri = mockk("localMediaUri") private val mockMediaUrl: Uri = mockk("localMediaUri")
private val localMediaFactory = FakeLocalMediaFactory(mockMediaUrl) private val localMediaFactory = FakeLocalMediaFactory(mockMediaUrl)
private val analyticsService = FakeAnalyticsService()
@Test @Test
fun `present - initial state`() = runTest { fun `present - initial state`() = runTest {
@ -90,12 +92,12 @@ class MessageComposerPresenterTest {
skipItems(1) skipItems(1)
val initialState = awaitItem() val initialState = awaitItem()
assertThat(initialState.isFullScreen).isFalse() assertThat(initialState.isFullScreen).isFalse()
assertThat(initialState.text).isEqualTo("") assertThat(initialState.richTextEditorState.messageHtml).isEqualTo("")
assertThat(initialState.mode).isEqualTo(MessageComposerMode.Normal("")) assertThat(initialState.mode).isEqualTo(MessageComposerMode.Normal(""))
assertThat(initialState.showAttachmentSourcePicker).isFalse() assertThat(initialState.showAttachmentSourcePicker).isFalse()
assertThat(initialState.canShareLocation).isTrue() assertThat(initialState.canShareLocation).isTrue()
assertThat(initialState.attachmentsState).isEqualTo(AttachmentsState.None) assertThat(initialState.attachmentsState).isEqualTo(AttachmentsState.None)
assertThat(initialState.isSendButtonVisible).isFalse() assertThat(initialState.canSendMessage).isFalse()
} }
} }
@ -124,14 +126,14 @@ class MessageComposerPresenterTest {
}.test { }.test {
skipItems(1) skipItems(1)
val initialState = awaitItem() val initialState = awaitItem()
initialState.eventSink.invoke(MessageComposerEvents.UpdateText(A_MESSAGE)) initialState.richTextEditorState.setHtml(A_MESSAGE)
val withMessageState = awaitItem() val withMessageState = awaitItem()
assertThat(withMessageState.text).isEqualTo(A_MESSAGE) assertThat(withMessageState.richTextEditorState.messageHtml).isEqualTo(A_MESSAGE)
assertThat(withMessageState.isSendButtonVisible).isTrue() assertThat(withMessageState.canSendMessage).isTrue()
withMessageState.eventSink.invoke(MessageComposerEvents.UpdateText("")) withMessageState.richTextEditorState.setHtml("")
val withEmptyMessageState = awaitItem() val withEmptyMessageState = awaitItem()
assertThat(withEmptyMessageState.text).isEqualTo("") assertThat(withEmptyMessageState.richTextEditorState.messageHtml).isEqualTo("")
assertThat(withEmptyMessageState.isSendButtonVisible).isFalse() assertThat(withEmptyMessageState.canSendMessage).isFalse()
} }
} }
@ -148,8 +150,8 @@ class MessageComposerPresenterTest {
state = awaitItem() state = awaitItem()
assertThat(state.mode).isEqualTo(mode) assertThat(state.mode).isEqualTo(mode)
state = awaitItem() state = awaitItem()
assertThat(state.text).isEqualTo(A_MESSAGE) assertThat(state.richTextEditorState.messageHtml).isEqualTo(A_MESSAGE)
assertThat(state.isSendButtonVisible).isTrue() assertThat(state.canSendMessage).isTrue()
backToNormalMode(state, skipCount = 1) backToNormalMode(state, skipCount = 1)
} }
} }
@ -166,8 +168,8 @@ class MessageComposerPresenterTest {
state.eventSink.invoke(MessageComposerEvents.SetMode(mode)) state.eventSink.invoke(MessageComposerEvents.SetMode(mode))
state = awaitItem() state = awaitItem()
assertThat(state.mode).isEqualTo(mode) assertThat(state.mode).isEqualTo(mode)
assertThat(state.text).isEqualTo("") assertThat(state.richTextEditorState.messageHtml).isEqualTo("")
assertThat(state.isSendButtonVisible).isFalse() assertThat(state.canSendMessage).isFalse()
backToNormalMode(state) backToNormalMode(state)
} }
} }
@ -184,8 +186,8 @@ class MessageComposerPresenterTest {
state.eventSink.invoke(MessageComposerEvents.SetMode(mode)) state.eventSink.invoke(MessageComposerEvents.SetMode(mode))
state = awaitItem() state = awaitItem()
assertThat(state.mode).isEqualTo(mode) assertThat(state.mode).isEqualTo(mode)
assertThat(state.text).isEqualTo("") assertThat(state.richTextEditorState.messageHtml).isEqualTo("")
assertThat(state.isSendButtonVisible).isFalse() assertThat(state.canSendMessage).isFalse()
backToNormalMode(state) backToNormalMode(state)
} }
} }
@ -198,14 +200,14 @@ class MessageComposerPresenterTest {
}.test { }.test {
skipItems(1) skipItems(1)
val initialState = awaitItem() val initialState = awaitItem()
initialState.eventSink.invoke(MessageComposerEvents.UpdateText(A_MESSAGE)) initialState.richTextEditorState.setHtml(A_MESSAGE)
val withMessageState = awaitItem() val withMessageState = awaitItem()
assertThat(withMessageState.text).isEqualTo(A_MESSAGE) assertThat(withMessageState.richTextEditorState.messageHtml).isEqualTo(A_MESSAGE)
assertThat(withMessageState.isSendButtonVisible).isTrue() assertThat(withMessageState.canSendMessage).isTrue()
withMessageState.eventSink.invoke(MessageComposerEvents.SendMessage(A_MESSAGE)) withMessageState.eventSink.invoke(MessageComposerEvents.SendMessage(A_MESSAGE.toMessage()))
val messageSentState = awaitItem() val messageSentState = awaitItem()
assertThat(messageSentState.text).isEqualTo("") assertThat(messageSentState.richTextEditorState.messageHtml).isEqualTo("")
assertThat(messageSentState.isSendButtonVisible).isFalse() assertThat(messageSentState.canSendMessage).isFalse()
} }
} }
@ -221,23 +223,23 @@ class MessageComposerPresenterTest {
}.test { }.test {
skipItems(1) skipItems(1)
val initialState = awaitItem() val initialState = awaitItem()
assertThat(initialState.text).isEqualTo("") assertThat(initialState.richTextEditorState.messageHtml).isEqualTo("")
val mode = anEditMode() val mode = anEditMode()
initialState.eventSink.invoke(MessageComposerEvents.SetMode(mode)) initialState.eventSink.invoke(MessageComposerEvents.SetMode(mode))
skipItems(1) skipItems(1)
val withMessageState = awaitItem() val withMessageState = awaitItem()
assertThat(withMessageState.mode).isEqualTo(mode) assertThat(withMessageState.mode).isEqualTo(mode)
assertThat(withMessageState.text).isEqualTo(A_MESSAGE) assertThat(withMessageState.richTextEditorState.messageHtml).isEqualTo(A_MESSAGE)
assertThat(withMessageState.isSendButtonVisible).isTrue() assertThat(withMessageState.canSendMessage).isTrue()
withMessageState.eventSink.invoke(MessageComposerEvents.UpdateText(ANOTHER_MESSAGE)) withMessageState.richTextEditorState.setHtml(ANOTHER_MESSAGE)
val withEditedMessageState = awaitItem() val withEditedMessageState = awaitItem()
assertThat(withEditedMessageState.text).isEqualTo(ANOTHER_MESSAGE) assertThat(withEditedMessageState.richTextEditorState.messageHtml).isEqualTo(ANOTHER_MESSAGE)
withEditedMessageState.eventSink.invoke(MessageComposerEvents.SendMessage(ANOTHER_MESSAGE)) withEditedMessageState.eventSink.invoke(MessageComposerEvents.SendMessage(ANOTHER_MESSAGE.toMessage()))
skipItems(1) skipItems(1)
val messageSentState = awaitItem() val messageSentState = awaitItem()
assertThat(messageSentState.text).isEqualTo("") assertThat(messageSentState.richTextEditorState.messageHtml).isEqualTo("")
assertThat(messageSentState.isSendButtonVisible).isFalse() assertThat(messageSentState.canSendMessage).isFalse()
assertThat(fakeMatrixRoom.editMessageCalls.first()).isEqualTo(ANOTHER_MESSAGE) assertThat(fakeMatrixRoom.editMessageCalls.first()).isEqualTo(ANOTHER_MESSAGE to ANOTHER_MESSAGE)
} }
} }
@ -253,23 +255,23 @@ class MessageComposerPresenterTest {
}.test { }.test {
skipItems(1) skipItems(1)
val initialState = awaitItem() val initialState = awaitItem()
assertThat(initialState.text).isEqualTo("") assertThat(initialState.richTextEditorState.messageHtml).isEqualTo("")
val mode = anEditMode(eventId = null, transactionId = A_TRANSACTION_ID) val mode = anEditMode(eventId = null, transactionId = A_TRANSACTION_ID)
initialState.eventSink.invoke(MessageComposerEvents.SetMode(mode)) initialState.eventSink.invoke(MessageComposerEvents.SetMode(mode))
skipItems(1) skipItems(1)
val withMessageState = awaitItem() val withMessageState = awaitItem()
assertThat(withMessageState.mode).isEqualTo(mode) assertThat(withMessageState.mode).isEqualTo(mode)
assertThat(withMessageState.text).isEqualTo(A_MESSAGE) assertThat(withMessageState.richTextEditorState.messageHtml).isEqualTo(A_MESSAGE)
assertThat(withMessageState.isSendButtonVisible).isTrue() assertThat(withMessageState.canSendMessage).isTrue()
withMessageState.eventSink.invoke(MessageComposerEvents.UpdateText(ANOTHER_MESSAGE)) withMessageState.richTextEditorState.setHtml(ANOTHER_MESSAGE)
val withEditedMessageState = awaitItem() val withEditedMessageState = awaitItem()
assertThat(withEditedMessageState.text).isEqualTo(ANOTHER_MESSAGE) assertThat(withEditedMessageState.richTextEditorState.messageHtml).isEqualTo(ANOTHER_MESSAGE)
withEditedMessageState.eventSink.invoke(MessageComposerEvents.SendMessage(ANOTHER_MESSAGE)) withEditedMessageState.eventSink.invoke(MessageComposerEvents.SendMessage(ANOTHER_MESSAGE.toMessage()))
skipItems(1) skipItems(1)
val messageSentState = awaitItem() val messageSentState = awaitItem()
assertThat(messageSentState.text).isEqualTo("") assertThat(messageSentState.richTextEditorState.messageHtml).isEqualTo("")
assertThat(messageSentState.isSendButtonVisible).isFalse() assertThat(messageSentState.canSendMessage).isFalse()
assertThat(fakeMatrixRoom.editMessageCalls.first()).isEqualTo(ANOTHER_MESSAGE) assertThat(fakeMatrixRoom.editMessageCalls.first()).isEqualTo(ANOTHER_MESSAGE to ANOTHER_MESSAGE)
} }
} }
@ -285,23 +287,23 @@ class MessageComposerPresenterTest {
}.test { }.test {
skipItems(1) skipItems(1)
val initialState = awaitItem() val initialState = awaitItem()
assertThat(initialState.text).isEqualTo("") assertThat(initialState.richTextEditorState.messageHtml).isEqualTo("")
val mode = aReplyMode() val mode = aReplyMode()
initialState.eventSink.invoke(MessageComposerEvents.SetMode(mode)) initialState.eventSink.invoke(MessageComposerEvents.SetMode(mode))
val state = awaitItem() val state = awaitItem()
assertThat(state.mode).isEqualTo(mode) assertThat(state.mode).isEqualTo(mode)
assertThat(state.text).isEqualTo("") assertThat(state.richTextEditorState.messageHtml).isEqualTo("")
assertThat(state.isSendButtonVisible).isFalse() assertThat(state.canSendMessage).isFalse()
initialState.eventSink.invoke(MessageComposerEvents.UpdateText(A_REPLY)) state.richTextEditorState.setHtml(A_REPLY)
val withMessageState = awaitItem() val withMessageState = awaitItem()
assertThat(withMessageState.text).isEqualTo(A_REPLY) assertThat(withMessageState.richTextEditorState.messageHtml).isEqualTo(A_REPLY)
assertThat(withMessageState.isSendButtonVisible).isTrue() assertThat(withMessageState.canSendMessage).isTrue()
withMessageState.eventSink.invoke(MessageComposerEvents.SendMessage(A_REPLY)) withMessageState.eventSink.invoke(MessageComposerEvents.SendMessage(A_REPLY.toMessage()))
skipItems(1) skipItems(1)
val messageSentState = awaitItem() val messageSentState = awaitItem()
assertThat(messageSentState.text).isEqualTo("") assertThat(messageSentState.richTextEditorState.messageHtml).isEqualTo("")
assertThat(messageSentState.isSendButtonVisible).isFalse() assertThat(messageSentState.canSendMessage).isFalse()
assertThat(fakeMatrixRoom.replyMessageParameter).isEqualTo(A_REPLY) 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) { private suspend fun ReceiveTurbine<MessageComposerState>.backToNormalMode(state: MessageComposerState, skipCount: Int = 0) {
state.eventSink.invoke(MessageComposerEvents.CloseSpecialMode) state.eventSink.invoke(MessageComposerEvents.CloseSpecialMode)
skipItems(skipCount) skipItems(skipCount)
val normalState = awaitItem() val normalState = awaitItem()
assertThat(normalState.mode).isEqualTo(MessageComposerMode.Normal("")) assertThat(normalState.mode).isEqualTo(MessageComposerMode.Normal(""))
assertThat(normalState.text).isEqualTo("") assertThat(normalState.richTextEditorState.messageHtml).isEqualTo("")
assertThat(normalState.isSendButtonVisible).isFalse() assertThat(normalState.canSendMessage).isFalse()
} }
private fun createPresenter( private fun createPresenter(
@ -547,8 +563,9 @@ class MessageComposerPresenterTest {
localMediaFactory, localMediaFactory,
MediaSender(mediaPreProcessor, room), MediaSender(mediaPreProcessor, room),
snackbarDispatcher, snackbarDispatcher,
FakeAnalyticsService(), analyticsService,
MessageComposerContextImpl(), MessageComposerContextImpl(),
TestRichTextEditorStateFactory(),
) )
} }
@ -560,3 +577,8 @@ fun anEditMode(
fun aReplyMode() = MessageComposerMode.Reply(A_USER_NAME, null, AN_EVENT_ID, A_MESSAGE) fun aReplyMode() = MessageComposerMode.Reply(A_USER_NAME, null, AN_EVENT_ID, A_MESSAGE)
fun aQuoteMode() = MessageComposerMode.Quote(AN_EVENT_ID, A_MESSAGE) fun aQuoteMode() = MessageComposerMode.Quote(AN_EVENT_ID, A_MESSAGE)
private fun String.toMessage() = Message(
html = this,
markdown = this,
)

29
features/messages/impl/src/test/kotlin/io/element/android/features/messages/textcomposer/TestRichTextEditorStateFactory.kt

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

3
gradle/libs.versions.toml

@ -46,6 +46,7 @@ dependencyanalysis = "1.21.0"
stem = "2.3.0" stem = "2.3.0"
sqldelight = "1.5.5" sqldelight = "1.5.5"
telephoto = "0.6.0" telephoto = "0.6.0"
wysiwyg = "2.9.0"
# DI # DI
dagger = "2.48" 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" } molecule-runtime = { module = "app.cash.molecule:molecule-runtime", version.ref = "molecule" }
timber = "com.jakewharton.timber:timber:5.0.1" timber = "com.jakewharton.timber:timber:5.0.1"
matrix_sdk = "org.matrix.rustcomponents:sdk-android:0.1.49" 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-android = { module = "com.squareup.sqldelight:android-driver", version.ref = "sqldelight" }
sqldelight-driver-jvm = { module = "com.squareup.sqldelight:sqlite-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" } sqldelight-coroutines = { module = "com.squareup.sqldelight:coroutines-extensions", version.ref = "sqldelight" }

6
libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/MatrixRoom.kt

@ -79,11 +79,11 @@ interface MatrixRoom : Closeable {
suspend fun userAvatarUrl(userId: UserId): Result<String?> 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> suspend fun redactEvent(eventId: EventId, reason: String? = null): Result<Unit>

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

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

14
libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/room/FakeMatrixRoom.kt

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

2
libraries/permissions/impl/src/main/kotlin/io/element/android/libraries/permissions/impl/DefaultPermissionsPresenter.kt

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

6
libraries/textcomposer/build.gradle.kts → libraries/textcomposer/impl/build.gradle.kts

@ -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"); * Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with 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.matrix.api)
implementation(projects.libraries.matrixui) implementation(projects.libraries.matrixui)
implementation(projects.libraries.designsystem) implementation(projects.libraries.designsystem)
implementation(libs.matrix.richtexteditor)
api(libs.matrix.richtexteditor.compose)
ksp(libs.showkase.processor) ksp(libs.showkase.processor)
} }

22
libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/Message.kt

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

0
libraries/textcomposer/src/main/kotlin/io/element/android/libraries/textcomposer/MessageComposerMode.kt → libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/MessageComposerMode.kt

210
libraries/textcomposer/src/main/kotlin/io/element/android/libraries/textcomposer/TextComposer.kt → libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/TextComposer.kt

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

0
libraries/textcomposer/src/main/res/drawable/ic_add_attachment.xml → libraries/textcomposer/impl/src/main/res/drawable/ic_add_attachment.xml

0
libraries/textcomposer/src/main/res/drawable/ic_send.xml → libraries/textcomposer/impl/src/main/res/drawable/ic_send.xml

0
libraries/textcomposer/src/main/res/drawable/ic_tick.xml → libraries/textcomposer/impl/src/main/res/drawable/ic_tick.xml

0
libraries/textcomposer/src/main/res/values-cs/translations.xml → libraries/textcomposer/impl/src/main/res/values-cs/translations.xml

0
libraries/textcomposer/src/main/res/values-de/translations.xml → libraries/textcomposer/impl/src/main/res/values-de/translations.xml

0
libraries/textcomposer/src/main/res/values-ro/translations.xml → libraries/textcomposer/impl/src/main/res/values-ro/translations.xml

0
libraries/textcomposer/src/main/res/values-ru/translations.xml → libraries/textcomposer/impl/src/main/res/values-ru/translations.xml

0
libraries/textcomposer/src/main/res/values-sk/translations.xml → libraries/textcomposer/impl/src/main/res/values-sk/translations.xml

0
libraries/textcomposer/src/main/res/values-zh-rTW/translations.xml → libraries/textcomposer/impl/src/main/res/values-zh-rTW/translations.xml

0
libraries/textcomposer/src/main/res/values/localazy.xml → libraries/textcomposer/impl/src/main/res/values/localazy.xml

28
libraries/textcomposer/test/build.gradle.kts

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

2
plugins/src/main/kotlin/extension/DependencyHandleScope.kt

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

2
services/analytics/test/src/main/kotlin/io/element/android/services/analytics/test/FakeAnalyticsService.kt

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

BIN
tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl.messagecomposer_null_MessageComposerViewDark_0_null_0,NEXUS_5,1.0,en].png (Stored with Git LFS)

Binary file not shown.

BIN
tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl.messagecomposer_null_MessageComposerViewLight_0_null_0,NEXUS_5,1.0,en].png (Stored with Git LFS)

Binary file not shown.

BIN
tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl_null_MessagesViewDark_0_null_0,NEXUS_5,1.0,en].png (Stored with Git LFS)

Binary file not shown.

BIN
tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl_null_MessagesViewDark_0_null_1,NEXUS_5,1.0,en].png (Stored with Git LFS)

Binary file not shown.

BIN
tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl_null_MessagesViewDark_0_null_2,NEXUS_5,1.0,en].png (Stored with Git LFS)

Binary file not shown.

BIN
tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl_null_MessagesViewDark_0_null_4,NEXUS_5,1.0,en].png (Stored with Git LFS)

Binary file not shown.

BIN
tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl_null_MessagesViewDark_0_null_5,NEXUS_5,1.0,en].png (Stored with Git LFS)

Binary file not shown.

BIN
tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl_null_MessagesViewLight_0_null_0,NEXUS_5,1.0,en].png (Stored with Git LFS)

Binary file not shown.

BIN
tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl_null_MessagesViewLight_0_null_1,NEXUS_5,1.0,en].png (Stored with Git LFS)

Binary file not shown.

BIN
tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl_null_MessagesViewLight_0_null_2,NEXUS_5,1.0,en].png (Stored with Git LFS)

Binary file not shown.

BIN
tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl_null_MessagesViewLight_0_null_4,NEXUS_5,1.0,en].png (Stored with Git LFS)

Binary file not shown.

BIN
tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl_null_MessagesViewLight_0_null_5,NEXUS_5,1.0,en].png (Stored with Git LFS)

Binary file not shown.

BIN
tests/uitests/src/test/snapshots/images/ui_S_t[l.textcomposer_null_TextComposerEdit-D-1_2_null,NEXUS_5,1.0,en].png (Stored with Git LFS)

Binary file not shown.

BIN
tests/uitests/src/test/snapshots/images/ui_S_t[l.textcomposer_null_TextComposerEdit-N-1_3_null,NEXUS_5,1.0,en].png (Stored with Git LFS)

Binary file not shown.

BIN
tests/uitests/src/test/snapshots/images/ui_S_t[l.textcomposer_null_TextComposerReply-D-2_3_null,NEXUS_5,1.0,en].png (Stored with Git LFS)

Binary file not shown.

BIN
tests/uitests/src/test/snapshots/images/ui_S_t[l.textcomposer_null_TextComposerReply-N-2_4_null,NEXUS_5,1.0,en].png (Stored with Git LFS)

Binary file not shown.

BIN
tests/uitests/src/test/snapshots/images/ui_S_t[l.textcomposer_null_TextComposerSimple-D-0_1_null,NEXUS_5,1.0,en].png (Stored with Git LFS)

Binary file not shown.

BIN
tests/uitests/src/test/snapshots/images/ui_S_t[l.textcomposer_null_TextComposerSimple-N-0_2_null,NEXUS_5,1.0,en].png (Stored with Git LFS)

Binary file not shown.

2
tools/localazy/config.json

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

Loading…
Cancel
Save