[Rich text editor] Integrate rich text editor library (#1172)
* Integrate rich text editor * Also increase swapfile size in test CI Fixes issue where screenshot tests are terminated due to lack of CI resources. See https://github.com/actions/runner-images/discussions/7188#discussioncomment-6750749 --------- Co-authored-by: ElementBot <benoitm+elementbot@element.io>
This commit is contained in:
parent
9bb6d9b66d
commit
11c1fca92e
62 changed files with 441 additions and 289 deletions
10
.github/workflows/tests.yml
vendored
10
.github/workflows/tests.yml
vendored
|
|
@ -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
Normal file
2
changelog.d/1172.feature
Normal 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.
|
||||||
|
|
||||||
|
|
@ -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)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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(
|
||||||
|
|
|
||||||
|
|
@ -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"),
|
||||||
),
|
),
|
||||||
|
|
|
||||||
|
|
@ -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) }
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,38 @@
|
||||||
|
/*
|
||||||
|
* Copyright (c) 2023 New Vector Ltd
|
||||||
|
*
|
||||||
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
* you may not use this file except in compliance with the License.
|
||||||
|
* You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing, software
|
||||||
|
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
* See the License for the specific language governing permissions and
|
||||||
|
* limitations under the License.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package io.element.android.features.messages.impl.messagecomposer
|
||||||
|
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import com.squareup.anvil.annotations.ContributesBinding
|
||||||
|
import io.element.android.libraries.di.AppScope
|
||||||
|
import io.element.android.wysiwyg.compose.RichTextEditorState
|
||||||
|
import io.element.android.wysiwyg.compose.rememberRichTextEditorState
|
||||||
|
import javax.inject.Inject
|
||||||
|
|
||||||
|
interface RichTextEditorStateFactory {
|
||||||
|
@Composable
|
||||||
|
fun create(): RichTextEditorState
|
||||||
|
}
|
||||||
|
|
||||||
|
@ContributesBinding(AppScope::class)
|
||||||
|
class DefaultRichTextEditorStateFactory @Inject constructor() : RichTextEditorStateFactory {
|
||||||
|
@Composable
|
||||||
|
override fun create(): RichTextEditorState {
|
||||||
|
return rememberRichTextEditorState()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
@ -21,6 +21,7 @@ import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.MutableState
|
import androidx.compose.runtime.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) }
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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) }
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -61,7 +61,7 @@ class ReactionSummaryPresenter @Inject constructor(
|
||||||
}
|
}
|
||||||
return ReactionSummaryState(
|
return ReactionSummaryState(
|
||||||
target = targetWithAvatars.value,
|
target = targetWithAvatars.value,
|
||||||
eventSink = ::handleEvents
|
eventSink = { handleEvents(it) }
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -66,7 +66,7 @@ class RetrySendMenuPresenter @Inject constructor(
|
||||||
|
|
||||||
return RetrySendMenuState(
|
return RetrySendMenuState(
|
||||||
selectedEvent = selectedEvent,
|
selectedEvent = selectedEvent,
|
||||||
eventSink = ::handleEvent,
|
eventSink = { handleEvent(it) },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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(),
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
)
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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" }
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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) }
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
}
|
}
|
||||||
|
|
@ -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,
|
||||||
|
)
|
||||||
|
|
@ -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 = {},
|
onSendMessage = {},
|
||||||
onComposerTextChange = {},
|
|
||||||
composerMode = MessageComposerMode.Normal(""),
|
composerMode = MessageComposerMode.Normal(""),
|
||||||
onResetComposerMode = {},
|
onResetComposerMode = {},
|
||||||
composerCanSendMessage = false,
|
|
||||||
composerText = "",
|
|
||||||
)
|
)
|
||||||
TextComposer(
|
TextComposer(
|
||||||
|
RichTextEditorState("A message", 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\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 = {},
|
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",
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
28
libraries/textcomposer/test/build.gradle.kts
Normal file
28
libraries/textcomposer/test/build.gradle.kts
Normal 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)
|
||||||
|
}
|
||||||
|
|
@ -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() {
|
||||||
|
|
|
||||||
|
|
@ -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() {
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,3 @@
|
||||||
version https://git-lfs.github.com/spec/v1
|
version https://git-lfs.github.com/spec/v1
|
||||||
oid sha256:906c643393af8d290f0635d7560eaa54339fc0498744f9ab8139932986d73a8c
|
oid sha256:4013454094701004f3c132df1ea1c3db2aa56a025b9edea80417fdd16fe55b19
|
||||||
size 9740
|
size 10422
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,3 @@
|
||||||
version https://git-lfs.github.com/spec/v1
|
version https://git-lfs.github.com/spec/v1
|
||||||
oid sha256:bd57f885f161316550712dcb471ed213537a395539ed5b9975e17465310803b7
|
oid sha256:db74b1655e377e4fd8c35aa18c22ef5f5641586b64d5e582ab2079afeac4b8c3
|
||||||
size 9823
|
size 10775
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,3 @@
|
||||||
version https://git-lfs.github.com/spec/v1
|
version https://git-lfs.github.com/spec/v1
|
||||||
oid sha256:39b527075df2b3f7afa4f3ecfca1ac93a180855b20d2bd64cb763fb98b0a7b69
|
oid sha256:cfc6f54988e4fab08ff443f0c2ea285d47e3d030a8e6c92a1294ee0f85dc1269
|
||||||
size 51340
|
size 51967
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,3 @@
|
||||||
version https://git-lfs.github.com/spec/v1
|
version https://git-lfs.github.com/spec/v1
|
||||||
oid sha256:81f196dc08f4de31f4257f6ba89c52ea41a97b107e7c5c04261188e6c572d4d0
|
oid sha256:d2ffd4fc93e7da38836fd860ce5e505c5f0c25fdd6558efef480aaeb05d00dd5
|
||||||
size 52594
|
size 53327
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,3 @@
|
||||||
version https://git-lfs.github.com/spec/v1
|
version https://git-lfs.github.com/spec/v1
|
||||||
oid sha256:a648d286a3b0536fc97801c3281bb8df432c7ffa28efff66f67cd6cf4c546c10
|
oid sha256:96864552336c65b464251c262f330856fc82c69a5f92f89b1357a185d654fccd
|
||||||
size 51579
|
size 52252
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,3 @@
|
||||||
version https://git-lfs.github.com/spec/v1
|
version https://git-lfs.github.com/spec/v1
|
||||||
oid sha256:34f9eacfd61965cfde4f420fe6a0fc0d637a21aca0d4b12c6a6f1642bd44556d
|
oid sha256:2411212e87f95415e0b4ed6732387922faed891cde5e78fa9f0691a185873129
|
||||||
size 50586
|
size 51026
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,3 @@
|
||||||
version https://git-lfs.github.com/spec/v1
|
version https://git-lfs.github.com/spec/v1
|
||||||
oid sha256:c336a9cbc803eb314aa9333719d813ae64994a75344aa363b25aee8d52551b21
|
oid sha256:255560f478c093f4ca7d021646f4cf9064c16b420d5eeec7cbce97d26c751d92
|
||||||
size 48928
|
size 49540
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,3 @@
|
||||||
version https://git-lfs.github.com/spec/v1
|
version https://git-lfs.github.com/spec/v1
|
||||||
oid sha256:5d7148068516b5e3299cce2924ea3cb54c8f1ddd6378e48b0fa746217a547d22
|
oid sha256:a384aa84ae514aa4989d4b4a5ef8048cb8810f1f37056137c77fd0ee798a0bab
|
||||||
size 52940
|
size 53941
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,3 @@
|
||||||
version https://git-lfs.github.com/spec/v1
|
version https://git-lfs.github.com/spec/v1
|
||||||
oid sha256:b8aaf2c70473711a24bf587ff524fd71d43c2610ade1c3d4ff2013705d5670b3
|
oid sha256:a48badf15c59ab5c42fc7cc200971f00cefa7838461ad0cb59537c2b4065bfff
|
||||||
size 54314
|
size 55409
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,3 @@
|
||||||
version https://git-lfs.github.com/spec/v1
|
version https://git-lfs.github.com/spec/v1
|
||||||
oid sha256:566d5b197091d4b740e1241eab54017fd31e65f570def4b8c24ade19ea78ccc8
|
oid sha256:88a498b9070653513b3e27aa2b597367ee85c8526c6e4c3712a4cb0c06e5313b
|
||||||
size 53254
|
size 54353
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,3 @@
|
||||||
version https://git-lfs.github.com/spec/v1
|
version https://git-lfs.github.com/spec/v1
|
||||||
oid sha256:8353077959916fa85046d3a81d1479d552748635f1286bb8bacc6d922ba30f28
|
oid sha256:bf493a9cb7325b4f68924abd86864b37c72226afcf1c4179a06c17e923cfd0ae
|
||||||
size 55312
|
size 55837
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,3 @@
|
||||||
version https://git-lfs.github.com/spec/v1
|
version https://git-lfs.github.com/spec/v1
|
||||||
oid sha256:a84dabfdcbaff8de5c30472676dee2cee8dc0f77469f2802dfc796a87bc10dff
|
oid sha256:8ac3dbf36380fa273d82ebe8836c525bbeee004d6348822d965b216f9bcdb920
|
||||||
size 50328
|
size 51335
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,3 @@
|
||||||
version https://git-lfs.github.com/spec/v1
|
version https://git-lfs.github.com/spec/v1
|
||||||
oid sha256:b8c4dfcbfb6c97b9bb8a33df6fd1ef57ef436b2c7e7e9bfa5d6a59fcae2515be
|
oid sha256:ce08e8c81d5494d2d803da43612a137df881e359646878b1d38cdfe77e0857ff
|
||||||
size 14140
|
size 13829
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,3 @@
|
||||||
version https://git-lfs.github.com/spec/v1
|
version https://git-lfs.github.com/spec/v1
|
||||||
oid sha256:4fd3c3f554bd668552863a6f7cd3b43cd15cf4a29b2fd67265e3a9a8a05d4258
|
oid sha256:3c2ba0ac13c81c707f0d00e3ff69737163dfc450a3d21aa468dd0b34deeeb7e1
|
||||||
size 13216
|
size 12916
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,3 @@
|
||||||
version https://git-lfs.github.com/spec/v1
|
version https://git-lfs.github.com/spec/v1
|
||||||
oid sha256:049d237aa3e2ba18df462caca17d059fa47e25de9bee8f1fbfd310106a1de07c
|
oid sha256:f15bc37e2a3d052e8d79e663f2c03223c27ddff3c02cd69f95a140c33fc6952c
|
||||||
size 81319
|
size 79320
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,3 @@
|
||||||
version https://git-lfs.github.com/spec/v1
|
version https://git-lfs.github.com/spec/v1
|
||||||
oid sha256:d45e4a53b9280b295b731ac1287ea31a8c2b653e0ae5dedd82fd2c0a6c2078ba
|
oid sha256:6aa9b8849a2bbd7b487de6fee7fd355300091bc14d647973c4beea02e6009ac4
|
||||||
size 78323
|
size 76465
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,3 @@
|
||||||
version https://git-lfs.github.com/spec/v1
|
version https://git-lfs.github.com/spec/v1
|
||||||
oid sha256:a817f9c6e2d9823dc7f4d20669c80a3cf8f39e6a4468d7dde7159cb7920f20fd
|
oid sha256:501734b297cec566a008d3492690cf75b46ab68e662d2835a0b195fd0740c13f
|
||||||
size 35134
|
size 42912
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,3 @@
|
||||||
version https://git-lfs.github.com/spec/v1
|
version https://git-lfs.github.com/spec/v1
|
||||||
oid sha256:a67d75e43fff35eaa3fff9c17245738b16cb7687f6915c1857a2ba85a61037bf
|
oid sha256:ff69ed7056380df3279a39b7544bc1887d2e455b43fb069b547e32cf5c92dc0f
|
||||||
size 33576
|
size 40229
|
||||||
|
|
|
||||||
|
|
@ -45,7 +45,7 @@
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": ":libraries:textcomposer",
|
"name": ":libraries:textcomposer:impl",
|
||||||
"includeRegex": [
|
"includeRegex": [
|
||||||
"rich_text_editor_.*"
|
"rich_text_editor_.*"
|
||||||
]
|
]
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue