Merge branch 'develop' into feature/fga/sync_indicator_api

This commit is contained in:
ganfra 2023-09-07 21:56:48 +02:00
commit b10932b2dd
108 changed files with 558 additions and 406 deletions

View file

@ -22,6 +22,16 @@ jobs:
group: ${{ github.ref == 'refs/heads/main' && format('unit-tests-main-{0}', github.sha) || github.ref == 'refs/heads/develop' && format('unit-tests-develop-{0}', github.sha) || format('unit-tests-{0}', github.ref) }} 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
View file

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

1
changelog.d/1251.misc Normal file
View file

@ -0,0 +1 @@
Improve RoomSummary mapping by using RoomInfo.

View file

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

View file

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

View file

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

View file

@ -30,6 +30,7 @@ import io.element.android.libraries.designsystem.components.avatar.AvatarData
import io.element.android.libraries.designsystem.components.avatar.AvatarSize import io.element.android.libraries.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"),
), ),

View file

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

View file

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

View file

@ -44,8 +44,10 @@ import io.element.android.libraries.matrix.api.core.ProgressCallback
import io.element.android.libraries.matrix.api.room.MatrixRoom import io.element.android.libraries.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,
) )
} }
} }

View file

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

View file

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

View file

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

View file

@ -0,0 +1,38 @@
/*
* Copyright (c) 2023 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.features.messages.impl.messagecomposer
import androidx.compose.runtime.Composable
import com.squareup.anvil.annotations.ContributesBinding
import io.element.android.libraries.di.AppScope
import io.element.android.wysiwyg.compose.RichTextEditorState
import io.element.android.wysiwyg.compose.rememberRichTextEditorState
import javax.inject.Inject
interface RichTextEditorStateFactory {
@Composable
fun create(): RichTextEditorState
}
@ContributesBinding(AppScope::class)
class DefaultRichTextEditorStateFactory @Inject constructor() : RichTextEditorStateFactory {
@Composable
override fun create(): RichTextEditorState {
return rememberRichTextEditorState()
}
}

View file

@ -21,6 +21,7 @@ import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.MutableState import androidx.compose.runtime.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) }
) )
} }

View file

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

View file

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

View file

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

View file

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

View file

@ -30,7 +30,6 @@ import io.element.android.features.messages.impl.actionlist.ActionListPresenter
import io.element.android.features.messages.impl.actionlist.ActionListState import io.element.android.features.messages.impl.actionlist.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(),

View file

@ -53,6 +53,7 @@ import io.element.android.libraries.mediaupload.api.MediaPreProcessor
import io.element.android.libraries.mediaupload.api.MediaSender import io.element.android.libraries.mediaupload.api.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,
)

View file

@ -0,0 +1,29 @@
/*
* Copyright (c) 2023 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.features.messages.textcomposer
import androidx.compose.runtime.Composable
import io.element.android.features.messages.impl.messagecomposer.RichTextEditorStateFactory
import io.element.android.wysiwyg.compose.RichTextEditorState
import io.element.android.wysiwyg.compose.rememberRichTextEditorState
class TestRichTextEditorStateFactory : RichTextEditorStateFactory {
@Composable
override fun create(): RichTextEditorState {
return rememberRichTextEditorState("", fake = true)
}
}

View file

@ -20,8 +20,10 @@ import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ExperimentalLayoutApi import androidx.compose.foundation.layout.ExperimentalLayoutApi
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.consumeWindowInsets import androidx.compose.foundation.layout.consumeWindowInsets
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.lazy.itemsIndexed
@ -52,8 +54,8 @@ import io.element.android.features.roomlist.impl.model.RoomListRoomSummary
import io.element.android.features.roomlist.impl.search.RoomListSearchResultView import io.element.android.features.roomlist.impl.search.RoomListSearchResultView
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.designsystem.theme.components.HorizontalDivider
import io.element.android.libraries.designsystem.theme.components.FloatingActionButton import io.element.android.libraries.designsystem.theme.components.FloatingActionButton
import io.element.android.libraries.designsystem.theme.components.HorizontalDivider
import io.element.android.libraries.designsystem.theme.components.Icon import io.element.android.libraries.designsystem.theme.components.Icon
import io.element.android.libraries.designsystem.theme.components.Scaffold import io.element.android.libraries.designsystem.theme.components.Scaffold
import io.element.android.libraries.designsystem.utils.LogCompositions import io.element.android.libraries.designsystem.utils.LogCompositions
@ -210,6 +212,11 @@ fun RoomListContent(
HorizontalDivider() HorizontalDivider()
} }
} }
// Add a last Spacer item to ensure that the FAB does not hide the last room item
// FAB height is 56dp, bottom padding is 16dp, we add 8dp as extra margin -> 56+16+8 = 80
item {
Spacer(modifier = Modifier.height(80.dp))
}
} }
}, },
floatingActionButton = { floatingActionButton = {
@ -219,8 +226,7 @@ fun RoomListContent(
onClick = onCreateRoomClicked onClick = onCreateRoomClicked
) { ) {
Icon( Icon(
// Correct icon alignment for better rendering. // Note cannot use Icons.Outlined.EditSquare, it does not exist :/
modifier = Modifier.padding(start = 1.dp, bottom = 1.dp),
resourceId = DrawableR.drawable.ic_edit_square, resourceId = DrawableR.drawable.ic_edit_square,
contentDescription = stringResource(id = R.string.screen_roomlist_a11y_create_message) contentDescription = stringResource(id = R.string.screen_roomlist_a11y_create_message)
) )

View file

@ -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"
@ -146,7 +147,9 @@ jsoup = { module = "org.jsoup:jsoup", version.ref = "jsoup" }
appyx_core = { module = "com.bumble.appyx:core", version.ref = "appyx" } 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.50"
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" }

View file

@ -19,6 +19,7 @@ package io.element.android.libraries.designsystem.theme.components
import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.CircleShape
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.material3.FloatingActionButtonDefaults import androidx.compose.material3.FloatingActionButtonDefaults
@ -38,7 +39,7 @@ import io.element.android.libraries.designsystem.preview.PreviewGroup
fun FloatingActionButton( fun FloatingActionButton(
onClick: () -> Unit, onClick: () -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
shape: Shape = FloatingActionButtonDefaults.shape, shape: Shape = CircleShape, // FloatingActionButtonDefaults.shape,
containerColor: Color = FloatingActionButtonDefaults.containerColor, containerColor: Color = FloatingActionButtonDefaults.containerColor,
contentColor: Color = contentColorFor(containerColor), contentColor: Color = contentColorFor(containerColor),
elevation: FloatingActionButtonElevation = FloatingActionButtonDefaults.elevation(), elevation: FloatingActionButtonElevation = FloatingActionButtonDefaults.elevation(),

View file

@ -15,12 +15,13 @@
--> -->
<vector xmlns:android="http://schemas.android.com/apk/res/android" <vector xmlns:android="http://schemas.android.com/apk/res/android"
android:tint="#000000" android:width="24dp"
android:width="21dp" android:height="24dp"
android:height="22dp" android:viewportWidth="24"
android:viewportWidth="21" android:viewportHeight="24">
android:viewportHeight="22"> <group>
<path <path
android:pathData="M1.5,21.7C1.1,21.7 0.75,21.55 0.45,21.25C0.15,20.95 0,20.6 0,20.2V5.2C0,4.8 0.15,4.45 0.45,4.15C0.75,3.85 1.1,3.7 1.5,3.7H11.625L10.125,5.2H1.5V20.2H16.5V11.5L18,10V20.2C18,20.6 17.85,20.95 17.55,21.25C17.25,21.55 16.9,21.7 16.5,21.7H1.5ZM13.55,3.9L14.625,4.95L7.5,12.05V14.2H9.625L16.775,7.05L17.825,8.1L10.7,15.25C10.567,15.383 10.404,15.492 10.212,15.575C10.021,15.658 9.825,15.7 9.625,15.7H6.75C6.533,15.7 6.354,15.629 6.213,15.488C6.071,15.346 6,15.167 6,14.95V12.075C6,11.875 6.042,11.679 6.125,11.488C6.208,11.296 6.317,11.133 6.45,11L13.55,3.9ZM17.825,8.1L13.55,3.9L16.05,1.4C16.333,1.117 16.688,0.975 17.112,0.975C17.538,0.975 17.892,1.125 18.175,1.425L20.275,3.55C20.558,3.85 20.7,4.204 20.7,4.613C20.7,5.021 20.55,5.367 20.25,5.65L17.825,8.1Z" android:fillColor="#ffffff"
android:fillColor="#ffffff"/> android:pathData="M5,21.025C4.45,21.025 3.979,20.829 3.588,20.438C3.196,20.046 3,19.575 3,19.025V5.025C3,4.475 3.196,4.004 3.588,3.612C3.979,3.221 4.45,3.025 5,3.025H13.925L11.925,5.025H5V19.025H19V12.075L21,10.075V19.025C21,19.575 20.804,20.046 20.413,20.438C20.021,20.829 19.55,21.025 19,21.025H5ZM16.175,3.6L17.6,5L11,11.6V13.025H12.4L19.025,6.4L20.45,7.8L13.825,14.425C13.642,14.608 13.429,14.754 13.188,14.863C12.946,14.971 12.692,15.025 12.425,15.025H10C9.717,15.025 9.479,14.929 9.288,14.738C9.096,14.546 9,14.308 9,14.025V11.6C9,11.333 9.05,11.079 9.15,10.837C9.25,10.596 9.392,10.383 9.575,10.2L16.175,3.6ZM20.45,7.8L16.175,3.6L18.675,1.1C19.075,0.7 19.554,0.5 20.112,0.5C20.671,0.5 21.142,0.7 21.525,1.1L22.925,2.525C23.308,2.908 23.5,3.375 23.5,3.925C23.5,4.475 23.308,4.942 22.925,5.325L20.45,7.8Z" />
</group>
</vector> </vector>

View file

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

View file

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

View file

@ -20,25 +20,26 @@ import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.roomlist.RoomSummaryDetails import io.element.android.libraries.matrix.api.roomlist.RoomSummaryDetails
import io.element.android.libraries.matrix.impl.room.RoomMemberMapper import io.element.android.libraries.matrix.impl.room.RoomMemberMapper
import io.element.android.libraries.matrix.impl.room.message.RoomMessageFactory import io.element.android.libraries.matrix.impl.room.message.RoomMessageFactory
import org.matrix.rustcomponents.sdk.Room import org.matrix.rustcomponents.sdk.RoomInfo
import org.matrix.rustcomponents.sdk.RoomListItem import org.matrix.rustcomponents.sdk.use
class RoomSummaryDetailsFactory(private val roomMessageFactory: RoomMessageFactory = RoomMessageFactory()) { class RoomSummaryDetailsFactory(private val roomMessageFactory: RoomMessageFactory = RoomMessageFactory()) {
suspend fun create(roomListItem: RoomListItem, room: Room?): RoomSummaryDetails { fun create(roomInfo: RoomInfo): RoomSummaryDetails {
val latestRoomMessage = roomListItem.latestEvent()?.use { val latestRoomMessage = roomInfo.latestEvent?.use {
roomMessageFactory.create(it) roomMessageFactory.create(it)
} }
return RoomSummaryDetails( return RoomSummaryDetails(
roomId = RoomId(roomListItem.id()), roomId = RoomId(roomInfo.id),
name = roomListItem.name() ?: roomListItem.id(), name = roomInfo.name ?: roomInfo.id,
canonicalAlias = roomListItem.canonicalAlias(), canonicalAlias = roomInfo.canonicalAlias,
isDirect = roomListItem.isDirect(), isDirect = roomInfo.isDirect,
avatarURLString = roomListItem.avatarUrl(), avatarURLString = roomInfo.avatarUrl,
unreadNotificationCount = roomListItem.unreadNotifications().use { it.notificationCount().toInt() }, unreadNotificationCount = roomInfo.notificationCount.toInt(),
lastMessage = latestRoomMessage, lastMessage = latestRoomMessage,
lastMessageTimestamp = latestRoomMessage?.originServerTs, lastMessageTimestamp = latestRoomMessage?.originServerTs,
inviter = room?.inviter()?.let(RoomMemberMapper::map), inviter = roomInfo.inviter?.let(RoomMemberMapper::map),
) )
} }
} }

View file

@ -22,11 +22,10 @@ import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.sync.withLock
import org.matrix.rustcomponents.sdk.Room
import org.matrix.rustcomponents.sdk.RoomListEntriesUpdate import org.matrix.rustcomponents.sdk.RoomListEntriesUpdate
import org.matrix.rustcomponents.sdk.RoomListEntry import org.matrix.rustcomponents.sdk.RoomListEntry
import org.matrix.rustcomponents.sdk.RoomListItem
import org.matrix.rustcomponents.sdk.RoomListService import org.matrix.rustcomponents.sdk.RoomListService
import org.matrix.rustcomponents.sdk.use
import timber.log.Timber import timber.log.Timber
import java.util.UUID import java.util.UUID
@ -34,7 +33,6 @@ class RoomSummaryListProcessor(
private val roomSummaries: MutableStateFlow<List<RoomSummary>>, private val roomSummaries: MutableStateFlow<List<RoomSummary>>,
private val roomListService: RoomListService, private val roomListService: RoomListService,
private val roomSummaryDetailsFactory: RoomSummaryDetailsFactory = RoomSummaryDetailsFactory(), private val roomSummaryDetailsFactory: RoomSummaryDetailsFactory = RoomSummaryDetailsFactory(),
private val shouldFetchFullRoom: Boolean = false,
) { ) {
private val roomSummariesByIdentifier = HashMap<String, RoomSummary>() private val roomSummariesByIdentifier = HashMap<String, RoomSummary>()
@ -120,9 +118,9 @@ class RoomSummaryListProcessor(
private suspend fun buildAndCacheRoomSummaryForIdentifier(identifier: String): RoomSummary { private suspend fun buildAndCacheRoomSummaryForIdentifier(identifier: String): RoomSummary {
val builtRoomSummary = roomListService.roomOrNull(identifier)?.use { roomListItem -> val builtRoomSummary = roomListService.roomOrNull(identifier)?.use { roomListItem ->
roomListItem.fullRoomOrNull().use { fullRoom -> roomListItem.roomInfo().use { roomInfo ->
RoomSummary.Filled( RoomSummary.Filled(
details = roomSummaryDetailsFactory.create(roomListItem, fullRoom) details = roomSummaryDetailsFactory.create(roomInfo)
) )
} }
} ?: buildEmptyRoomSummary() } ?: buildEmptyRoomSummary()
@ -130,14 +128,6 @@ class RoomSummaryListProcessor(
return builtRoomSummary return builtRoomSummary
} }
private fun RoomListItem.fullRoomOrNull(): Room? {
return if (shouldFetchFullRoom) {
fullRoom()
} else {
null
}
}
private suspend fun updateRoomSummaries(block: suspend MutableList<RoomSummary>.() -> Unit) = private suspend fun updateRoomSummaries(block: suspend MutableList<RoomSummary>.() -> Unit) =
mutex.withLock { mutex.withLock {
val mutableRoomSummaries = roomSummaries.value.toMutableList() val mutableRoomSummaries = roomSummaries.value.toMutableList()

View file

@ -53,9 +53,9 @@ class RustRoomListService(
private val inviteRooms = MutableStateFlow<List<RoomSummary>>(emptyList()) private val inviteRooms = MutableStateFlow<List<RoomSummary>>(emptyList())
private val allRoomsLoadingState: MutableStateFlow<RoomList.LoadingState> = MutableStateFlow(RoomList.LoadingState.NotLoaded) private val allRoomsLoadingState: MutableStateFlow<RoomList.LoadingState> = MutableStateFlow(RoomList.LoadingState.NotLoaded)
private val allRoomsListProcessor = RoomSummaryListProcessor(allRooms, innerRoomListService, roomSummaryDetailsFactory, shouldFetchFullRoom = false) private val allRoomsListProcessor = RoomSummaryListProcessor(allRooms, innerRoomListService, roomSummaryDetailsFactory)
private val invitesLoadingState: MutableStateFlow<RoomList.LoadingState> = MutableStateFlow(RoomList.LoadingState.NotLoaded) private val invitesLoadingState: MutableStateFlow<RoomList.LoadingState> = MutableStateFlow(RoomList.LoadingState.NotLoaded)
private val inviteRoomsListProcessor = RoomSummaryListProcessor(inviteRooms, innerRoomListService, roomSummaryDetailsFactory, shouldFetchFullRoom = true) private val inviteRoomsListProcessor = RoomSummaryListProcessor(inviteRooms, innerRoomListService, roomSummaryDetailsFactory)
init { init {
sessionCoroutineScope.launch(dispatcher) { sessionCoroutineScope.launch(dispatcher) {

View file

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

View file

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

View file

@ -1,5 +1,5 @@
/* /*
* Copyright (c) 2022 New Vector Ltd * Copyright (c) 2023 New Vector Ltd
* *
* Licensed under the Apache License, Version 2.0 (the "License"); * 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)
} }

View file

@ -0,0 +1,22 @@
/*
* Copyright (c) 2023 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.libraries.textcomposer
data class Message(
val html: String,
val markdown: String,
)

View file

@ -37,38 +37,25 @@ import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width import androidx.compose.foundation.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",
) )
} }
} }

View file

@ -0,0 +1,28 @@
/*
* Copyright (c) 2023 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
plugins {
id("io.element.android-compose-library")
}
android {
namespace = "io.element.android.libraries.textcomposer.test"
}
dependencies {
api(projects.libraries.textcomposer.impl)
implementation(projects.tests.testutils)
}

View file

@ -99,7 +99,7 @@ fun DependencyHandlerScope.allLibrariesImpl() {
implementation(project(":libraries:mediapickers:impl")) implementation(project(":libraries: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() {

View file

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

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:1625b6184c01ab75efcfe4c8d1594af1ffb84f0416219580adce5a9371fabe7d oid sha256:ad8519086ae725ac7b1aeae9cd628237afeb489c6000c814b4d8e3f644592e58
size 21292 size 22278

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:91d1b5d5c58a4f2cbf048ad20b3a9742c209ea19703eafaf6d3e6f542caf2111 oid sha256:f4cc5cd0ba741aa217ee000749ec4041186c2c83d13f6c87023d16f16689a9ed
size 40640 size 41359

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:fe37eb6328241c70b4219c9fde9118593b1354fe99628e106af828868f9cc4c6 oid sha256:1ff04ca56c00e2bbf4858bcf6f8faed596e2c538937ec26d61abac7922cda2e1
size 39178 size 39890

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:1625b6184c01ab75efcfe4c8d1594af1ffb84f0416219580adce5a9371fabe7d oid sha256:ad8519086ae725ac7b1aeae9cd628237afeb489c6000c814b4d8e3f644592e58
size 21292 size 22278

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:c94b320a8a6e1bbf2824eaa66d80476a293405b6bd0e7c2c96ce3b016be8f291 oid sha256:b461bb08151c6efb41b0c4d7a40c33b3710dc5a1d0a32cb707ba90f0ef1ee2d1
size 21430 size 22419

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:eba27416d7da08fbd8c47a172f3b54a603c893e1d07faff443417e4949fbc985 oid sha256:0a59281bb44aea2e220f48f1229afd6b4d2d0551dc0a7dd6e19b7a6ff3984f09
size 19849 size 20742

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:40184d796909922b4316ee027e4500fa183d275bc73d7f69e51801e3f956333b oid sha256:ac124e959b3c2303ecabe6eeba7c4faf7de55929757c0b69ceb4c0021cc1a390
size 37569 size 38197

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:5647bd3d7605f49358a4e1a76bf5fa86ab9a3ad32d180a134ea5e5271d8d8093 oid sha256:7e064103aa27ef0b2765962bb5d21f24387ac215d27691570991a75dd85f8725
size 36026 size 36633

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:eba27416d7da08fbd8c47a172f3b54a603c893e1d07faff443417e4949fbc985 oid sha256:0a59281bb44aea2e220f48f1229afd6b4d2d0551dc0a7dd6e19b7a6ff3984f09
size 19849 size 20742

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:434170541079e63b9c606e592407168f8e7e1e695f5f3e188428570ec364e2c6 oid sha256:5b7db80ee7f9c4a4d74b0665803c1cf85a391b6af0643f7cc823de5e8897ba6e
size 20047 size 20904

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:d502b465f6f70ead5b9bc373b443d548053bc1d0867b4cbfb52d84e59fa253f1 oid sha256:905d574bc64f14e309072a508732281c87dd00f8fa756bbd20b6d59c9b50eee6
size 11596 size 12428

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:81c29f9bb336e74a97189d8361490a3a29c4f4678d80f62c2e384396cb90a871 oid sha256:1cb7669bfe98a30d0658c4444f08c11de6b8589a8a81163cb9a83366343c3aea
size 32465 size 33113

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:daf1ab743402e503ff6a5d0ec5f7a599b531c23fcbd9767570527828530bf5b3 oid sha256:520a4053273d7d72a7339fdc609355ffbca183cbf151a24c701b9674e806306d
size 30817 size 31453

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:d502b465f6f70ead5b9bc373b443d548053bc1d0867b4cbfb52d84e59fa253f1 oid sha256:905d574bc64f14e309072a508732281c87dd00f8fa756bbd20b6d59c9b50eee6
size 11596 size 12428

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:03769782960e9fc1abf6c609a318ca90debb03484d7b8d61d0703ef1374a0b55 oid sha256:71e4415423627091abedfccd7a03e20afa27fc9f599de70dd82be1571c4131a0
size 11786 size 12616

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:e0c113e7eb9d495ab2fa3d03a945fd6fac35810af2df4571703a8a3f2b55a079 oid sha256:ca76be1bf0a76e93614c5bca704269932d690f6133f53d09275e2f08d3d27200
size 15018 size 15843

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:92da6c43ebe2da8ad035e0f03ddd5261491e846212396240aee8287d4f4d9a10 oid sha256:4d0e5e459ed40f12fa3d2897a53928bc6808903bc8a36938e7954be41eab80b2
size 22631 size 23415

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:c23fded123579979a9b6acf41def9a864ff99de6adbf224014c6d77a552fb2fe oid sha256:adb557b5ccc00a8bbb5b605e372c892f6c0b7d194ed3e6e352f05b975e2e0eab
size 24754 size 25525

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:6519ea26cb85aa1cecdab38d9f3cd54f3e986d33a9d411c576714fb070835d6f oid sha256:c581e4cf30caec4e5039f11091417780ab0decf779ec7b31112a94fec0a596e1
size 12503 size 13524

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:1cdb9c379d685e7664c76f4ae7c712665de8dc8ad1f65441121dde4475a92f57 oid sha256:e82b0029ac4e23aacfde992e36267eec369108d6ef9021138510d4b421017bd3
size 35269 size 36033

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:0c5285cb82229e84a7cf2c1f2ce375060ec4f99577c88a9840b6db3b8d0966cb oid sha256:9229d55e1a109abacfa82f066a48fa60606a0d0ed0d155634b4c31fb2c7be98c
size 33736 size 34501

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:6519ea26cb85aa1cecdab38d9f3cd54f3e986d33a9d411c576714fb070835d6f oid sha256:c581e4cf30caec4e5039f11091417780ab0decf779ec7b31112a94fec0a596e1
size 12503 size 13524

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:d33eeef612e03599f835991ad70c5f128c51e0ac49e67fff445b2e98ed3b71ef oid sha256:9703d279e6401268fd066984d4cc91403c7f8f8e2c248c6ba2ea4c242694170f
size 12706 size 13729

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:b47af4d036f0cf1275773fb8b81d3cae847626b49f5197e2249df1d9d9be2cbb oid sha256:11a6fd8151812c8733bab26904f53754899db2caeaa0f4f24ea010dd2d54b538
size 16501 size 17522

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:1aca2f20b99dbbdbd70ca437c06949b634d10c30701f7634cfebaf78593c34d8 oid sha256:0083578f572e51de0a0464caf90ec68e8a82930b97e0217d20a550ce269f7519
size 25515 size 26504

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:f612c17dedc33b8c4089828673dd58d6f1b51faa0c6df3edb1a0f3398c4c55e2 oid sha256:1a5e11eed79ab3f92c130c3055e70e5cf0df2b22200e8e73e2180c0114ff5b04
size 27885 size 28881

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:e045c8686effc435e5d31b7ef034d93bca64a226d2e6ebae941afeb36dbad099 oid sha256:4482b15d928567822dc984a1f19c698f6a9d981d907b70635b3054795292306a
size 35534 size 36543

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:a62f8d9a50f7d23bc355dfe98be0bc5eeb2b68866e3035b79b51591eae2f781f oid sha256:8b22df92cc4ec3e9622f23f6fb7e800e17afbe3a12f9f3d4121b1981ac0350f8
size 58626 size 59635

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:e045c8686effc435e5d31b7ef034d93bca64a226d2e6ebae941afeb36dbad099 oid sha256:4482b15d928567822dc984a1f19c698f6a9d981d907b70635b3054795292306a
size 35534 size 36543

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:76b43763ca93e7bf31e49aa4ba98e9c9cb6d5ee2ab6dba573e69315084884859 oid sha256:c78072a5ab2e95aaf7abb085db0d66761eb987b22c0e1dcd6da901f3fbe2a3d7
size 37433 size 38438

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:0451e8d5939e9c90ccef22a8384d80e7d83cf8faa8a7df4bcd6032d9f3ef394c oid sha256:2cbf0a4e9ca5d3471d361890737d5aa9636abe3143f5de9218e0568951d59bf5
size 36884 size 37887

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:e9220e4a8dca8924d574e9a5511f25f6c41d14163b58cb4d8f622d7fd8fc71ae oid sha256:12cac3731fd61b4cbfec09b0f439b2744a5374b6d9ee2b79880507af2224b969
size 37238 size 38241

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:8d24c1170affdcae6f189ba2e6646fb61e01f8eed4d83340297f3f189c2f3470 oid sha256:b03c0240edcc58f64bd1dc7e9ef619081f4019c5825c6e4133f3aab4b7cf9b7a
size 38225 size 39711

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:1cca9ca327d8a64cb04489cd52c52d3871bd583de9835eb266a1e60db8e53f84 oid sha256:f2ca4bee47e6beaf4b79c0417dbfe132f6e92974c33484bb0440929a35ada794
size 62191 size 63618

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:8d24c1170affdcae6f189ba2e6646fb61e01f8eed4d83340297f3f189c2f3470 oid sha256:b03c0240edcc58f64bd1dc7e9ef619081f4019c5825c6e4133f3aab4b7cf9b7a
size 38225 size 39711

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:fcfec887ae55ce77b049ef49880f1858e7a0a0ea2a7c81140c8c66069a598a14 oid sha256:19e78d44954b0cde8ea83873f40e6b0a5cf53a02b4bd523e8b8710a483471e32
size 40265 size 41698

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:5716646f8c5d8e9488f79b033ffce5f337133eb78a63328ec616b8eb35d1c70b oid sha256:6ff479b11d48f3bdf5adc02d466b6427cbf778b2d2184d6e88d7abe8c12043c5
size 39534 size 40960

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:4a8723f12c2884d20c195a779cf6d48a08191f1caa0acdaa2324e478ecac92b2 oid sha256:1f9e4cc9f63f4414fbe1f82e82b9eca7b5071e26aa057b1137daa75cecccf150
size 39898 size 41329

Some files were not shown because too many files have changed in this diff Show more