Merge branch 'develop' into feature/fga/sync_indicator_api
This commit is contained in:
commit
b10932b2dd
108 changed files with 558 additions and 406 deletions
10
.github/workflows/tests.yml
vendored
10
.github/workflows/tests.yml
vendored
|
|
@ -22,6 +22,16 @@ jobs:
|
||||||
group: ${{ github.ref == 'refs/heads/main' && format('unit-tests-main-{0}', github.sha) || github.ref == 'refs/heads/develop' && format('unit-tests-develop-{0}', github.sha) || format('unit-tests-{0}', github.ref) }}
|
group: ${{ github.ref == 'refs/heads/main' && format('unit-tests-main-{0}', github.sha) || github.ref == 'refs/heads/develop' && format('unit-tests-develop-{0}', github.sha) || format('unit-tests-{0}', github.ref) }}
|
||||||
cancel-in-progress: true
|
cancel-in-progress: true
|
||||||
steps:
|
steps:
|
||||||
|
# Increase swapfile size to prevent screenshot tests getting terminated
|
||||||
|
# https://github.com/actions/runner-images/discussions/7188#discussioncomment-6750749
|
||||||
|
- name: 💽 Increase swapfile size
|
||||||
|
run: |
|
||||||
|
sudo swapoff -a
|
||||||
|
sudo fallocate -l 8G /mnt/swapfile
|
||||||
|
sudo chmod 600 /mnt/swapfile
|
||||||
|
sudo mkswap /mnt/swapfile
|
||||||
|
sudo swapon /mnt/swapfile
|
||||||
|
sudo swapon --show
|
||||||
- name: ⏬ Checkout with LFS
|
- name: ⏬ Checkout with LFS
|
||||||
uses: nschloe/action-cached-lfs-checkout@v1.2.2
|
uses: nschloe/action-cached-lfs-checkout@v1.2.2
|
||||||
with:
|
with:
|
||||||
|
|
|
||||||
2
changelog.d/1172.feature
Normal file
2
changelog.d/1172.feature
Normal file
|
|
@ -0,0 +1,2 @@
|
||||||
|
[Rich text editor] Integrate rich text editor library. Note that markdown is now not supported and further formatting support will be introduced through the rich text editor.
|
||||||
|
|
||||||
1
changelog.d/1251.misc
Normal file
1
changelog.d/1251.misc
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
Improve RoomSummary mapping by using RoomInfo.
|
||||||
|
|
@ -25,5 +25,5 @@ android {
|
||||||
dependencies {
|
dependencies {
|
||||||
implementation(projects.libraries.architecture)
|
implementation(projects.libraries.architecture)
|
||||||
implementation(projects.libraries.matrix.api)
|
implementation(projects.libraries.matrix.api)
|
||||||
api(projects.libraries.textcomposer)
|
api(projects.libraries.textcomposer.impl)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -41,7 +41,7 @@ dependencies {
|
||||||
implementation(projects.libraries.matrix.api)
|
implementation(projects.libraries.matrix.api)
|
||||||
implementation(projects.libraries.matrixui)
|
implementation(projects.libraries.matrixui)
|
||||||
implementation(projects.libraries.designsystem)
|
implementation(projects.libraries.designsystem)
|
||||||
implementation(projects.libraries.textcomposer)
|
implementation(projects.libraries.textcomposer.impl)
|
||||||
implementation(projects.libraries.uiStrings)
|
implementation(projects.libraries.uiStrings)
|
||||||
implementation(projects.libraries.dateformatter.api)
|
implementation(projects.libraries.dateformatter.api)
|
||||||
implementation(projects.libraries.eventformatter.api)
|
implementation(projects.libraries.eventformatter.api)
|
||||||
|
|
@ -76,6 +76,7 @@ dependencies {
|
||||||
testImplementation(projects.libraries.featureflag.test)
|
testImplementation(projects.libraries.featureflag.test)
|
||||||
testImplementation(projects.libraries.mediaupload.test)
|
testImplementation(projects.libraries.mediaupload.test)
|
||||||
testImplementation(projects.libraries.mediapickers.test)
|
testImplementation(projects.libraries.mediapickers.test)
|
||||||
|
testImplementation(projects.libraries.textcomposer.test)
|
||||||
testImplementation(libs.test.mockk)
|
testImplementation(libs.test.mockk)
|
||||||
|
|
||||||
ksp(libs.showkase.processor)
|
ksp(libs.showkase.processor)
|
||||||
|
|
|
||||||
|
|
@ -175,7 +175,7 @@ class MessagesPresenter @AssistedInject constructor(
|
||||||
snackbarMessage = snackbarMessage,
|
snackbarMessage = snackbarMessage,
|
||||||
showReinvitePrompt = showReinvitePrompt,
|
showReinvitePrompt = showReinvitePrompt,
|
||||||
inviteProgress = inviteProgress.value,
|
inviteProgress = inviteProgress.value,
|
||||||
eventSink = ::handleEvents
|
eventSink = { handleEvents(it) }
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -250,7 +250,9 @@ class MessagesPresenter @AssistedInject constructor(
|
||||||
private fun handleActionEdit(targetEvent: TimelineItem.Event, composerState: MessageComposerState) {
|
private fun handleActionEdit(targetEvent: TimelineItem.Event, composerState: MessageComposerState) {
|
||||||
val composerMode = MessageComposerMode.Edit(
|
val composerMode = MessageComposerMode.Edit(
|
||||||
targetEvent.eventId,
|
targetEvent.eventId,
|
||||||
(targetEvent.content as? TimelineItemTextBasedContent)?.body.orEmpty(),
|
(targetEvent.content as? TimelineItemTextBasedContent)?.let {
|
||||||
|
it.htmlBody ?: it.body
|
||||||
|
}.orEmpty(),
|
||||||
targetEvent.transactionId,
|
targetEvent.transactionId,
|
||||||
)
|
)
|
||||||
composerState.eventSink(
|
composerState.eventSink(
|
||||||
|
|
|
||||||
|
|
@ -30,6 +30,7 @@ import io.element.android.libraries.designsystem.components.avatar.AvatarData
|
||||||
import io.element.android.libraries.designsystem.components.avatar.AvatarSize
|
import io.element.android.libraries.designsystem.components.avatar.AvatarSize
|
||||||
import io.element.android.libraries.matrix.api.core.RoomId
|
import io.element.android.libraries.matrix.api.core.RoomId
|
||||||
import io.element.android.libraries.textcomposer.MessageComposerMode
|
import io.element.android.libraries.textcomposer.MessageComposerMode
|
||||||
|
import io.element.android.wysiwyg.compose.RichTextEditorState
|
||||||
import kotlinx.collections.immutable.persistentSetOf
|
import kotlinx.collections.immutable.persistentSetOf
|
||||||
|
|
||||||
open class MessagesStateProvider : PreviewParameterProvider<MessagesState> {
|
open class MessagesStateProvider : PreviewParameterProvider<MessagesState> {
|
||||||
|
|
@ -54,7 +55,9 @@ fun aMessagesState() = MessagesState(
|
||||||
userHasPermissionToSendMessage = true,
|
userHasPermissionToSendMessage = true,
|
||||||
userHasPermissionToRedact = false,
|
userHasPermissionToRedact = false,
|
||||||
composerState = aMessageComposerState().copy(
|
composerState = aMessageComposerState().copy(
|
||||||
text = "Hello",
|
richTextEditorState = RichTextEditorState("Hello", fake = true).apply {
|
||||||
|
requestFocus()
|
||||||
|
},
|
||||||
isFullScreen = false,
|
isFullScreen = false,
|
||||||
mode = MessageComposerMode.Normal("Hello"),
|
mode = MessageComposerMode.Normal("Hello"),
|
||||||
),
|
),
|
||||||
|
|
|
||||||
|
|
@ -70,7 +70,7 @@ class ActionListPresenter @Inject constructor(
|
||||||
return ActionListState(
|
return ActionListState(
|
||||||
target = target.value,
|
target = target.value,
|
||||||
displayEmojiReactions = displayEmojiReactions,
|
displayEmojiReactions = displayEmojiReactions,
|
||||||
eventSink = ::handleEvents
|
eventSink = { handleEvents(it) }
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -17,16 +17,15 @@
|
||||||
package io.element.android.features.messages.impl.messagecomposer
|
package io.element.android.features.messages.impl.messagecomposer
|
||||||
|
|
||||||
import androidx.compose.runtime.Immutable
|
import androidx.compose.runtime.Immutable
|
||||||
|
import io.element.android.libraries.textcomposer.Message
|
||||||
import io.element.android.libraries.textcomposer.MessageComposerMode
|
import io.element.android.libraries.textcomposer.MessageComposerMode
|
||||||
|
|
||||||
@Immutable
|
@Immutable
|
||||||
sealed interface MessageComposerEvents {
|
sealed interface MessageComposerEvents {
|
||||||
data object ToggleFullScreenState : MessageComposerEvents
|
data object ToggleFullScreenState : MessageComposerEvents
|
||||||
data class FocusChanged(val hasFocus: Boolean) : MessageComposerEvents
|
data class SendMessage(val message: Message) : MessageComposerEvents
|
||||||
data class SendMessage(val message: String) : MessageComposerEvents
|
|
||||||
data object CloseSpecialMode : MessageComposerEvents
|
data object CloseSpecialMode : MessageComposerEvents
|
||||||
data class SetMode(val composerMode: MessageComposerMode) : MessageComposerEvents
|
data class SetMode(val composerMode: MessageComposerMode) : MessageComposerEvents
|
||||||
data class UpdateText(val text: String) : MessageComposerEvents
|
|
||||||
data object AddAttachment : MessageComposerEvents
|
data object AddAttachment : MessageComposerEvents
|
||||||
data object DismissAttachmentMenu : MessageComposerEvents
|
data object DismissAttachmentMenu : MessageComposerEvents
|
||||||
sealed interface PickAttachmentSource : MessageComposerEvents {
|
sealed interface PickAttachmentSource : MessageComposerEvents {
|
||||||
|
|
@ -38,4 +37,5 @@ sealed interface MessageComposerEvents {
|
||||||
data object Poll : PickAttachmentSource
|
data object Poll : PickAttachmentSource
|
||||||
}
|
}
|
||||||
data object CancelSendAttachment : MessageComposerEvents
|
data object CancelSendAttachment : MessageComposerEvents
|
||||||
|
data class Error(val error: Throwable) : MessageComposerEvents
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -44,8 +44,10 @@ import io.element.android.libraries.matrix.api.core.ProgressCallback
|
||||||
import io.element.android.libraries.matrix.api.room.MatrixRoom
|
import io.element.android.libraries.matrix.api.room.MatrixRoom
|
||||||
import io.element.android.libraries.mediapickers.api.PickerProvider
|
import io.element.android.libraries.mediapickers.api.PickerProvider
|
||||||
import io.element.android.libraries.mediaupload.api.MediaSender
|
import io.element.android.libraries.mediaupload.api.MediaSender
|
||||||
|
import io.element.android.libraries.textcomposer.Message
|
||||||
import io.element.android.libraries.textcomposer.MessageComposerMode
|
import io.element.android.libraries.textcomposer.MessageComposerMode
|
||||||
import io.element.android.services.analytics.api.AnalyticsService
|
import io.element.android.services.analytics.api.AnalyticsService
|
||||||
|
import io.element.android.wysiwyg.compose.RichTextEditorState
|
||||||
import kotlinx.collections.immutable.persistentListOf
|
import kotlinx.collections.immutable.persistentListOf
|
||||||
import kotlinx.coroutines.CancellationException
|
import kotlinx.coroutines.CancellationException
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
|
@ -67,6 +69,7 @@ class MessageComposerPresenter @Inject constructor(
|
||||||
private val snackbarDispatcher: SnackbarDispatcher,
|
private val snackbarDispatcher: SnackbarDispatcher,
|
||||||
private val analyticsService: AnalyticsService,
|
private val analyticsService: AnalyticsService,
|
||||||
private val messageComposerContext: MessageComposerContextImpl,
|
private val messageComposerContext: MessageComposerContextImpl,
|
||||||
|
private val richTextEditorStateFactory: RichTextEditorStateFactory,
|
||||||
) : Presenter<MessageComposerState> {
|
) : Presenter<MessageComposerState> {
|
||||||
|
|
||||||
@SuppressLint("UnsafeOptInUsageError")
|
@SuppressLint("UnsafeOptInUsageError")
|
||||||
|
|
@ -103,19 +106,15 @@ class MessageComposerPresenter @Inject constructor(
|
||||||
val isFullScreen = rememberSaveable {
|
val isFullScreen = rememberSaveable {
|
||||||
mutableStateOf(false)
|
mutableStateOf(false)
|
||||||
}
|
}
|
||||||
val hasFocus = remember {
|
val richTextEditorState = richTextEditorStateFactory.create()
|
||||||
mutableStateOf(false)
|
|
||||||
}
|
|
||||||
val text: MutableState<String> = rememberSaveable {
|
|
||||||
mutableStateOf("")
|
|
||||||
}
|
|
||||||
val ongoingSendAttachmentJob = remember { mutableStateOf<Job?>(null) }
|
val ongoingSendAttachmentJob = remember { mutableStateOf<Job?>(null) }
|
||||||
|
|
||||||
var showAttachmentSourcePicker: Boolean by remember { mutableStateOf(false) }
|
var showAttachmentSourcePicker: Boolean by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
LaunchedEffect(messageComposerContext.composerMode) {
|
LaunchedEffect(messageComposerContext.composerMode) {
|
||||||
when (val modeValue = messageComposerContext.composerMode) {
|
when (val modeValue = messageComposerContext.composerMode) {
|
||||||
is MessageComposerMode.Edit -> text.value = modeValue.defaultContent
|
is MessageComposerMode.Edit ->
|
||||||
|
richTextEditorState.setHtml(modeValue.defaultContent)
|
||||||
else -> Unit
|
else -> Unit
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -136,18 +135,15 @@ class MessageComposerPresenter @Inject constructor(
|
||||||
when (event) {
|
when (event) {
|
||||||
MessageComposerEvents.ToggleFullScreenState -> isFullScreen.value = !isFullScreen.value
|
MessageComposerEvents.ToggleFullScreenState -> isFullScreen.value = !isFullScreen.value
|
||||||
|
|
||||||
is MessageComposerEvents.FocusChanged -> hasFocus.value = event.hasFocus
|
|
||||||
|
|
||||||
is MessageComposerEvents.UpdateText -> text.value = event.text
|
|
||||||
MessageComposerEvents.CloseSpecialMode -> {
|
MessageComposerEvents.CloseSpecialMode -> {
|
||||||
text.value = ""
|
richTextEditorState.setHtml("")
|
||||||
messageComposerContext.composerMode = MessageComposerMode.Normal("")
|
messageComposerContext.composerMode = MessageComposerMode.Normal("")
|
||||||
}
|
}
|
||||||
|
|
||||||
is MessageComposerEvents.SendMessage -> appCoroutineScope.sendMessage(
|
is MessageComposerEvents.SendMessage -> appCoroutineScope.sendMessage(
|
||||||
text = event.message,
|
message = event.message,
|
||||||
updateComposerMode = { messageComposerContext.composerMode = it },
|
updateComposerMode = { messageComposerContext.composerMode = it },
|
||||||
textState = text
|
richTextEditorState = richTextEditorState,
|
||||||
)
|
)
|
||||||
is MessageComposerEvents.SetMode -> {
|
is MessageComposerEvents.SetMode -> {
|
||||||
messageComposerContext.composerMode = event.composerMode
|
messageComposerContext.composerMode = event.composerMode
|
||||||
|
|
@ -194,43 +190,46 @@ class MessageComposerPresenter @Inject constructor(
|
||||||
ongoingSendAttachmentJob.value == null
|
ongoingSendAttachmentJob.value == null
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
is MessageComposerEvents.Error -> {
|
||||||
|
analyticsService.trackError(event.error)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return MessageComposerState(
|
return MessageComposerState(
|
||||||
text = text.value,
|
richTextEditorState = richTextEditorState,
|
||||||
isFullScreen = isFullScreen.value,
|
isFullScreen = isFullScreen.value,
|
||||||
hasFocus = hasFocus.value,
|
|
||||||
mode = messageComposerContext.composerMode,
|
mode = messageComposerContext.composerMode,
|
||||||
showAttachmentSourcePicker = showAttachmentSourcePicker,
|
showAttachmentSourcePicker = showAttachmentSourcePicker,
|
||||||
canShareLocation = canShareLocation.value,
|
canShareLocation = canShareLocation.value,
|
||||||
canCreatePoll = canCreatePoll.value,
|
canCreatePoll = canCreatePoll.value,
|
||||||
attachmentsState = attachmentsState.value,
|
attachmentsState = attachmentsState.value,
|
||||||
eventSink = ::handleEvents
|
eventSink = { handleEvents(it) }
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun CoroutineScope.sendMessage(
|
private fun CoroutineScope.sendMessage(
|
||||||
text: String,
|
message: Message,
|
||||||
updateComposerMode: (newComposerMode: MessageComposerMode) -> Unit,
|
updateComposerMode: (newComposerMode: MessageComposerMode) -> Unit,
|
||||||
textState: MutableState<String>
|
richTextEditorState: RichTextEditorState,
|
||||||
) = launch {
|
) = launch {
|
||||||
val capturedMode = messageComposerContext.composerMode
|
val capturedMode = messageComposerContext.composerMode
|
||||||
// Reset composer right away
|
// Reset composer right away
|
||||||
textState.value = ""
|
richTextEditorState.setHtml("")
|
||||||
updateComposerMode(MessageComposerMode.Normal(""))
|
updateComposerMode(MessageComposerMode.Normal(""))
|
||||||
when (capturedMode) {
|
when (capturedMode) {
|
||||||
is MessageComposerMode.Normal -> room.sendMessage(text)
|
is MessageComposerMode.Normal -> room.sendMessage(body = message.markdown, htmlBody = message.html)
|
||||||
is MessageComposerMode.Edit -> {
|
is MessageComposerMode.Edit -> {
|
||||||
val eventId = capturedMode.eventId
|
val eventId = capturedMode.eventId
|
||||||
val transactionId = capturedMode.transactionId
|
val transactionId = capturedMode.transactionId
|
||||||
room.editMessage(eventId, transactionId, text)
|
room.editMessage(eventId, transactionId, message.markdown, message.html)
|
||||||
}
|
}
|
||||||
|
|
||||||
is MessageComposerMode.Quote -> TODO()
|
is MessageComposerMode.Quote -> TODO()
|
||||||
is MessageComposerMode.Reply -> room.replyMessage(
|
is MessageComposerMode.Reply -> room.replyMessage(
|
||||||
capturedMode.eventId,
|
capturedMode.eventId,
|
||||||
text
|
message.markdown,
|
||||||
|
message.html,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -19,21 +19,22 @@ package io.element.android.features.messages.impl.messagecomposer
|
||||||
import androidx.compose.runtime.Immutable
|
import androidx.compose.runtime.Immutable
|
||||||
import io.element.android.features.messages.impl.attachments.Attachment
|
import io.element.android.features.messages.impl.attachments.Attachment
|
||||||
import io.element.android.libraries.textcomposer.MessageComposerMode
|
import io.element.android.libraries.textcomposer.MessageComposerMode
|
||||||
|
import io.element.android.wysiwyg.compose.RichTextEditorState
|
||||||
import kotlinx.collections.immutable.ImmutableList
|
import kotlinx.collections.immutable.ImmutableList
|
||||||
|
|
||||||
@Immutable
|
@Immutable
|
||||||
data class MessageComposerState(
|
data class MessageComposerState(
|
||||||
val text: String?,
|
val richTextEditorState: RichTextEditorState,
|
||||||
val isFullScreen: Boolean,
|
val isFullScreen: Boolean,
|
||||||
val hasFocus: Boolean,
|
|
||||||
val mode: MessageComposerMode,
|
val mode: MessageComposerMode,
|
||||||
val showAttachmentSourcePicker: Boolean,
|
val showAttachmentSourcePicker: Boolean,
|
||||||
val canShareLocation: Boolean,
|
val canShareLocation: Boolean,
|
||||||
val canCreatePoll: Boolean,
|
val canCreatePoll: Boolean,
|
||||||
val attachmentsState: AttachmentsState,
|
val attachmentsState: AttachmentsState,
|
||||||
val eventSink: (MessageComposerEvents) -> Unit
|
val eventSink: (MessageComposerEvents) -> Unit,
|
||||||
) {
|
) {
|
||||||
val isSendButtonVisible: Boolean = text.isNullOrEmpty().not()
|
val canSendMessage: Boolean = richTextEditorState.messageHtml.isNotEmpty()
|
||||||
|
val hasFocus: Boolean = richTextEditorState.hasFocus
|
||||||
}
|
}
|
||||||
|
|
||||||
@Immutable
|
@Immutable
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,7 @@ package io.element.android.features.messages.impl.messagecomposer
|
||||||
|
|
||||||
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
|
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
|
||||||
import io.element.android.libraries.textcomposer.MessageComposerMode
|
import io.element.android.libraries.textcomposer.MessageComposerMode
|
||||||
|
import io.element.android.wysiwyg.compose.RichTextEditorState
|
||||||
|
|
||||||
open class MessageComposerStateProvider : PreviewParameterProvider<MessageComposerState> {
|
open class MessageComposerStateProvider : PreviewParameterProvider<MessageComposerState> {
|
||||||
override val values: Sequence<MessageComposerState>
|
override val values: Sequence<MessageComposerState>
|
||||||
|
|
@ -27,18 +28,17 @@ open class MessageComposerStateProvider : PreviewParameterProvider<MessageCompos
|
||||||
}
|
}
|
||||||
|
|
||||||
fun aMessageComposerState(
|
fun aMessageComposerState(
|
||||||
text: String = "",
|
requestFocus: Boolean = true,
|
||||||
|
composerState: RichTextEditorState = RichTextEditorState("", fake = true),
|
||||||
isFullScreen: Boolean = false,
|
isFullScreen: Boolean = false,
|
||||||
hasFocus: Boolean = false,
|
|
||||||
mode: MessageComposerMode = MessageComposerMode.Normal(content = ""),
|
mode: MessageComposerMode = MessageComposerMode.Normal(content = ""),
|
||||||
showAttachmentSourcePicker: Boolean = false,
|
showAttachmentSourcePicker: Boolean = false,
|
||||||
canShareLocation: Boolean = true,
|
canShareLocation: Boolean = true,
|
||||||
canCreatePoll: Boolean = true,
|
canCreatePoll: Boolean = true,
|
||||||
attachmentsState: AttachmentsState = AttachmentsState.None,
|
attachmentsState: AttachmentsState = AttachmentsState.None,
|
||||||
) = MessageComposerState(
|
) = MessageComposerState(
|
||||||
text = text,
|
richTextEditorState = composerState.apply { if(requestFocus) requestFocus() },
|
||||||
isFullScreen = isFullScreen,
|
isFullScreen = isFullScreen,
|
||||||
hasFocus = hasFocus,
|
|
||||||
mode = mode,
|
mode = mode,
|
||||||
showAttachmentSourcePicker = showAttachmentSourcePicker,
|
showAttachmentSourcePicker = showAttachmentSourcePicker,
|
||||||
canShareLocation = canShareLocation,
|
canShareLocation = canShareLocation,
|
||||||
|
|
|
||||||
|
|
@ -23,6 +23,7 @@ import androidx.compose.ui.tooling.preview.Preview
|
||||||
import androidx.compose.ui.tooling.preview.PreviewParameter
|
import androidx.compose.ui.tooling.preview.PreviewParameter
|
||||||
import io.element.android.libraries.designsystem.preview.ElementPreviewDark
|
import io.element.android.libraries.designsystem.preview.ElementPreviewDark
|
||||||
import io.element.android.libraries.designsystem.preview.ElementPreviewLight
|
import io.element.android.libraries.designsystem.preview.ElementPreviewLight
|
||||||
|
import io.element.android.libraries.textcomposer.Message
|
||||||
import io.element.android.libraries.textcomposer.TextComposer
|
import io.element.android.libraries.textcomposer.TextComposer
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
|
|
@ -36,7 +37,7 @@ fun MessageComposerView(
|
||||||
state.eventSink(MessageComposerEvents.ToggleFullScreenState)
|
state.eventSink(MessageComposerEvents.ToggleFullScreenState)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun sendMessage(message: String) {
|
fun sendMessage(message: Message) {
|
||||||
state.eventSink(MessageComposerEvents.SendMessage(message))
|
state.eventSink(MessageComposerEvents.SendMessage(message))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -48,12 +49,8 @@ fun MessageComposerView(
|
||||||
state.eventSink(MessageComposerEvents.CloseSpecialMode)
|
state.eventSink(MessageComposerEvents.CloseSpecialMode)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun onComposerTextChange(text: String) {
|
fun onError(error: Throwable) {
|
||||||
state.eventSink(MessageComposerEvents.UpdateText(text))
|
state.eventSink(MessageComposerEvents.Error(error))
|
||||||
}
|
|
||||||
|
|
||||||
fun onFocusChanged(hasFocus: Boolean) {
|
|
||||||
state.eventSink(MessageComposerEvents.FocusChanged(hasFocus))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Box(modifier = modifier) {
|
Box(modifier = modifier) {
|
||||||
|
|
@ -64,14 +61,14 @@ fun MessageComposerView(
|
||||||
)
|
)
|
||||||
|
|
||||||
TextComposer(
|
TextComposer(
|
||||||
|
state = state.richTextEditorState,
|
||||||
|
canSendMessage = state.canSendMessage,
|
||||||
|
onRequestFocus = { state.richTextEditorState.requestFocus() },
|
||||||
onSendMessage = ::sendMessage,
|
onSendMessage = ::sendMessage,
|
||||||
composerMode = state.mode,
|
composerMode = state.mode,
|
||||||
onResetComposerMode = ::onCloseSpecialMode,
|
onResetComposerMode = ::onCloseSpecialMode,
|
||||||
onComposerTextChange = ::onComposerTextChange,
|
|
||||||
onAddAttachment = ::onAddAttachment,
|
onAddAttachment = ::onAddAttachment,
|
||||||
onFocusChanged = ::onFocusChanged,
|
onError = ::onError,
|
||||||
composerCanSendMessage = state.isSendButtonVisible,
|
|
||||||
composerText = state.text
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,38 @@
|
||||||
|
/*
|
||||||
|
* Copyright (c) 2023 New Vector Ltd
|
||||||
|
*
|
||||||
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
* you may not use this file except in compliance with the License.
|
||||||
|
* You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing, software
|
||||||
|
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
* See the License for the specific language governing permissions and
|
||||||
|
* limitations under the License.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package io.element.android.features.messages.impl.messagecomposer
|
||||||
|
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import com.squareup.anvil.annotations.ContributesBinding
|
||||||
|
import io.element.android.libraries.di.AppScope
|
||||||
|
import io.element.android.wysiwyg.compose.RichTextEditorState
|
||||||
|
import io.element.android.wysiwyg.compose.rememberRichTextEditorState
|
||||||
|
import javax.inject.Inject
|
||||||
|
|
||||||
|
interface RichTextEditorStateFactory {
|
||||||
|
@Composable
|
||||||
|
fun create(): RichTextEditorState
|
||||||
|
}
|
||||||
|
|
||||||
|
@ContributesBinding(AppScope::class)
|
||||||
|
class DefaultRichTextEditorStateFactory @Inject constructor() : RichTextEditorStateFactory {
|
||||||
|
@Composable
|
||||||
|
override fun create(): RichTextEditorState {
|
||||||
|
return rememberRichTextEditorState()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
@ -21,6 +21,7 @@ import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.MutableState
|
import androidx.compose.runtime.MutableState
|
||||||
import androidx.compose.runtime.collectAsState
|
import androidx.compose.runtime.collectAsState
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableIntStateOf
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.runtime.rememberCoroutineScope
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
|
|
@ -61,7 +62,7 @@ class TimelinePresenter @Inject constructor(
|
||||||
mutableStateOf(null)
|
mutableStateOf(null)
|
||||||
}
|
}
|
||||||
|
|
||||||
val lastReadReceiptIndex = rememberSaveable { mutableStateOf(Int.MAX_VALUE) }
|
val lastReadReceiptIndex = rememberSaveable { mutableIntStateOf(Int.MAX_VALUE) }
|
||||||
val lastReadReceiptId = rememberSaveable { mutableStateOf<EventId?>(null) }
|
val lastReadReceiptId = rememberSaveable { mutableStateOf<EventId?>(null) }
|
||||||
|
|
||||||
val timelineItems by timelineItemsFactory.collectItemsAsState()
|
val timelineItems by timelineItemsFactory.collectItemsAsState()
|
||||||
|
|
@ -119,7 +120,7 @@ class TimelinePresenter @Inject constructor(
|
||||||
paginationState = paginationState,
|
paginationState = paginationState,
|
||||||
timelineItems = timelineItems,
|
timelineItems = timelineItems,
|
||||||
hasNewItems = hasNewItems.value,
|
hasNewItems = hasNewItems.value,
|
||||||
eventSink = ::handleEvents
|
eventSink = { handleEvents(it) }
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -63,7 +63,7 @@ class CustomReactionPresenter @Inject constructor(
|
||||||
return CustomReactionState(
|
return CustomReactionState(
|
||||||
target = target.value,
|
target = target.value,
|
||||||
selectedEmoji = selectedEmoji,
|
selectedEmoji = selectedEmoji,
|
||||||
eventSink = ::handleEvents
|
eventSink = { handleEvents(it) }
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -61,7 +61,7 @@ class ReactionSummaryPresenter @Inject constructor(
|
||||||
}
|
}
|
||||||
return ReactionSummaryState(
|
return ReactionSummaryState(
|
||||||
target = targetWithAvatars.value,
|
target = targetWithAvatars.value,
|
||||||
eventSink = ::handleEvents
|
eventSink = { handleEvents(it) }
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -66,7 +66,7 @@ class RetrySendMenuPresenter @Inject constructor(
|
||||||
|
|
||||||
return RetrySendMenuState(
|
return RetrySendMenuState(
|
||||||
selectedEvent = selectedEvent,
|
selectedEvent = selectedEvent,
|
||||||
eventSink = ::handleEvent,
|
eventSink = { handleEvent(it) },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -22,4 +22,6 @@ sealed interface TimelineItemTextBasedContent : TimelineItemEventContent {
|
||||||
val body: String
|
val body: String
|
||||||
val htmlDocument: Document?
|
val htmlDocument: Document?
|
||||||
val isEdited: Boolean
|
val isEdited: Boolean
|
||||||
|
val htmlBody: String?
|
||||||
|
get() = htmlDocument?.body()?.html()
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -30,7 +30,6 @@ import io.element.android.features.messages.impl.actionlist.ActionListPresenter
|
||||||
import io.element.android.features.messages.impl.actionlist.ActionListState
|
import io.element.android.features.messages.impl.actionlist.ActionListState
|
||||||
import io.element.android.features.messages.impl.actionlist.model.TimelineItemAction
|
import io.element.android.features.messages.impl.actionlist.model.TimelineItemAction
|
||||||
import io.element.android.features.messages.impl.messagecomposer.MessageComposerContextImpl
|
import io.element.android.features.messages.impl.messagecomposer.MessageComposerContextImpl
|
||||||
import io.element.android.features.messages.impl.messagecomposer.MessageComposerEvents
|
|
||||||
import io.element.android.features.messages.impl.messagecomposer.MessageComposerPresenter
|
import io.element.android.features.messages.impl.messagecomposer.MessageComposerPresenter
|
||||||
import io.element.android.features.messages.impl.timeline.TimelinePresenter
|
import io.element.android.features.messages.impl.timeline.TimelinePresenter
|
||||||
import io.element.android.features.messages.impl.timeline.components.customreaction.CustomReactionPresenter
|
import io.element.android.features.messages.impl.timeline.components.customreaction.CustomReactionPresenter
|
||||||
|
|
@ -41,6 +40,7 @@ import io.element.android.features.messages.impl.timeline.model.event.TimelineIt
|
||||||
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemTextContent
|
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemTextContent
|
||||||
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemVideoContent
|
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemVideoContent
|
||||||
import io.element.android.features.messages.media.FakeLocalMediaFactory
|
import io.element.android.features.messages.media.FakeLocalMediaFactory
|
||||||
|
import io.element.android.features.messages.textcomposer.TestRichTextEditorStateFactory
|
||||||
import io.element.android.features.messages.timeline.components.customreaction.FakeEmojibaseProvider
|
import io.element.android.features.messages.timeline.components.customreaction.FakeEmojibaseProvider
|
||||||
import io.element.android.features.messages.utils.messagesummary.FakeMessageSummaryFormatter
|
import io.element.android.features.messages.utils.messagesummary.FakeMessageSummaryFormatter
|
||||||
import io.element.android.features.networkmonitor.test.FakeNetworkMonitor
|
import io.element.android.features.networkmonitor.test.FakeNetworkMonitor
|
||||||
|
|
@ -325,6 +325,7 @@ class MessagesPresenterTest {
|
||||||
initialState.eventSink.invoke(MessagesEvents.HandleAction(TimelineItemAction.Redact, aMessageEvent()))
|
initialState.eventSink.invoke(MessagesEvents.HandleAction(TimelineItemAction.Redact, aMessageEvent()))
|
||||||
assertThat(matrixRoom.redactEventEventIdParam).isEqualTo(AN_EVENT_ID)
|
assertThat(matrixRoom.redactEventEventIdParam).isEqualTo(AN_EVENT_ID)
|
||||||
assertThat(awaitItem().actionListState.target).isEqualTo(ActionListState.Target.None)
|
assertThat(awaitItem().actionListState.target).isEqualTo(ActionListState.Target.None)
|
||||||
|
skipItems(1) // back paginating
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -381,7 +382,7 @@ class MessagesPresenterTest {
|
||||||
// Initially the composer doesn't have focus, so we don't show the alert
|
// Initially the composer doesn't have focus, so we don't show the alert
|
||||||
assertThat(initialState.showReinvitePrompt).isFalse()
|
assertThat(initialState.showReinvitePrompt).isFalse()
|
||||||
// When the input field is focused we show the alert
|
// When the input field is focused we show the alert
|
||||||
initialState.composerState.eventSink(MessageComposerEvents.FocusChanged(true))
|
initialState.composerState.richTextEditorState.requestFocus()
|
||||||
val focusedState = consumeItemsUntilPredicate(timeout = 250.milliseconds) { state ->
|
val focusedState = consumeItemsUntilPredicate(timeout = 250.milliseconds) { state ->
|
||||||
state.showReinvitePrompt
|
state.showReinvitePrompt
|
||||||
}.last()
|
}.last()
|
||||||
|
|
@ -405,7 +406,7 @@ class MessagesPresenterTest {
|
||||||
skipItems(1)
|
skipItems(1)
|
||||||
val initialState = awaitItem()
|
val initialState = awaitItem()
|
||||||
assertThat(initialState.showReinvitePrompt).isFalse()
|
assertThat(initialState.showReinvitePrompt).isFalse()
|
||||||
initialState.composerState.eventSink(MessageComposerEvents.FocusChanged(true))
|
initialState.composerState.richTextEditorState.requestFocus()
|
||||||
val focusedState = awaitItem()
|
val focusedState = awaitItem()
|
||||||
assertThat(focusedState.showReinvitePrompt).isFalse()
|
assertThat(focusedState.showReinvitePrompt).isFalse()
|
||||||
}
|
}
|
||||||
|
|
@ -421,7 +422,7 @@ class MessagesPresenterTest {
|
||||||
skipItems(1)
|
skipItems(1)
|
||||||
val initialState = awaitItem()
|
val initialState = awaitItem()
|
||||||
assertThat(initialState.showReinvitePrompt).isFalse()
|
assertThat(initialState.showReinvitePrompt).isFalse()
|
||||||
initialState.composerState.eventSink(MessageComposerEvents.FocusChanged(true))
|
initialState.composerState.richTextEditorState.requestFocus()
|
||||||
val focusedState = awaitItem()
|
val focusedState = awaitItem()
|
||||||
assertThat(focusedState.showReinvitePrompt).isFalse()
|
assertThat(focusedState.showReinvitePrompt).isFalse()
|
||||||
}
|
}
|
||||||
|
|
@ -605,6 +606,8 @@ class MessagesPresenterTest {
|
||||||
snackbarDispatcher = SnackbarDispatcher(),
|
snackbarDispatcher = SnackbarDispatcher(),
|
||||||
analyticsService = FakeAnalyticsService(),
|
analyticsService = FakeAnalyticsService(),
|
||||||
messageComposerContext = MessageComposerContextImpl(),
|
messageComposerContext = MessageComposerContextImpl(),
|
||||||
|
richTextEditorStateFactory = TestRichTextEditorStateFactory(),
|
||||||
|
|
||||||
)
|
)
|
||||||
val timelinePresenter = TimelinePresenter(
|
val timelinePresenter = TimelinePresenter(
|
||||||
timelineItemsFactory = aTimelineItemsFactory(),
|
timelineItemsFactory = aTimelineItemsFactory(),
|
||||||
|
|
|
||||||
|
|
@ -53,6 +53,7 @@ import io.element.android.libraries.mediaupload.api.MediaPreProcessor
|
||||||
import io.element.android.libraries.mediaupload.api.MediaSender
|
import io.element.android.libraries.mediaupload.api.MediaSender
|
||||||
import io.element.android.libraries.mediaupload.api.MediaUploadInfo
|
import io.element.android.libraries.mediaupload.api.MediaUploadInfo
|
||||||
import io.element.android.libraries.mediaupload.test.FakeMediaPreProcessor
|
import io.element.android.libraries.mediaupload.test.FakeMediaPreProcessor
|
||||||
|
import io.element.android.libraries.textcomposer.Message
|
||||||
import io.element.android.libraries.textcomposer.MessageComposerMode
|
import io.element.android.libraries.textcomposer.MessageComposerMode
|
||||||
import io.element.android.services.analytics.test.FakeAnalyticsService
|
import io.element.android.services.analytics.test.FakeAnalyticsService
|
||||||
import io.element.android.tests.testutils.WarmUpRule
|
import io.element.android.tests.testutils.WarmUpRule
|
||||||
|
|
@ -80,6 +81,7 @@ class MessageComposerPresenterTest {
|
||||||
private val snackbarDispatcher = SnackbarDispatcher()
|
private val snackbarDispatcher = SnackbarDispatcher()
|
||||||
private val mockMediaUrl: Uri = mockk("localMediaUri")
|
private val mockMediaUrl: Uri = mockk("localMediaUri")
|
||||||
private val localMediaFactory = FakeLocalMediaFactory(mockMediaUrl)
|
private val localMediaFactory = FakeLocalMediaFactory(mockMediaUrl)
|
||||||
|
private val analyticsService = FakeAnalyticsService()
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `present - initial state`() = runTest {
|
fun `present - initial state`() = runTest {
|
||||||
|
|
@ -90,12 +92,12 @@ class MessageComposerPresenterTest {
|
||||||
skipItems(1)
|
skipItems(1)
|
||||||
val initialState = awaitItem()
|
val initialState = awaitItem()
|
||||||
assertThat(initialState.isFullScreen).isFalse()
|
assertThat(initialState.isFullScreen).isFalse()
|
||||||
assertThat(initialState.text).isEqualTo("")
|
assertThat(initialState.richTextEditorState.messageHtml).isEqualTo("")
|
||||||
assertThat(initialState.mode).isEqualTo(MessageComposerMode.Normal(""))
|
assertThat(initialState.mode).isEqualTo(MessageComposerMode.Normal(""))
|
||||||
assertThat(initialState.showAttachmentSourcePicker).isFalse()
|
assertThat(initialState.showAttachmentSourcePicker).isFalse()
|
||||||
assertThat(initialState.canShareLocation).isTrue()
|
assertThat(initialState.canShareLocation).isTrue()
|
||||||
assertThat(initialState.attachmentsState).isEqualTo(AttachmentsState.None)
|
assertThat(initialState.attachmentsState).isEqualTo(AttachmentsState.None)
|
||||||
assertThat(initialState.isSendButtonVisible).isFalse()
|
assertThat(initialState.canSendMessage).isFalse()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -124,14 +126,14 @@ class MessageComposerPresenterTest {
|
||||||
}.test {
|
}.test {
|
||||||
skipItems(1)
|
skipItems(1)
|
||||||
val initialState = awaitItem()
|
val initialState = awaitItem()
|
||||||
initialState.eventSink.invoke(MessageComposerEvents.UpdateText(A_MESSAGE))
|
initialState.richTextEditorState.setHtml(A_MESSAGE)
|
||||||
val withMessageState = awaitItem()
|
val withMessageState = awaitItem()
|
||||||
assertThat(withMessageState.text).isEqualTo(A_MESSAGE)
|
assertThat(withMessageState.richTextEditorState.messageHtml).isEqualTo(A_MESSAGE)
|
||||||
assertThat(withMessageState.isSendButtonVisible).isTrue()
|
assertThat(withMessageState.canSendMessage).isTrue()
|
||||||
withMessageState.eventSink.invoke(MessageComposerEvents.UpdateText(""))
|
withMessageState.richTextEditorState.setHtml("")
|
||||||
val withEmptyMessageState = awaitItem()
|
val withEmptyMessageState = awaitItem()
|
||||||
assertThat(withEmptyMessageState.text).isEqualTo("")
|
assertThat(withEmptyMessageState.richTextEditorState.messageHtml).isEqualTo("")
|
||||||
assertThat(withEmptyMessageState.isSendButtonVisible).isFalse()
|
assertThat(withEmptyMessageState.canSendMessage).isFalse()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -148,8 +150,8 @@ class MessageComposerPresenterTest {
|
||||||
state = awaitItem()
|
state = awaitItem()
|
||||||
assertThat(state.mode).isEqualTo(mode)
|
assertThat(state.mode).isEqualTo(mode)
|
||||||
state = awaitItem()
|
state = awaitItem()
|
||||||
assertThat(state.text).isEqualTo(A_MESSAGE)
|
assertThat(state.richTextEditorState.messageHtml).isEqualTo(A_MESSAGE)
|
||||||
assertThat(state.isSendButtonVisible).isTrue()
|
assertThat(state.canSendMessage).isTrue()
|
||||||
backToNormalMode(state, skipCount = 1)
|
backToNormalMode(state, skipCount = 1)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -166,8 +168,8 @@ class MessageComposerPresenterTest {
|
||||||
state.eventSink.invoke(MessageComposerEvents.SetMode(mode))
|
state.eventSink.invoke(MessageComposerEvents.SetMode(mode))
|
||||||
state = awaitItem()
|
state = awaitItem()
|
||||||
assertThat(state.mode).isEqualTo(mode)
|
assertThat(state.mode).isEqualTo(mode)
|
||||||
assertThat(state.text).isEqualTo("")
|
assertThat(state.richTextEditorState.messageHtml).isEqualTo("")
|
||||||
assertThat(state.isSendButtonVisible).isFalse()
|
assertThat(state.canSendMessage).isFalse()
|
||||||
backToNormalMode(state)
|
backToNormalMode(state)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -184,8 +186,8 @@ class MessageComposerPresenterTest {
|
||||||
state.eventSink.invoke(MessageComposerEvents.SetMode(mode))
|
state.eventSink.invoke(MessageComposerEvents.SetMode(mode))
|
||||||
state = awaitItem()
|
state = awaitItem()
|
||||||
assertThat(state.mode).isEqualTo(mode)
|
assertThat(state.mode).isEqualTo(mode)
|
||||||
assertThat(state.text).isEqualTo("")
|
assertThat(state.richTextEditorState.messageHtml).isEqualTo("")
|
||||||
assertThat(state.isSendButtonVisible).isFalse()
|
assertThat(state.canSendMessage).isFalse()
|
||||||
backToNormalMode(state)
|
backToNormalMode(state)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -198,14 +200,14 @@ class MessageComposerPresenterTest {
|
||||||
}.test {
|
}.test {
|
||||||
skipItems(1)
|
skipItems(1)
|
||||||
val initialState = awaitItem()
|
val initialState = awaitItem()
|
||||||
initialState.eventSink.invoke(MessageComposerEvents.UpdateText(A_MESSAGE))
|
initialState.richTextEditorState.setHtml(A_MESSAGE)
|
||||||
val withMessageState = awaitItem()
|
val withMessageState = awaitItem()
|
||||||
assertThat(withMessageState.text).isEqualTo(A_MESSAGE)
|
assertThat(withMessageState.richTextEditorState.messageHtml).isEqualTo(A_MESSAGE)
|
||||||
assertThat(withMessageState.isSendButtonVisible).isTrue()
|
assertThat(withMessageState.canSendMessage).isTrue()
|
||||||
withMessageState.eventSink.invoke(MessageComposerEvents.SendMessage(A_MESSAGE))
|
withMessageState.eventSink.invoke(MessageComposerEvents.SendMessage(A_MESSAGE.toMessage()))
|
||||||
val messageSentState = awaitItem()
|
val messageSentState = awaitItem()
|
||||||
assertThat(messageSentState.text).isEqualTo("")
|
assertThat(messageSentState.richTextEditorState.messageHtml).isEqualTo("")
|
||||||
assertThat(messageSentState.isSendButtonVisible).isFalse()
|
assertThat(messageSentState.canSendMessage).isFalse()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -221,23 +223,23 @@ class MessageComposerPresenterTest {
|
||||||
}.test {
|
}.test {
|
||||||
skipItems(1)
|
skipItems(1)
|
||||||
val initialState = awaitItem()
|
val initialState = awaitItem()
|
||||||
assertThat(initialState.text).isEqualTo("")
|
assertThat(initialState.richTextEditorState.messageHtml).isEqualTo("")
|
||||||
val mode = anEditMode()
|
val mode = anEditMode()
|
||||||
initialState.eventSink.invoke(MessageComposerEvents.SetMode(mode))
|
initialState.eventSink.invoke(MessageComposerEvents.SetMode(mode))
|
||||||
skipItems(1)
|
skipItems(1)
|
||||||
val withMessageState = awaitItem()
|
val withMessageState = awaitItem()
|
||||||
assertThat(withMessageState.mode).isEqualTo(mode)
|
assertThat(withMessageState.mode).isEqualTo(mode)
|
||||||
assertThat(withMessageState.text).isEqualTo(A_MESSAGE)
|
assertThat(withMessageState.richTextEditorState.messageHtml).isEqualTo(A_MESSAGE)
|
||||||
assertThat(withMessageState.isSendButtonVisible).isTrue()
|
assertThat(withMessageState.canSendMessage).isTrue()
|
||||||
withMessageState.eventSink.invoke(MessageComposerEvents.UpdateText(ANOTHER_MESSAGE))
|
withMessageState.richTextEditorState.setHtml(ANOTHER_MESSAGE)
|
||||||
val withEditedMessageState = awaitItem()
|
val withEditedMessageState = awaitItem()
|
||||||
assertThat(withEditedMessageState.text).isEqualTo(ANOTHER_MESSAGE)
|
assertThat(withEditedMessageState.richTextEditorState.messageHtml).isEqualTo(ANOTHER_MESSAGE)
|
||||||
withEditedMessageState.eventSink.invoke(MessageComposerEvents.SendMessage(ANOTHER_MESSAGE))
|
withEditedMessageState.eventSink.invoke(MessageComposerEvents.SendMessage(ANOTHER_MESSAGE.toMessage()))
|
||||||
skipItems(1)
|
skipItems(1)
|
||||||
val messageSentState = awaitItem()
|
val messageSentState = awaitItem()
|
||||||
assertThat(messageSentState.text).isEqualTo("")
|
assertThat(messageSentState.richTextEditorState.messageHtml).isEqualTo("")
|
||||||
assertThat(messageSentState.isSendButtonVisible).isFalse()
|
assertThat(messageSentState.canSendMessage).isFalse()
|
||||||
assertThat(fakeMatrixRoom.editMessageCalls.first()).isEqualTo(ANOTHER_MESSAGE)
|
assertThat(fakeMatrixRoom.editMessageCalls.first()).isEqualTo(ANOTHER_MESSAGE to ANOTHER_MESSAGE)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -253,23 +255,23 @@ class MessageComposerPresenterTest {
|
||||||
}.test {
|
}.test {
|
||||||
skipItems(1)
|
skipItems(1)
|
||||||
val initialState = awaitItem()
|
val initialState = awaitItem()
|
||||||
assertThat(initialState.text).isEqualTo("")
|
assertThat(initialState.richTextEditorState.messageHtml).isEqualTo("")
|
||||||
val mode = anEditMode(eventId = null, transactionId = A_TRANSACTION_ID)
|
val mode = anEditMode(eventId = null, transactionId = A_TRANSACTION_ID)
|
||||||
initialState.eventSink.invoke(MessageComposerEvents.SetMode(mode))
|
initialState.eventSink.invoke(MessageComposerEvents.SetMode(mode))
|
||||||
skipItems(1)
|
skipItems(1)
|
||||||
val withMessageState = awaitItem()
|
val withMessageState = awaitItem()
|
||||||
assertThat(withMessageState.mode).isEqualTo(mode)
|
assertThat(withMessageState.mode).isEqualTo(mode)
|
||||||
assertThat(withMessageState.text).isEqualTo(A_MESSAGE)
|
assertThat(withMessageState.richTextEditorState.messageHtml).isEqualTo(A_MESSAGE)
|
||||||
assertThat(withMessageState.isSendButtonVisible).isTrue()
|
assertThat(withMessageState.canSendMessage).isTrue()
|
||||||
withMessageState.eventSink.invoke(MessageComposerEvents.UpdateText(ANOTHER_MESSAGE))
|
withMessageState.richTextEditorState.setHtml(ANOTHER_MESSAGE)
|
||||||
val withEditedMessageState = awaitItem()
|
val withEditedMessageState = awaitItem()
|
||||||
assertThat(withEditedMessageState.text).isEqualTo(ANOTHER_MESSAGE)
|
assertThat(withEditedMessageState.richTextEditorState.messageHtml).isEqualTo(ANOTHER_MESSAGE)
|
||||||
withEditedMessageState.eventSink.invoke(MessageComposerEvents.SendMessage(ANOTHER_MESSAGE))
|
withEditedMessageState.eventSink.invoke(MessageComposerEvents.SendMessage(ANOTHER_MESSAGE.toMessage()))
|
||||||
skipItems(1)
|
skipItems(1)
|
||||||
val messageSentState = awaitItem()
|
val messageSentState = awaitItem()
|
||||||
assertThat(messageSentState.text).isEqualTo("")
|
assertThat(messageSentState.richTextEditorState.messageHtml).isEqualTo("")
|
||||||
assertThat(messageSentState.isSendButtonVisible).isFalse()
|
assertThat(messageSentState.canSendMessage).isFalse()
|
||||||
assertThat(fakeMatrixRoom.editMessageCalls.first()).isEqualTo(ANOTHER_MESSAGE)
|
assertThat(fakeMatrixRoom.editMessageCalls.first()).isEqualTo(ANOTHER_MESSAGE to ANOTHER_MESSAGE)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -285,23 +287,23 @@ class MessageComposerPresenterTest {
|
||||||
}.test {
|
}.test {
|
||||||
skipItems(1)
|
skipItems(1)
|
||||||
val initialState = awaitItem()
|
val initialState = awaitItem()
|
||||||
assertThat(initialState.text).isEqualTo("")
|
assertThat(initialState.richTextEditorState.messageHtml).isEqualTo("")
|
||||||
val mode = aReplyMode()
|
val mode = aReplyMode()
|
||||||
initialState.eventSink.invoke(MessageComposerEvents.SetMode(mode))
|
initialState.eventSink.invoke(MessageComposerEvents.SetMode(mode))
|
||||||
val state = awaitItem()
|
val state = awaitItem()
|
||||||
assertThat(state.mode).isEqualTo(mode)
|
assertThat(state.mode).isEqualTo(mode)
|
||||||
assertThat(state.text).isEqualTo("")
|
assertThat(state.richTextEditorState.messageHtml).isEqualTo("")
|
||||||
assertThat(state.isSendButtonVisible).isFalse()
|
assertThat(state.canSendMessage).isFalse()
|
||||||
initialState.eventSink.invoke(MessageComposerEvents.UpdateText(A_REPLY))
|
state.richTextEditorState.setHtml(A_REPLY)
|
||||||
val withMessageState = awaitItem()
|
val withMessageState = awaitItem()
|
||||||
assertThat(withMessageState.text).isEqualTo(A_REPLY)
|
assertThat(withMessageState.richTextEditorState.messageHtml).isEqualTo(A_REPLY)
|
||||||
assertThat(withMessageState.isSendButtonVisible).isTrue()
|
assertThat(withMessageState.canSendMessage).isTrue()
|
||||||
withMessageState.eventSink.invoke(MessageComposerEvents.SendMessage(A_REPLY))
|
withMessageState.eventSink.invoke(MessageComposerEvents.SendMessage(A_REPLY.toMessage()))
|
||||||
skipItems(1)
|
skipItems(1)
|
||||||
val messageSentState = awaitItem()
|
val messageSentState = awaitItem()
|
||||||
assertThat(messageSentState.text).isEqualTo("")
|
assertThat(messageSentState.richTextEditorState.messageHtml).isEqualTo("")
|
||||||
assertThat(messageSentState.isSendButtonVisible).isFalse()
|
assertThat(messageSentState.canSendMessage).isFalse()
|
||||||
assertThat(fakeMatrixRoom.replyMessageParameter).isEqualTo(A_REPLY)
|
assertThat(fakeMatrixRoom.replyMessageParameter).isEqualTo(A_REPLY to A_REPLY)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -523,13 +525,27 @@ class MessageComposerPresenterTest {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `present - errors are tracked`() = runTest {
|
||||||
|
val testException = Exception("Test error")
|
||||||
|
val presenter = createPresenter(this)
|
||||||
|
moleculeFlow(RecompositionMode.Immediate) {
|
||||||
|
presenter.present()
|
||||||
|
}.test {
|
||||||
|
skipItems(1)
|
||||||
|
val initialState = awaitItem()
|
||||||
|
initialState.eventSink(MessageComposerEvents.Error(testException))
|
||||||
|
assertThat(analyticsService.trackedErrors).containsExactly(testException)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private suspend fun ReceiveTurbine<MessageComposerState>.backToNormalMode(state: MessageComposerState, skipCount: Int = 0) {
|
private suspend fun ReceiveTurbine<MessageComposerState>.backToNormalMode(state: MessageComposerState, skipCount: Int = 0) {
|
||||||
state.eventSink.invoke(MessageComposerEvents.CloseSpecialMode)
|
state.eventSink.invoke(MessageComposerEvents.CloseSpecialMode)
|
||||||
skipItems(skipCount)
|
skipItems(skipCount)
|
||||||
val normalState = awaitItem()
|
val normalState = awaitItem()
|
||||||
assertThat(normalState.mode).isEqualTo(MessageComposerMode.Normal(""))
|
assertThat(normalState.mode).isEqualTo(MessageComposerMode.Normal(""))
|
||||||
assertThat(normalState.text).isEqualTo("")
|
assertThat(normalState.richTextEditorState.messageHtml).isEqualTo("")
|
||||||
assertThat(normalState.isSendButtonVisible).isFalse()
|
assertThat(normalState.canSendMessage).isFalse()
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun createPresenter(
|
private fun createPresenter(
|
||||||
|
|
@ -547,8 +563,9 @@ class MessageComposerPresenterTest {
|
||||||
localMediaFactory,
|
localMediaFactory,
|
||||||
MediaSender(mediaPreProcessor, room),
|
MediaSender(mediaPreProcessor, room),
|
||||||
snackbarDispatcher,
|
snackbarDispatcher,
|
||||||
FakeAnalyticsService(),
|
analyticsService,
|
||||||
MessageComposerContextImpl(),
|
MessageComposerContextImpl(),
|
||||||
|
TestRichTextEditorStateFactory(),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -560,3 +577,8 @@ fun anEditMode(
|
||||||
|
|
||||||
fun aReplyMode() = MessageComposerMode.Reply(A_USER_NAME, null, AN_EVENT_ID, A_MESSAGE)
|
fun aReplyMode() = MessageComposerMode.Reply(A_USER_NAME, null, AN_EVENT_ID, A_MESSAGE)
|
||||||
fun aQuoteMode() = MessageComposerMode.Quote(AN_EVENT_ID, A_MESSAGE)
|
fun aQuoteMode() = MessageComposerMode.Quote(AN_EVENT_ID, A_MESSAGE)
|
||||||
|
|
||||||
|
private fun String.toMessage() = Message(
|
||||||
|
html = this,
|
||||||
|
markdown = this,
|
||||||
|
)
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,29 @@
|
||||||
|
/*
|
||||||
|
* Copyright (c) 2023 New Vector Ltd
|
||||||
|
*
|
||||||
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
* you may not use this file except in compliance with the License.
|
||||||
|
* You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing, software
|
||||||
|
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
* See the License for the specific language governing permissions and
|
||||||
|
* limitations under the License.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package io.element.android.features.messages.textcomposer
|
||||||
|
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import io.element.android.features.messages.impl.messagecomposer.RichTextEditorStateFactory
|
||||||
|
import io.element.android.wysiwyg.compose.RichTextEditorState
|
||||||
|
import io.element.android.wysiwyg.compose.rememberRichTextEditorState
|
||||||
|
|
||||||
|
class TestRichTextEditorStateFactory : RichTextEditorStateFactory {
|
||||||
|
@Composable
|
||||||
|
override fun create(): RichTextEditorState {
|
||||||
|
return rememberRichTextEditorState("", fake = true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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)
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -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" }
|
||||||
|
|
|
||||||
|
|
@ -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(),
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -79,11 +79,11 @@ interface MatrixRoom : Closeable {
|
||||||
|
|
||||||
suspend fun userAvatarUrl(userId: UserId): Result<String?>
|
suspend fun userAvatarUrl(userId: UserId): Result<String?>
|
||||||
|
|
||||||
suspend fun sendMessage(message: String): Result<Unit>
|
suspend fun sendMessage(body: String, htmlBody: String): Result<Unit>
|
||||||
|
|
||||||
suspend fun editMessage(originalEventId: EventId?, transactionId: TransactionId?, message: String): Result<Unit>
|
suspend fun editMessage(originalEventId: EventId?, transactionId: TransactionId?, body: String, htmlBody: String): Result<Unit>
|
||||||
|
|
||||||
suspend fun replyMessage(eventId: EventId, message: String): Result<Unit>
|
suspend fun replyMessage(eventId: EventId, body: String, htmlBody: String): Result<Unit>
|
||||||
|
|
||||||
suspend fun redactEvent(eventId: EventId, reason: String? = null): Result<Unit>
|
suspend fun redactEvent(eventId: EventId, reason: String? = null): Result<Unit>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -66,7 +66,7 @@ import org.matrix.rustcomponents.sdk.RoomMember
|
||||||
import org.matrix.rustcomponents.sdk.RoomSubscription
|
import org.matrix.rustcomponents.sdk.RoomSubscription
|
||||||
import org.matrix.rustcomponents.sdk.SendAttachmentJoinHandle
|
import org.matrix.rustcomponents.sdk.SendAttachmentJoinHandle
|
||||||
import org.matrix.rustcomponents.sdk.genTransactionId
|
import org.matrix.rustcomponents.sdk.genTransactionId
|
||||||
import org.matrix.rustcomponents.sdk.messageEventContentFromMarkdown
|
import org.matrix.rustcomponents.sdk.messageEventContentFromHtml
|
||||||
import timber.log.Timber
|
import timber.log.Timber
|
||||||
import java.io.File
|
import java.io.File
|
||||||
|
|
||||||
|
|
@ -227,31 +227,32 @@ class RustMatrixRoom(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun sendMessage(message: String): Result<Unit> = withContext(roomDispatcher) {
|
override suspend fun sendMessage(body: String, htmlBody: String): Result<Unit> = withContext(roomDispatcher) {
|
||||||
val transactionId = genTransactionId()
|
val transactionId = genTransactionId()
|
||||||
messageEventContentFromMarkdown(message).use { content ->
|
messageEventContentFromHtml(body, htmlBody).use { content ->
|
||||||
runCatching {
|
runCatching {
|
||||||
innerRoom.send(content, transactionId)
|
innerRoom.send(content, transactionId)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun editMessage(originalEventId: EventId?, transactionId: TransactionId?, message: String): Result<Unit> = withContext(roomDispatcher) {
|
override suspend fun editMessage(originalEventId: EventId?, transactionId: TransactionId?, body: String, htmlBody: String): Result<Unit> =
|
||||||
if (originalEventId != null) {
|
withContext(roomDispatcher) {
|
||||||
runCatching {
|
if (originalEventId != null) {
|
||||||
innerRoom.edit(messageEventContentFromMarkdown(message), originalEventId.value, transactionId?.value)
|
runCatching {
|
||||||
}
|
innerRoom.edit(messageEventContentFromHtml(body, htmlBody), originalEventId.value, transactionId?.value)
|
||||||
} else {
|
}
|
||||||
runCatching {
|
} else {
|
||||||
transactionId?.let { cancelSend(it) }
|
runCatching {
|
||||||
innerRoom.send(messageEventContentFromMarkdown(message), genTransactionId())
|
transactionId?.let { cancelSend(it) }
|
||||||
|
innerRoom.send(messageEventContentFromHtml(body, htmlBody), genTransactionId())
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
override suspend fun replyMessage(eventId: EventId, message: String): Result<Unit> = withContext(roomDispatcher) {
|
override suspend fun replyMessage(eventId: EventId, body: String, htmlBody: String): Result<Unit> = withContext(roomDispatcher) {
|
||||||
runCatching {
|
runCatching {
|
||||||
innerRoom.sendReply(messageEventContentFromMarkdown(message), eventId.value, genTransactionId())
|
innerRoom.sendReply(messageEventContentFromHtml(body, htmlBody), eventId.value, genTransactionId())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
|
|
|
||||||
|
|
@ -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) {
|
||||||
|
|
|
||||||
|
|
@ -92,7 +92,7 @@ class FakeMatrixRoom(
|
||||||
private var sendPollResponseResult = Result.success(Unit)
|
private var sendPollResponseResult = Result.success(Unit)
|
||||||
private var endPollResult = Result.success(Unit)
|
private var endPollResult = Result.success(Unit)
|
||||||
private var progressCallbackValues = emptyList<Pair<Long, Long>>()
|
private var progressCallbackValues = emptyList<Pair<Long, Long>>()
|
||||||
val editMessageCalls = mutableListOf<String>()
|
val editMessageCalls = mutableListOf<Pair<String, String>>()
|
||||||
|
|
||||||
var sendMediaCount = 0
|
var sendMediaCount = 0
|
||||||
private set
|
private set
|
||||||
|
|
@ -171,7 +171,7 @@ class FakeMatrixRoom(
|
||||||
userAvatarUrlResult
|
userAvatarUrlResult
|
||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun sendMessage(message: String): Result<Unit> = simulateLongTask {
|
override suspend fun sendMessage(body: String, htmlBody: String) = simulateLongTask {
|
||||||
Result.success(Unit)
|
Result.success(Unit)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -200,16 +200,16 @@ class FakeMatrixRoom(
|
||||||
return cancelSendResult
|
return cancelSendResult
|
||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun editMessage(originalEventId: EventId?, transactionId: TransactionId?, message: String): Result<Unit> {
|
override suspend fun editMessage(originalEventId: EventId?, transactionId: TransactionId?, body: String, htmlBody: String): Result<Unit> {
|
||||||
editMessageCalls += message
|
editMessageCalls += body to htmlBody
|
||||||
return Result.success(Unit)
|
return Result.success(Unit)
|
||||||
}
|
}
|
||||||
|
|
||||||
var replyMessageParameter: String? = null
|
var replyMessageParameter: Pair<String, String>? = null
|
||||||
private set
|
private set
|
||||||
|
|
||||||
override suspend fun replyMessage(eventId: EventId, message: String): Result<Unit> {
|
override suspend fun replyMessage(eventId: EventId, body: String, htmlBody: String): Result<Unit> {
|
||||||
replyMessageParameter = message
|
replyMessageParameter = body to htmlBody
|
||||||
return Result.success(Unit)
|
return Result.success(Unit)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -124,7 +124,7 @@ class DefaultPermissionsPresenter @AssistedInject constructor(
|
||||||
showDialog = showDialog.value,
|
showDialog = showDialog.value,
|
||||||
permissionAlreadyAsked = isAlreadyAsked,
|
permissionAlreadyAsked = isAlreadyAsked,
|
||||||
permissionAlreadyDenied = isAlreadyDenied,
|
permissionAlreadyDenied = isAlreadyDenied,
|
||||||
eventSink = ::handleEvents
|
eventSink = { handleEvents(it) }
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
/*
|
/*
|
||||||
* Copyright (c) 2022 New Vector Ltd
|
* Copyright (c) 2023 New Vector Ltd
|
||||||
*
|
*
|
||||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
* you may not use this file except in compliance with the License.
|
* you may not use this file except in compliance with the License.
|
||||||
|
|
@ -31,5 +31,9 @@ dependencies {
|
||||||
implementation(projects.libraries.matrix.api)
|
implementation(projects.libraries.matrix.api)
|
||||||
implementation(projects.libraries.matrixui)
|
implementation(projects.libraries.matrixui)
|
||||||
implementation(projects.libraries.designsystem)
|
implementation(projects.libraries.designsystem)
|
||||||
|
|
||||||
|
implementation(libs.matrix.richtexteditor)
|
||||||
|
api(libs.matrix.richtexteditor.compose)
|
||||||
|
|
||||||
ksp(libs.showkase.processor)
|
ksp(libs.showkase.processor)
|
||||||
}
|
}
|
||||||
|
|
@ -0,0 +1,22 @@
|
||||||
|
/*
|
||||||
|
* Copyright (c) 2023 New Vector Ltd
|
||||||
|
*
|
||||||
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
* you may not use this file except in compliance with the License.
|
||||||
|
* You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing, software
|
||||||
|
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
* See the License for the specific language governing permissions and
|
||||||
|
* limitations under the License.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package io.element.android.libraries.textcomposer
|
||||||
|
|
||||||
|
data class Message(
|
||||||
|
val html: String,
|
||||||
|
val markdown: String,
|
||||||
|
)
|
||||||
|
|
@ -37,38 +37,25 @@ import androidx.compose.foundation.layout.size
|
||||||
import androidx.compose.foundation.layout.width
|
import androidx.compose.foundation.layout.width
|
||||||
import androidx.compose.foundation.shape.CircleShape
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
import androidx.compose.foundation.text.BasicTextField
|
|
||||||
import androidx.compose.foundation.text.KeyboardOptions
|
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.filled.Close
|
import androidx.compose.material.icons.filled.Close
|
||||||
import androidx.compose.material.ripple.rememberRipple
|
import androidx.compose.material.ripple.rememberRipple
|
||||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
|
||||||
import androidx.compose.material3.LocalContentColor
|
import androidx.compose.material3.LocalContentColor
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.TextFieldDefaults
|
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.derivedStateOf
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableIntStateOf
|
|
||||||
import androidx.compose.runtime.mutableStateOf
|
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.runtime.setValue
|
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.ExperimentalComposeUiApi
|
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.draw.clip
|
import androidx.compose.ui.draw.clip
|
||||||
import androidx.compose.ui.focus.FocusRequester
|
|
||||||
import androidx.compose.ui.focus.focusRequester
|
|
||||||
import androidx.compose.ui.focus.onFocusEvent
|
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.graphics.ColorFilter
|
import androidx.compose.ui.graphics.ColorFilter
|
||||||
import androidx.compose.ui.graphics.SolidColor
|
|
||||||
import androidx.compose.ui.layout.ContentScale
|
import androidx.compose.ui.layout.ContentScale
|
||||||
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
|
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
|
||||||
import androidx.compose.ui.res.painterResource
|
import androidx.compose.ui.res.painterResource
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.ui.text.input.KeyboardCapitalization
|
|
||||||
import androidx.compose.ui.text.input.VisualTransformation
|
|
||||||
import androidx.compose.ui.text.style.TextAlign
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
import androidx.compose.ui.text.style.TextOverflow
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
|
|
@ -88,23 +75,23 @@ import io.element.android.libraries.matrix.ui.components.AttachmentThumbnailInfo
|
||||||
import io.element.android.libraries.matrix.ui.components.AttachmentThumbnailType
|
import io.element.android.libraries.matrix.ui.components.AttachmentThumbnailType
|
||||||
import io.element.android.libraries.theme.ElementTheme
|
import io.element.android.libraries.theme.ElementTheme
|
||||||
import io.element.android.libraries.ui.strings.CommonStrings
|
import io.element.android.libraries.ui.strings.CommonStrings
|
||||||
|
import io.element.android.wysiwyg.compose.RichTextEditor
|
||||||
|
import io.element.android.wysiwyg.compose.RichTextEditorDefaults
|
||||||
|
import io.element.android.wysiwyg.compose.RichTextEditorState
|
||||||
import kotlinx.coroutines.android.awaitFrame
|
import kotlinx.coroutines.android.awaitFrame
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class, ExperimentalComposeUiApi::class)
|
|
||||||
@Composable
|
@Composable
|
||||||
fun TextComposer(
|
fun TextComposer(
|
||||||
composerText: String?,
|
state: RichTextEditorState,
|
||||||
composerMode: MessageComposerMode,
|
composerMode: MessageComposerMode,
|
||||||
composerCanSendMessage: Boolean,
|
canSendMessage: Boolean,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
focusRequester: FocusRequester = FocusRequester(),
|
onRequestFocus: () -> Unit = {},
|
||||||
onSendMessage: (String) -> Unit = {},
|
onSendMessage: (Message) -> Unit = {},
|
||||||
onResetComposerMode: () -> Unit = {},
|
onResetComposerMode: () -> Unit = {},
|
||||||
onComposerTextChange: (String) -> Unit = {},
|
|
||||||
onAddAttachment: () -> Unit = {},
|
onAddAttachment: () -> Unit = {},
|
||||||
onFocusChanged: (Boolean) -> Unit = {},
|
onError: (Throwable) -> Unit = {},
|
||||||
) {
|
) {
|
||||||
val text = composerText.orEmpty()
|
|
||||||
Row(
|
Row(
|
||||||
modifier.padding(
|
modifier.padding(
|
||||||
horizontal = 12.dp,
|
horizontal = 12.dp,
|
||||||
|
|
@ -115,10 +102,9 @@ fun TextComposer(
|
||||||
Spacer(modifier = Modifier.width(12.dp))
|
Spacer(modifier = Modifier.width(12.dp))
|
||||||
val roundCornerSmall = 20.dp.applyScaleUp()
|
val roundCornerSmall = 20.dp.applyScaleUp()
|
||||||
val roundCornerLarge = 28.dp.applyScaleUp()
|
val roundCornerLarge = 28.dp.applyScaleUp()
|
||||||
var lineCount by remember { mutableIntStateOf(0) }
|
|
||||||
|
|
||||||
val roundedCornerSize = remember(lineCount, composerMode) {
|
val roundedCornerSize = remember(state.lineCount, composerMode) {
|
||||||
if (lineCount > 1 || composerMode is MessageComposerMode.Special) {
|
if (state.lineCount > 1 || composerMode is MessageComposerMode.Special) {
|
||||||
roundCornerSmall
|
roundCornerSmall
|
||||||
} else {
|
} else {
|
||||||
roundCornerLarge
|
roundCornerLarge
|
||||||
|
|
@ -132,10 +118,15 @@ fun TextComposer(
|
||||||
)
|
)
|
||||||
val roundedCorners = RoundedCornerShape(roundedCornerSizeState.value)
|
val roundedCorners = RoundedCornerShape(roundedCornerSizeState.value)
|
||||||
val minHeight = 42.dp.applyScaleUp()
|
val minHeight = 42.dp.applyScaleUp()
|
||||||
val bgColor = ElementTheme.colors.bgSubtleSecondary
|
val colors = ElementTheme.colors
|
||||||
// Change border color depending on focus
|
val bgColor = colors.bgSubtleSecondary
|
||||||
var hasFocus by remember { mutableStateOf(false) }
|
|
||||||
val borderColor = if (hasFocus) ElementTheme.colors.borderDisabled else bgColor
|
val borderColor by remember(state.hasFocus, colors) {
|
||||||
|
derivedStateOf {
|
||||||
|
if (state.hasFocus) colors.borderDisabled else bgColor
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Column(
|
Column(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
|
|
@ -147,66 +138,56 @@ fun TextComposer(
|
||||||
ComposerModeView(composerMode = composerMode, onResetComposerMode = onResetComposerMode)
|
ComposerModeView(composerMode = composerMode, onResetComposerMode = onResetComposerMode)
|
||||||
}
|
}
|
||||||
val defaultTypography = ElementTheme.typography.fontBodyLgRegular
|
val defaultTypography = ElementTheme.typography.fontBodyLgRegular
|
||||||
|
|
||||||
Box {
|
Box {
|
||||||
BasicTextField(
|
Box(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
|
||||||
.heightIn(min = minHeight)
|
.heightIn(min = minHeight)
|
||||||
.focusRequester(focusRequester)
|
.background(color = bgColor, shape = roundedCorners)
|
||||||
.onFocusEvent {
|
.padding(
|
||||||
hasFocus = it.hasFocus
|
PaddingValues(
|
||||||
onFocusChanged(it.hasFocus)
|
top = 4.dp.applyScaleUp(),
|
||||||
},
|
bottom = 4.dp.applyScaleUp(),
|
||||||
value = text,
|
|
||||||
onValueChange = { onComposerTextChange(it) },
|
|
||||||
onTextLayout = {
|
|
||||||
lineCount = it.lineCount
|
|
||||||
},
|
|
||||||
keyboardOptions = KeyboardOptions(
|
|
||||||
capitalization = KeyboardCapitalization.Sentences,
|
|
||||||
),
|
|
||||||
textStyle = defaultTypography.copy(color = MaterialTheme.colorScheme.primary),
|
|
||||||
cursorBrush = SolidColor(ElementTheme.colors.iconAccentTertiary),
|
|
||||||
decorationBox = { innerTextField ->
|
|
||||||
TextFieldDefaults.DecorationBox(
|
|
||||||
value = text,
|
|
||||||
innerTextField = innerTextField,
|
|
||||||
enabled = true,
|
|
||||||
singleLine = false,
|
|
||||||
visualTransformation = VisualTransformation.None,
|
|
||||||
shape = roundedCorners,
|
|
||||||
contentPadding = PaddingValues(
|
|
||||||
top = 10.dp.applyScaleUp(),
|
|
||||||
bottom = 10.dp.applyScaleUp(),
|
|
||||||
start = 12.dp.applyScaleUp(),
|
start = 12.dp.applyScaleUp(),
|
||||||
end = 42.dp.applyScaleUp(),
|
end = 42.dp.applyScaleUp()
|
||||||
),
|
|
||||||
interactionSource = remember { MutableInteractionSource() },
|
|
||||||
placeholder = {
|
|
||||||
Text(stringResource(CommonStrings.common_message), style = defaultTypography)
|
|
||||||
},
|
|
||||||
colors = TextFieldDefaults.colors(
|
|
||||||
unfocusedTextColor = MaterialTheme.colorScheme.secondary,
|
|
||||||
focusedTextColor = MaterialTheme.colorScheme.primary,
|
|
||||||
unfocusedPlaceholderColor = ElementTheme.colors.textDisabled,
|
|
||||||
focusedPlaceholderColor = ElementTheme.colors.textDisabled,
|
|
||||||
unfocusedIndicatorColor = Color.Transparent,
|
|
||||||
focusedIndicatorColor = Color.Transparent,
|
|
||||||
disabledIndicatorColor = Color.Transparent,
|
|
||||||
errorIndicatorColor = Color.Transparent,
|
|
||||||
unfocusedContainerColor = bgColor,
|
|
||||||
focusedContainerColor = bgColor,
|
|
||||||
errorContainerColor = bgColor,
|
|
||||||
disabledContainerColor = bgColor,
|
|
||||||
)
|
)
|
||||||
|
),
|
||||||
|
contentAlignment = Alignment.CenterStart,
|
||||||
|
) {
|
||||||
|
|
||||||
|
// Placeholder
|
||||||
|
if (state.messageHtml.isEmpty()) {
|
||||||
|
Text(
|
||||||
|
stringResource(CommonStrings.common_message),
|
||||||
|
style = defaultTypography.copy(
|
||||||
|
color = ElementTheme.colors.textDisabled,
|
||||||
|
),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
)
|
|
||||||
|
RichTextEditor(
|
||||||
|
state = state,
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth(),
|
||||||
|
style = RichTextEditorDefaults.style(
|
||||||
|
text = RichTextEditorDefaults.textStyle(
|
||||||
|
color = if (state.hasFocus) {
|
||||||
|
MaterialTheme.colorScheme.primary
|
||||||
|
} else {
|
||||||
|
MaterialTheme.colorScheme.secondary
|
||||||
|
}
|
||||||
|
),
|
||||||
|
cursor = RichTextEditorDefaults.cursorStyle(
|
||||||
|
color = ElementTheme.colors.iconAccentTertiary,
|
||||||
|
)
|
||||||
|
),
|
||||||
|
onError = onError
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
SendButton(
|
SendButton(
|
||||||
text = text,
|
canSendMessage = canSendMessage,
|
||||||
canSendMessage = composerCanSendMessage,
|
onClick = { onSendMessage(Message(html = state.messageHtml, markdown = state.messageMarkdown)) },
|
||||||
onSendMessage = onSendMessage,
|
|
||||||
composerMode = composerMode,
|
composerMode = composerMode,
|
||||||
modifier = Modifier.padding(end = 6.dp.applyScaleUp(), bottom = 6.dp.applyScaleUp())
|
modifier = Modifier.padding(end = 6.dp.applyScaleUp(), bottom = 6.dp.applyScaleUp())
|
||||||
)
|
)
|
||||||
|
|
@ -218,7 +199,7 @@ fun TextComposer(
|
||||||
val keyboard = LocalSoftwareKeyboardController.current
|
val keyboard = LocalSoftwareKeyboardController.current
|
||||||
LaunchedEffect(composerMode) {
|
LaunchedEffect(composerMode) {
|
||||||
if (composerMode is MessageComposerMode.Special) {
|
if (composerMode is MessageComposerMode.Special) {
|
||||||
focusRequester.requestFocus()
|
onRequestFocus()
|
||||||
keyboard?.let {
|
keyboard?.let {
|
||||||
awaitFrame()
|
awaitFrame()
|
||||||
it.show()
|
it.show()
|
||||||
|
|
@ -241,7 +222,7 @@ private fun ComposerModeView(
|
||||||
ReplyToModeView(
|
ReplyToModeView(
|
||||||
modifier = modifier.padding(8.dp),
|
modifier = modifier.padding(8.dp),
|
||||||
senderName = composerMode.senderName,
|
senderName = composerMode.senderName,
|
||||||
text = composerMode.defaultContent.toString(),
|
text = composerMode.defaultContent,
|
||||||
attachmentThumbnailInfo = composerMode.attachmentThumbnailInfo,
|
attachmentThumbnailInfo = composerMode.attachmentThumbnailInfo,
|
||||||
onResetComposerMode = onResetComposerMode,
|
onResetComposerMode = onResetComposerMode,
|
||||||
)
|
)
|
||||||
|
|
@ -385,9 +366,8 @@ private fun AttachmentButton(
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun BoxScope.SendButton(
|
private fun BoxScope.SendButton(
|
||||||
text: String,
|
|
||||||
canSendMessage: Boolean,
|
canSendMessage: Boolean,
|
||||||
onSendMessage: (String) -> Unit,
|
onClick: () -> Unit,
|
||||||
composerMode: MessageComposerMode,
|
composerMode: MessageComposerMode,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
|
|
@ -405,9 +385,8 @@ private fun BoxScope.SendButton(
|
||||||
enabled = canSendMessage,
|
enabled = canSendMessage,
|
||||||
interactionSource = interactionSource,
|
interactionSource = interactionSource,
|
||||||
indication = rememberRipple(bounded = false),
|
indication = rememberRipple(bounded = false),
|
||||||
onClick = {
|
onClick = onClick,
|
||||||
onSendMessage(text)
|
),
|
||||||
}),
|
|
||||||
contentAlignment = Alignment.Center,
|
contentAlignment = Alignment.Center,
|
||||||
) {
|
) {
|
||||||
val iconId = when (composerMode) {
|
val iconId = when (composerMode) {
|
||||||
|
|
@ -433,28 +412,37 @@ private fun BoxScope.SendButton(
|
||||||
internal fun TextComposerSimplePreview() = ElementPreview {
|
internal fun TextComposerSimplePreview() = ElementPreview {
|
||||||
Column {
|
Column {
|
||||||
TextComposer(
|
TextComposer(
|
||||||
|
RichTextEditorState("", fake = true).apply { requestFocus() },
|
||||||
|
canSendMessage = false,
|
||||||
onSendMessage = {},
|
onSendMessage = {},
|
||||||
onComposerTextChange = {},
|
|
||||||
composerMode = MessageComposerMode.Normal(""),
|
composerMode = MessageComposerMode.Normal(""),
|
||||||
onResetComposerMode = {},
|
onResetComposerMode = {},
|
||||||
composerCanSendMessage = false,
|
|
||||||
composerText = "",
|
|
||||||
)
|
)
|
||||||
TextComposer(
|
TextComposer(
|
||||||
|
RichTextEditorState("A message", fake = true).apply { requestFocus() },
|
||||||
|
canSendMessage = true,
|
||||||
onSendMessage = {},
|
onSendMessage = {},
|
||||||
onComposerTextChange = {},
|
|
||||||
composerMode = MessageComposerMode.Normal(""),
|
composerMode = MessageComposerMode.Normal(""),
|
||||||
onResetComposerMode = {},
|
onResetComposerMode = {},
|
||||||
composerCanSendMessage = true,
|
|
||||||
composerText = "A message",
|
|
||||||
)
|
)
|
||||||
TextComposer(
|
TextComposer(
|
||||||
|
RichTextEditorState(
|
||||||
|
"A message\nWith several lines\nTo preview larger textfields and long lines with overflow",
|
||||||
|
fake = true
|
||||||
|
).apply {
|
||||||
|
requestFocus()
|
||||||
|
},
|
||||||
|
canSendMessage = true,
|
||||||
|
onSendMessage = {},
|
||||||
|
composerMode = MessageComposerMode.Normal(""),
|
||||||
|
onResetComposerMode = {},
|
||||||
|
)
|
||||||
|
TextComposer(
|
||||||
|
RichTextEditorState("A message without focus", fake = true),
|
||||||
|
canSendMessage = true,
|
||||||
onSendMessage = {},
|
onSendMessage = {},
|
||||||
onComposerTextChange = {},
|
|
||||||
composerMode = MessageComposerMode.Normal(""),
|
composerMode = MessageComposerMode.Normal(""),
|
||||||
onResetComposerMode = {},
|
onResetComposerMode = {},
|
||||||
composerCanSendMessage = true,
|
|
||||||
composerText = "A message\nWith several lines\nTo preview larger textfields and long lines with overflow",
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -463,12 +451,11 @@ internal fun TextComposerSimplePreview() = ElementPreview {
|
||||||
@Composable
|
@Composable
|
||||||
internal fun TextComposerEditPreview() = ElementPreview {
|
internal fun TextComposerEditPreview() = ElementPreview {
|
||||||
TextComposer(
|
TextComposer(
|
||||||
|
RichTextEditorState("A message", fake = true).apply { requestFocus() },
|
||||||
|
canSendMessage = true,
|
||||||
onSendMessage = {},
|
onSendMessage = {},
|
||||||
onComposerTextChange = {},
|
|
||||||
composerMode = MessageComposerMode.Edit(EventId("$1234"), "Some text", TransactionId("1234")),
|
composerMode = MessageComposerMode.Edit(EventId("$1234"), "Some text", TransactionId("1234")),
|
||||||
onResetComposerMode = {},
|
onResetComposerMode = {},
|
||||||
composerCanSendMessage = true,
|
|
||||||
composerText = "A message",
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -477,8 +464,9 @@ internal fun TextComposerEditPreview() = ElementPreview {
|
||||||
internal fun TextComposerReplyPreview() = ElementPreview {
|
internal fun TextComposerReplyPreview() = ElementPreview {
|
||||||
Column {
|
Column {
|
||||||
TextComposer(
|
TextComposer(
|
||||||
|
RichTextEditorState("", fake = true),
|
||||||
|
canSendMessage = false,
|
||||||
onSendMessage = {},
|
onSendMessage = {},
|
||||||
onComposerTextChange = {},
|
|
||||||
composerMode = MessageComposerMode.Reply(
|
composerMode = MessageComposerMode.Reply(
|
||||||
senderName = "Alice",
|
senderName = "Alice",
|
||||||
eventId = EventId("$1234"),
|
eventId = EventId("$1234"),
|
||||||
|
|
@ -488,12 +476,11 @@ internal fun TextComposerReplyPreview() = ElementPreview {
|
||||||
"To preview larger textfields and long lines with overflow"
|
"To preview larger textfields and long lines with overflow"
|
||||||
),
|
),
|
||||||
onResetComposerMode = {},
|
onResetComposerMode = {},
|
||||||
composerCanSendMessage = true,
|
|
||||||
composerText = "A message",
|
|
||||||
)
|
)
|
||||||
TextComposer(
|
TextComposer(
|
||||||
|
RichTextEditorState("A message", fake = true),
|
||||||
|
canSendMessage = true,
|
||||||
onSendMessage = {},
|
onSendMessage = {},
|
||||||
onComposerTextChange = {},
|
|
||||||
composerMode = MessageComposerMode.Reply(
|
composerMode = MessageComposerMode.Reply(
|
||||||
senderName = "Alice",
|
senderName = "Alice",
|
||||||
eventId = EventId("$1234"),
|
eventId = EventId("$1234"),
|
||||||
|
|
@ -506,12 +493,11 @@ internal fun TextComposerReplyPreview() = ElementPreview {
|
||||||
defaultContent = "image.jpg"
|
defaultContent = "image.jpg"
|
||||||
),
|
),
|
||||||
onResetComposerMode = {},
|
onResetComposerMode = {},
|
||||||
composerCanSendMessage = true,
|
|
||||||
composerText = "A message",
|
|
||||||
)
|
)
|
||||||
TextComposer(
|
TextComposer(
|
||||||
|
RichTextEditorState("A message", fake = true),
|
||||||
|
canSendMessage = true,
|
||||||
onSendMessage = {},
|
onSendMessage = {},
|
||||||
onComposerTextChange = {},
|
|
||||||
composerMode = MessageComposerMode.Reply(
|
composerMode = MessageComposerMode.Reply(
|
||||||
senderName = "Alice",
|
senderName = "Alice",
|
||||||
eventId = EventId("$1234"),
|
eventId = EventId("$1234"),
|
||||||
|
|
@ -524,12 +510,11 @@ internal fun TextComposerReplyPreview() = ElementPreview {
|
||||||
defaultContent = "video.mp4"
|
defaultContent = "video.mp4"
|
||||||
),
|
),
|
||||||
onResetComposerMode = {},
|
onResetComposerMode = {},
|
||||||
composerCanSendMessage = true,
|
|
||||||
composerText = "A message",
|
|
||||||
)
|
)
|
||||||
TextComposer(
|
TextComposer(
|
||||||
|
RichTextEditorState("A message", fake = true),
|
||||||
|
canSendMessage = true,
|
||||||
onSendMessage = {},
|
onSendMessage = {},
|
||||||
onComposerTextChange = {},
|
|
||||||
composerMode = MessageComposerMode.Reply(
|
composerMode = MessageComposerMode.Reply(
|
||||||
senderName = "Alice",
|
senderName = "Alice",
|
||||||
eventId = EventId("$1234"),
|
eventId = EventId("$1234"),
|
||||||
|
|
@ -542,12 +527,11 @@ internal fun TextComposerReplyPreview() = ElementPreview {
|
||||||
defaultContent = "logs.txt"
|
defaultContent = "logs.txt"
|
||||||
),
|
),
|
||||||
onResetComposerMode = {},
|
onResetComposerMode = {},
|
||||||
composerCanSendMessage = true,
|
|
||||||
composerText = "A message",
|
|
||||||
)
|
)
|
||||||
TextComposer(
|
TextComposer(
|
||||||
|
RichTextEditorState("A message", fake = true).apply { requestFocus() },
|
||||||
|
canSendMessage = true,
|
||||||
onSendMessage = {},
|
onSendMessage = {},
|
||||||
onComposerTextChange = {},
|
|
||||||
composerMode = MessageComposerMode.Reply(
|
composerMode = MessageComposerMode.Reply(
|
||||||
senderName = "Alice",
|
senderName = "Alice",
|
||||||
eventId = EventId("$1234"),
|
eventId = EventId("$1234"),
|
||||||
|
|
@ -560,8 +544,6 @@ internal fun TextComposerReplyPreview() = ElementPreview {
|
||||||
defaultContent = "Shared location"
|
defaultContent = "Shared location"
|
||||||
),
|
),
|
||||||
onResetComposerMode = {},
|
onResetComposerMode = {},
|
||||||
composerCanSendMessage = true,
|
|
||||||
composerText = "A message",
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
28
libraries/textcomposer/test/build.gradle.kts
Normal file
28
libraries/textcomposer/test/build.gradle.kts
Normal file
|
|
@ -0,0 +1,28 @@
|
||||||
|
/*
|
||||||
|
* Copyright (c) 2023 New Vector Ltd
|
||||||
|
*
|
||||||
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
* you may not use this file except in compliance with the License.
|
||||||
|
* You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing, software
|
||||||
|
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
* See the License for the specific language governing permissions and
|
||||||
|
* limitations under the License.
|
||||||
|
*/
|
||||||
|
|
||||||
|
plugins {
|
||||||
|
id("io.element.android-compose-library")
|
||||||
|
}
|
||||||
|
|
||||||
|
android {
|
||||||
|
namespace = "io.element.android.libraries.textcomposer.test"
|
||||||
|
}
|
||||||
|
|
||||||
|
dependencies {
|
||||||
|
api(projects.libraries.textcomposer.impl)
|
||||||
|
implementation(projects.tests.testutils)
|
||||||
|
}
|
||||||
|
|
@ -99,7 +99,7 @@ fun DependencyHandlerScope.allLibrariesImpl() {
|
||||||
implementation(project(":libraries:mediapickers:impl"))
|
implementation(project(":libraries:mediapickers:impl"))
|
||||||
implementation(project(":libraries:mediaupload:impl"))
|
implementation(project(":libraries:mediaupload:impl"))
|
||||||
implementation(project(":libraries:usersearch:impl"))
|
implementation(project(":libraries:usersearch:impl"))
|
||||||
implementation(project(":libraries:textcomposer"))
|
implementation(project(":libraries:textcomposer:impl"))
|
||||||
}
|
}
|
||||||
|
|
||||||
fun DependencyHandlerScope.allServicesImpl() {
|
fun DependencyHandlerScope.allServicesImpl() {
|
||||||
|
|
|
||||||
|
|
@ -32,6 +32,7 @@ class FakeAnalyticsService(
|
||||||
private val isEnabledFlow = MutableStateFlow(isEnabled)
|
private val isEnabledFlow = MutableStateFlow(isEnabled)
|
||||||
private val didAskUserConsentFlow = MutableStateFlow(didAskUserConsent)
|
private val didAskUserConsentFlow = MutableStateFlow(didAskUserConsent)
|
||||||
val capturedEvents = mutableListOf<VectorAnalyticsEvent>()
|
val capturedEvents = mutableListOf<VectorAnalyticsEvent>()
|
||||||
|
val trackedErrors = mutableListOf<Throwable>()
|
||||||
|
|
||||||
override fun getAvailableAnalyticsProviders(): Set<AnalyticsProvider> = emptySet()
|
override fun getAvailableAnalyticsProviders(): Set<AnalyticsProvider> = emptySet()
|
||||||
|
|
||||||
|
|
@ -66,6 +67,7 @@ class FakeAnalyticsService(
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun trackError(throwable: Throwable) {
|
override fun trackError(throwable: Throwable) {
|
||||||
|
trackedErrors += throwable
|
||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun reset() {
|
override suspend fun reset() {
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,3 @@
|
||||||
version https://git-lfs.github.com/spec/v1
|
version https://git-lfs.github.com/spec/v1
|
||||||
oid sha256:1625b6184c01ab75efcfe4c8d1594af1ffb84f0416219580adce5a9371fabe7d
|
oid sha256:ad8519086ae725ac7b1aeae9cd628237afeb489c6000c814b4d8e3f644592e58
|
||||||
size 21292
|
size 22278
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,3 @@
|
||||||
version https://git-lfs.github.com/spec/v1
|
version https://git-lfs.github.com/spec/v1
|
||||||
oid sha256:906c643393af8d290f0635d7560eaa54339fc0498744f9ab8139932986d73a8c
|
oid sha256:4013454094701004f3c132df1ea1c3db2aa56a025b9edea80417fdd16fe55b19
|
||||||
size 9740
|
size 10422
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,3 @@
|
||||||
version https://git-lfs.github.com/spec/v1
|
version https://git-lfs.github.com/spec/v1
|
||||||
oid sha256:bd57f885f161316550712dcb471ed213537a395539ed5b9975e17465310803b7
|
oid sha256:db74b1655e377e4fd8c35aa18c22ef5f5641586b64d5e582ab2079afeac4b8c3
|
||||||
size 9823
|
size 10775
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,3 @@
|
||||||
version https://git-lfs.github.com/spec/v1
|
version https://git-lfs.github.com/spec/v1
|
||||||
oid sha256:39b527075df2b3f7afa4f3ecfca1ac93a180855b20d2bd64cb763fb98b0a7b69
|
oid sha256:cfc6f54988e4fab08ff443f0c2ea285d47e3d030a8e6c92a1294ee0f85dc1269
|
||||||
size 51340
|
size 51967
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,3 @@
|
||||||
version https://git-lfs.github.com/spec/v1
|
version https://git-lfs.github.com/spec/v1
|
||||||
oid sha256:81f196dc08f4de31f4257f6ba89c52ea41a97b107e7c5c04261188e6c572d4d0
|
oid sha256:d2ffd4fc93e7da38836fd860ce5e505c5f0c25fdd6558efef480aaeb05d00dd5
|
||||||
size 52594
|
size 53327
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,3 @@
|
||||||
version https://git-lfs.github.com/spec/v1
|
version https://git-lfs.github.com/spec/v1
|
||||||
oid sha256:a648d286a3b0536fc97801c3281bb8df432c7ffa28efff66f67cd6cf4c546c10
|
oid sha256:96864552336c65b464251c262f330856fc82c69a5f92f89b1357a185d654fccd
|
||||||
size 51579
|
size 52252
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,3 @@
|
||||||
version https://git-lfs.github.com/spec/v1
|
version https://git-lfs.github.com/spec/v1
|
||||||
oid sha256:34f9eacfd61965cfde4f420fe6a0fc0d637a21aca0d4b12c6a6f1642bd44556d
|
oid sha256:2411212e87f95415e0b4ed6732387922faed891cde5e78fa9f0691a185873129
|
||||||
size 50586
|
size 51026
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,3 @@
|
||||||
version https://git-lfs.github.com/spec/v1
|
version https://git-lfs.github.com/spec/v1
|
||||||
oid sha256:c336a9cbc803eb314aa9333719d813ae64994a75344aa363b25aee8d52551b21
|
oid sha256:255560f478c093f4ca7d021646f4cf9064c16b420d5eeec7cbce97d26c751d92
|
||||||
size 48928
|
size 49540
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,3 @@
|
||||||
version https://git-lfs.github.com/spec/v1
|
version https://git-lfs.github.com/spec/v1
|
||||||
oid sha256:5d7148068516b5e3299cce2924ea3cb54c8f1ddd6378e48b0fa746217a547d22
|
oid sha256:a384aa84ae514aa4989d4b4a5ef8048cb8810f1f37056137c77fd0ee798a0bab
|
||||||
size 52940
|
size 53941
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,3 @@
|
||||||
version https://git-lfs.github.com/spec/v1
|
version https://git-lfs.github.com/spec/v1
|
||||||
oid sha256:b8aaf2c70473711a24bf587ff524fd71d43c2610ade1c3d4ff2013705d5670b3
|
oid sha256:a48badf15c59ab5c42fc7cc200971f00cefa7838461ad0cb59537c2b4065bfff
|
||||||
size 54314
|
size 55409
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,3 @@
|
||||||
version https://git-lfs.github.com/spec/v1
|
version https://git-lfs.github.com/spec/v1
|
||||||
oid sha256:566d5b197091d4b740e1241eab54017fd31e65f570def4b8c24ade19ea78ccc8
|
oid sha256:88a498b9070653513b3e27aa2b597367ee85c8526c6e4c3712a4cb0c06e5313b
|
||||||
size 53254
|
size 54353
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,3 @@
|
||||||
version https://git-lfs.github.com/spec/v1
|
version https://git-lfs.github.com/spec/v1
|
||||||
oid sha256:8353077959916fa85046d3a81d1479d552748635f1286bb8bacc6d922ba30f28
|
oid sha256:bf493a9cb7325b4f68924abd86864b37c72226afcf1c4179a06c17e923cfd0ae
|
||||||
size 55312
|
size 55837
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,3 @@
|
||||||
version https://git-lfs.github.com/spec/v1
|
version https://git-lfs.github.com/spec/v1
|
||||||
oid sha256:a84dabfdcbaff8de5c30472676dee2cee8dc0f77469f2802dfc796a87bc10dff
|
oid sha256:8ac3dbf36380fa273d82ebe8836c525bbeee004d6348822d965b216f9bcdb920
|
||||||
size 50328
|
size 51335
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,3 @@
|
||||||
version https://git-lfs.github.com/spec/v1
|
version https://git-lfs.github.com/spec/v1
|
||||||
oid sha256:e045c8686effc435e5d31b7ef034d93bca64a226d2e6ebae941afeb36dbad099
|
oid sha256:4482b15d928567822dc984a1f19c698f6a9d981d907b70635b3054795292306a
|
||||||
size 35534
|
size 36543
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
Loading…
Add table
Add a link
Reference in a new issue