Merge pull request #3803 from element-hq/feature/bma/sendCaption
Send caption with image and video
This commit is contained in:
commit
47d7eac1ea
27 changed files with 384 additions and 128 deletions
|
|
@ -9,16 +9,21 @@ package io.element.android.features.messages.impl.attachments.preview
|
|||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.MutableState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.rememberUpdatedState
|
||||
import dagger.assisted.Assisted
|
||||
import dagger.assisted.AssistedFactory
|
||||
import dagger.assisted.AssistedInject
|
||||
import io.element.android.features.messages.impl.attachments.Attachment
|
||||
import io.element.android.libraries.architecture.Presenter
|
||||
import io.element.android.libraries.matrix.api.core.ProgressCallback
|
||||
import io.element.android.libraries.matrix.api.permalink.PermalinkBuilder
|
||||
import io.element.android.libraries.mediaupload.api.MediaSender
|
||||
import io.element.android.libraries.textcomposer.model.TextEditorState
|
||||
import io.element.android.libraries.textcomposer.model.rememberMarkdownTextEditorState
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Job
|
||||
|
|
@ -30,6 +35,7 @@ import kotlin.coroutines.coroutineContext
|
|||
class AttachmentsPreviewPresenter @AssistedInject constructor(
|
||||
@Assisted private val attachment: Attachment,
|
||||
private val mediaSender: MediaSender,
|
||||
private val permalinkBuilder: PermalinkBuilder,
|
||||
) : Presenter<AttachmentsPreviewState> {
|
||||
@AssistedFactory
|
||||
interface Factory {
|
||||
|
|
@ -44,11 +50,24 @@ class AttachmentsPreviewPresenter @AssistedInject constructor(
|
|||
mutableStateOf<SendActionState>(SendActionState.Idle)
|
||||
}
|
||||
|
||||
val markdownTextEditorState = rememberMarkdownTextEditorState(initialText = null, initialFocus = false)
|
||||
val textEditorState by rememberUpdatedState(
|
||||
TextEditorState.Markdown(markdownTextEditorState)
|
||||
)
|
||||
|
||||
val ongoingSendAttachmentJob = remember { mutableStateOf<Job?>(null) }
|
||||
|
||||
fun handleEvents(attachmentsPreviewEvents: AttachmentsPreviewEvents) {
|
||||
when (attachmentsPreviewEvents) {
|
||||
AttachmentsPreviewEvents.SendAttachment -> ongoingSendAttachmentJob.value = coroutineScope.sendAttachment(attachment, sendActionState)
|
||||
is AttachmentsPreviewEvents.SendAttachment -> {
|
||||
val caption = markdownTextEditorState.getMessageMarkdown(permalinkBuilder)
|
||||
.takeIf { it.isNotEmpty() }
|
||||
ongoingSendAttachmentJob.value = coroutineScope.sendAttachment(
|
||||
attachment = attachment,
|
||||
caption = caption,
|
||||
sendActionState = sendActionState,
|
||||
)
|
||||
}
|
||||
AttachmentsPreviewEvents.ClearSendState -> {
|
||||
ongoingSendAttachmentJob.value?.let {
|
||||
it.cancel()
|
||||
|
|
@ -62,18 +81,21 @@ class AttachmentsPreviewPresenter @AssistedInject constructor(
|
|||
return AttachmentsPreviewState(
|
||||
attachment = attachment,
|
||||
sendActionState = sendActionState.value,
|
||||
textEditorState = textEditorState,
|
||||
eventSink = ::handleEvents
|
||||
)
|
||||
}
|
||||
|
||||
private fun CoroutineScope.sendAttachment(
|
||||
attachment: Attachment,
|
||||
caption: String?,
|
||||
sendActionState: MutableState<SendActionState>,
|
||||
) = launch {
|
||||
when (attachment) {
|
||||
is Attachment.Media -> {
|
||||
sendMedia(
|
||||
mediaAttachment = attachment,
|
||||
caption = caption,
|
||||
sendActionState = sendActionState,
|
||||
)
|
||||
}
|
||||
|
|
@ -82,6 +104,7 @@ class AttachmentsPreviewPresenter @AssistedInject constructor(
|
|||
|
||||
private suspend fun sendMedia(
|
||||
mediaAttachment: Attachment.Media,
|
||||
caption: String?,
|
||||
sendActionState: MutableState<SendActionState>,
|
||||
) = runCatching {
|
||||
val context = coroutineContext
|
||||
|
|
@ -96,6 +119,7 @@ class AttachmentsPreviewPresenter @AssistedInject constructor(
|
|||
mediaSender.sendMedia(
|
||||
uri = mediaAttachment.localMedia.uri,
|
||||
mimeType = mediaAttachment.localMedia.info.mimeType,
|
||||
caption = caption,
|
||||
progressCallback = progressCallback
|
||||
).getOrThrow()
|
||||
}.fold(
|
||||
|
|
|
|||
|
|
@ -9,12 +9,21 @@ package io.element.android.features.messages.impl.attachments.preview
|
|||
|
||||
import androidx.compose.runtime.Immutable
|
||||
import io.element.android.features.messages.impl.attachments.Attachment
|
||||
import io.element.android.libraries.core.bool.orFalse
|
||||
import io.element.android.libraries.core.mimetype.MimeTypes.isMimeTypeImage
|
||||
import io.element.android.libraries.core.mimetype.MimeTypes.isMimeTypeVideo
|
||||
import io.element.android.libraries.textcomposer.model.TextEditorState
|
||||
|
||||
data class AttachmentsPreviewState(
|
||||
val attachment: Attachment,
|
||||
val sendActionState: SendActionState,
|
||||
val textEditorState: TextEditorState,
|
||||
val eventSink: (AttachmentsPreviewEvents) -> Unit
|
||||
)
|
||||
) {
|
||||
val allowCaption: Boolean = (attachment as? Attachment.Media)?.localMedia?.info?.mimeType?.let {
|
||||
it.isMimeTypeImage() || it.isMimeTypeVideo()
|
||||
}.orFalse()
|
||||
}
|
||||
|
||||
@Immutable
|
||||
sealed interface SendActionState {
|
||||
|
|
|
|||
|
|
@ -12,13 +12,19 @@ import androidx.core.net.toUri
|
|||
import io.element.android.features.messages.impl.attachments.Attachment
|
||||
import io.element.android.libraries.mediaviewer.api.local.LocalMedia
|
||||
import io.element.android.libraries.mediaviewer.api.local.MediaInfo
|
||||
import io.element.android.libraries.mediaviewer.api.local.aVideoMediaInfo
|
||||
import io.element.android.libraries.mediaviewer.api.local.anApkMediaInfo
|
||||
import io.element.android.libraries.mediaviewer.api.local.anAudioMediaInfo
|
||||
import io.element.android.libraries.mediaviewer.api.local.anImageMediaInfo
|
||||
import io.element.android.libraries.textcomposer.model.TextEditorState
|
||||
import io.element.android.libraries.textcomposer.model.aTextEditorStateMarkdown
|
||||
|
||||
open class AttachmentsPreviewStateProvider : PreviewParameterProvider<AttachmentsPreviewState> {
|
||||
override val values: Sequence<AttachmentsPreviewState>
|
||||
get() = sequenceOf(
|
||||
anAttachmentsPreviewState(),
|
||||
anAttachmentsPreviewState(mediaInfo = aVideoMediaInfo()),
|
||||
anAttachmentsPreviewState(mediaInfo = anAudioMediaInfo()),
|
||||
anAttachmentsPreviewState(mediaInfo = anApkMediaInfo()),
|
||||
anAttachmentsPreviewState(sendActionState = SendActionState.Sending.Uploading(0.5f)),
|
||||
anAttachmentsPreviewState(sendActionState = SendActionState.Failure(RuntimeException("error"))),
|
||||
|
|
@ -27,11 +33,13 @@ open class AttachmentsPreviewStateProvider : PreviewParameterProvider<Attachment
|
|||
|
||||
fun anAttachmentsPreviewState(
|
||||
mediaInfo: MediaInfo = anImageMediaInfo(),
|
||||
sendActionState: SendActionState = SendActionState.Idle
|
||||
textEditorState: TextEditorState = aTextEditorStateMarkdown(),
|
||||
sendActionState: SendActionState = SendActionState.Idle,
|
||||
) = AttachmentsPreviewState(
|
||||
attachment = Attachment.Media(
|
||||
localMedia = LocalMedia("file://path".toUri(), mediaInfo),
|
||||
),
|
||||
sendActionState = sendActionState,
|
||||
textEditorState = textEditorState,
|
||||
eventSink = {}
|
||||
)
|
||||
|
|
|
|||
|
|
@ -9,37 +9,44 @@ package io.element.android.features.messages.impl.attachments.preview
|
|||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.defaultMinSize
|
||||
import androidx.compose.foundation.layout.IntrinsicSize
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.imePadding
|
||||
import androidx.compose.foundation.layout.navigationBarsPadding
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.rememberUpdatedState
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.tooling.preview.PreviewParameter
|
||||
import androidx.compose.ui.unit.dp
|
||||
import io.element.android.compound.theme.ElementTheme
|
||||
import io.element.android.compound.tokens.generated.CompoundIcons
|
||||
import io.element.android.features.messages.impl.attachments.Attachment
|
||||
import io.element.android.features.messages.impl.attachments.preview.error.sendAttachmentError
|
||||
import io.element.android.libraries.designsystem.atomic.molecules.ButtonRowMolecule
|
||||
import io.element.android.libraries.designsystem.components.ProgressDialog
|
||||
import io.element.android.libraries.designsystem.components.ProgressDialogType
|
||||
import io.element.android.libraries.designsystem.components.button.BackButton
|
||||
import io.element.android.libraries.designsystem.components.dialogs.RetryDialog
|
||||
import io.element.android.libraries.designsystem.preview.ElementPreviewDark
|
||||
import io.element.android.libraries.designsystem.theme.components.Scaffold
|
||||
import io.element.android.libraries.designsystem.theme.components.TextButton
|
||||
import io.element.android.libraries.designsystem.theme.components.TopAppBar
|
||||
import io.element.android.libraries.mediaviewer.api.local.LocalMediaView
|
||||
import io.element.android.libraries.mediaviewer.api.local.rememberLocalMediaViewState
|
||||
import io.element.android.libraries.textcomposer.TextComposer
|
||||
import io.element.android.libraries.textcomposer.model.MessageComposerMode
|
||||
import io.element.android.libraries.textcomposer.model.VoiceMessageState
|
||||
import io.element.android.libraries.ui.strings.CommonStrings
|
||||
import io.element.android.wysiwyg.display.TextDisplay
|
||||
import me.saket.telephoto.zoomable.ZoomSpec
|
||||
import me.saket.telephoto.zoomable.rememberZoomableState
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun AttachmentsPreviewView(
|
||||
state: AttachmentsPreviewState,
|
||||
|
|
@ -61,11 +68,23 @@ fun AttachmentsPreviewView(
|
|||
}
|
||||
}
|
||||
|
||||
Scaffold(modifier) {
|
||||
Scaffold(
|
||||
modifier = modifier,
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
navigationIcon = {
|
||||
BackButton(
|
||||
imageVector = CompoundIcons.Close(),
|
||||
onClick = onDismiss,
|
||||
)
|
||||
},
|
||||
title = {},
|
||||
)
|
||||
}
|
||||
) {
|
||||
AttachmentPreviewContent(
|
||||
attachment = state.attachment,
|
||||
state = state,
|
||||
onSendClick = ::postSendAttachment,
|
||||
onDismiss = onDismiss
|
||||
)
|
||||
}
|
||||
AttachmentSendStateView(
|
||||
|
|
@ -106,21 +125,19 @@ private fun AttachmentSendStateView(
|
|||
|
||||
@Composable
|
||||
private fun AttachmentPreviewContent(
|
||||
attachment: Attachment,
|
||||
state: AttachmentsPreviewState,
|
||||
onSendClick: () -> Unit,
|
||||
onDismiss: () -> Unit,
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.navigationBarsPadding(),
|
||||
contentAlignment = Alignment.BottomCenter
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
when (attachment) {
|
||||
when (val attachment = state.attachment) {
|
||||
is Attachment.Media -> {
|
||||
val localMediaViewState = rememberLocalMediaViewState(
|
||||
zoomableState = rememberZoomableState(
|
||||
|
|
@ -137,27 +154,46 @@ private fun AttachmentPreviewContent(
|
|||
}
|
||||
}
|
||||
AttachmentsPreviewBottomActions(
|
||||
onCancelClick = onDismiss,
|
||||
state = state,
|
||||
onSendClick = onSendClick,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.background(Color.Black.copy(alpha = 0.7f))
|
||||
.padding(horizontal = 24.dp)
|
||||
.defaultMinSize(minHeight = 80.dp)
|
||||
.background(ElementTheme.colors.bgCanvasDefault)
|
||||
.height(IntrinsicSize.Min)
|
||||
.align(Alignment.BottomCenter)
|
||||
.imePadding(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun AttachmentsPreviewBottomActions(
|
||||
onCancelClick: () -> Unit,
|
||||
state: AttachmentsPreviewState,
|
||||
onSendClick: () -> Unit,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
ButtonRowMolecule(modifier = modifier) {
|
||||
TextButton(stringResource(id = CommonStrings.action_cancel), onClick = onCancelClick)
|
||||
TextButton(stringResource(id = CommonStrings.action_send), onClick = onSendClick)
|
||||
}
|
||||
TextComposer(
|
||||
modifier = modifier,
|
||||
state = state.textEditorState,
|
||||
voiceMessageState = VoiceMessageState.Idle,
|
||||
composerMode = MessageComposerMode.Attachment(state.allowCaption),
|
||||
onRequestFocus = {},
|
||||
onSendMessage = onSendClick,
|
||||
showTextFormatting = false,
|
||||
onResetComposerMode = {},
|
||||
onAddAttachment = {},
|
||||
onDismissTextFormatting = {},
|
||||
enableVoiceMessages = false,
|
||||
onVoiceRecorderEvent = {},
|
||||
onVoicePlayerEvent = {},
|
||||
onSendVoiceMessage = {},
|
||||
onDeleteVoiceMessage = {},
|
||||
onReceiveSuggestion = {},
|
||||
resolveMentionDisplay = { _, _ -> TextDisplay.Plain },
|
||||
onError = {},
|
||||
onTyping = {},
|
||||
onSelectRichContent = {},
|
||||
)
|
||||
}
|
||||
|
||||
// Only preview in dark, dark theme is forced on the Node.
|
||||
|
|
|
|||
|
|
@ -436,6 +436,7 @@ class MessageComposerPresenter @Inject constructor(
|
|||
// Reset composer right away
|
||||
resetComposer(markdownTextEditorState, richTextEditorState, fromEdit = capturedMode is MessageComposerMode.Edit)
|
||||
when (capturedMode) {
|
||||
is MessageComposerMode.Attachment,
|
||||
is MessageComposerMode.Normal -> room.sendMessage(
|
||||
body = message.markdown,
|
||||
htmlBody = message.html,
|
||||
|
|
@ -605,6 +606,7 @@ class MessageComposerPresenter @Inject constructor(
|
|||
): ComposerDraft? {
|
||||
val message = currentComposerMessage(markdownTextEditorState, richTextEditorState, withMentions = false)
|
||||
val draftType = when (val mode = messageComposerContext.composerMode) {
|
||||
is MessageComposerMode.Attachment,
|
||||
is MessageComposerMode.Normal -> ComposerDraftType.NewMessage
|
||||
is MessageComposerMode.Edit -> {
|
||||
mode.eventOrTransactionId.eventId?.let { eventId -> ComposerDraftType.Edit(eventId) }
|
||||
|
|
|
|||
|
|
@ -20,8 +20,13 @@ import io.element.android.features.messages.impl.attachments.preview.SendActionS
|
|||
import io.element.android.features.messages.impl.fixtures.aMediaAttachment
|
||||
import io.element.android.libraries.matrix.api.core.ProgressCallback
|
||||
import io.element.android.libraries.matrix.api.media.FileInfo
|
||||
import io.element.android.libraries.matrix.api.media.ImageInfo
|
||||
import io.element.android.libraries.matrix.api.media.VideoInfo
|
||||
import io.element.android.libraries.matrix.api.permalink.PermalinkBuilder
|
||||
import io.element.android.libraries.matrix.api.room.MatrixRoom
|
||||
import io.element.android.libraries.matrix.test.A_CAPTION
|
||||
import io.element.android.libraries.matrix.test.media.FakeMediaUploadHandler
|
||||
import io.element.android.libraries.matrix.test.permalink.FakePermalinkBuilder
|
||||
import io.element.android.libraries.matrix.test.room.FakeMatrixRoom
|
||||
import io.element.android.libraries.mediaupload.api.MediaPreProcessor
|
||||
import io.element.android.libraries.mediaupload.api.MediaSender
|
||||
|
|
@ -30,19 +35,23 @@ import io.element.android.libraries.mediaviewer.api.local.LocalMedia
|
|||
import io.element.android.libraries.mediaviewer.test.viewer.aLocalMedia
|
||||
import io.element.android.libraries.preferences.test.InMemorySessionPreferencesStore
|
||||
import io.element.android.tests.testutils.WarmUpRule
|
||||
import io.element.android.tests.testutils.lambda.any
|
||||
import io.element.android.tests.testutils.lambda.lambdaRecorder
|
||||
import io.element.android.tests.testutils.lambda.value
|
||||
import io.mockk.mockk
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.robolectric.RobolectricTestRunner
|
||||
import java.io.File
|
||||
|
||||
@RunWith(RobolectricTestRunner::class)
|
||||
class AttachmentsPreviewPresenterTest {
|
||||
@get:Rule
|
||||
val warmUpRule = WarmUpRule()
|
||||
|
||||
private val mediaPreProcessor = FakeMediaPreProcessor()
|
||||
private val mockMediaUrl: Uri = mockk("localMediaUri")
|
||||
|
||||
@Test
|
||||
|
|
@ -75,6 +84,80 @@ class AttachmentsPreviewPresenterTest {
|
|||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `present - send image with caption success scenario`() = runTest {
|
||||
val sendImageResult =
|
||||
lambdaRecorder<File, File?, ImageInfo, String?, String?, ProgressCallback?, Result<FakeMediaUploadHandler>> { _, _, _, _, _, _ ->
|
||||
Result.success(FakeMediaUploadHandler())
|
||||
}
|
||||
val mediaPreProcessor = FakeMediaPreProcessor().apply {
|
||||
givenImageResult()
|
||||
}
|
||||
val room = FakeMatrixRoom(
|
||||
sendImageResult = sendImageResult,
|
||||
)
|
||||
val presenter = createAttachmentsPreviewPresenter(
|
||||
room = room,
|
||||
mediaPreProcessor = mediaPreProcessor,
|
||||
)
|
||||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
presenter.present()
|
||||
}.test {
|
||||
val initialState = awaitItem()
|
||||
assertThat(initialState.sendActionState).isEqualTo(SendActionState.Idle)
|
||||
initialState.textEditorState.setMarkdown(A_CAPTION)
|
||||
initialState.eventSink(AttachmentsPreviewEvents.SendAttachment)
|
||||
assertThat(awaitItem().sendActionState).isEqualTo(SendActionState.Sending.Processing)
|
||||
val successState = awaitItem()
|
||||
assertThat(successState.sendActionState).isEqualTo(SendActionState.Done)
|
||||
sendImageResult.assertions().isCalledOnce().with(
|
||||
any(),
|
||||
any(),
|
||||
any(),
|
||||
value(A_CAPTION),
|
||||
any(),
|
||||
any(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `present - send video with caption success scenario`() = runTest {
|
||||
val sendVideoResult =
|
||||
lambdaRecorder<File, File?, VideoInfo, String?, String?, ProgressCallback?, Result<FakeMediaUploadHandler>> { _, _, _, _, _, _ ->
|
||||
Result.success(FakeMediaUploadHandler())
|
||||
}
|
||||
val mediaPreProcessor = FakeMediaPreProcessor().apply {
|
||||
givenVideoResult()
|
||||
}
|
||||
val room = FakeMatrixRoom(
|
||||
sendVideoResult = sendVideoResult,
|
||||
)
|
||||
val presenter = createAttachmentsPreviewPresenter(
|
||||
room = room,
|
||||
mediaPreProcessor = mediaPreProcessor,
|
||||
)
|
||||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
presenter.present()
|
||||
}.test {
|
||||
val initialState = awaitItem()
|
||||
assertThat(initialState.sendActionState).isEqualTo(SendActionState.Idle)
|
||||
initialState.textEditorState.setMarkdown(A_CAPTION)
|
||||
initialState.eventSink(AttachmentsPreviewEvents.SendAttachment)
|
||||
assertThat(awaitItem().sendActionState).isEqualTo(SendActionState.Sending.Processing)
|
||||
val successState = awaitItem()
|
||||
assertThat(successState.sendActionState).isEqualTo(SendActionState.Done)
|
||||
sendVideoResult.assertions().isCalledOnce().with(
|
||||
any(),
|
||||
any(),
|
||||
any(),
|
||||
value(A_CAPTION),
|
||||
any(),
|
||||
any(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `present - send media failure scenario`() = runTest {
|
||||
val failure = MediaPreProcessor.Failure(null)
|
||||
|
|
@ -121,11 +204,14 @@ class AttachmentsPreviewPresenterTest {
|
|||
localMedia: LocalMedia = aLocalMedia(
|
||||
uri = mockMediaUrl,
|
||||
),
|
||||
room: MatrixRoom = FakeMatrixRoom()
|
||||
room: MatrixRoom = FakeMatrixRoom(),
|
||||
permalinkBuilder: PermalinkBuilder = FakePermalinkBuilder(),
|
||||
mediaPreProcessor: MediaPreProcessor = FakeMediaPreProcessor(),
|
||||
): AttachmentsPreviewPresenter {
|
||||
return AttachmentsPreviewPresenter(
|
||||
attachment = aMediaAttachment(localMedia),
|
||||
mediaSender = MediaSender(mediaPreProcessor, room, InMemorySessionPreferencesStore())
|
||||
mediaSender = MediaSender(mediaPreProcessor, room, InMemorySessionPreferencesStore()),
|
||||
permalinkBuilder = permalinkBuilder,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -132,8 +132,8 @@ interface MatrixRoom : Closeable {
|
|||
file: File,
|
||||
thumbnailFile: File?,
|
||||
imageInfo: ImageInfo,
|
||||
body: String?,
|
||||
formattedBody: String?,
|
||||
caption: String?,
|
||||
formattedCaption: String?,
|
||||
progressCallback: ProgressCallback?
|
||||
): Result<MediaUploadHandler>
|
||||
|
||||
|
|
@ -141,8 +141,8 @@ interface MatrixRoom : Closeable {
|
|||
file: File,
|
||||
thumbnailFile: File?,
|
||||
videoInfo: VideoInfo,
|
||||
body: String?,
|
||||
formattedBody: String?,
|
||||
caption: String?,
|
||||
formattedCaption: String?,
|
||||
progressCallback: ProgressCallback?
|
||||
): Result<MediaUploadHandler>
|
||||
|
||||
|
|
|
|||
|
|
@ -75,8 +75,8 @@ interface Timeline : AutoCloseable {
|
|||
file: File,
|
||||
thumbnailFile: File?,
|
||||
imageInfo: ImageInfo,
|
||||
body: String?,
|
||||
formattedBody: String?,
|
||||
caption: String?,
|
||||
formattedCaption: String?,
|
||||
progressCallback: ProgressCallback?
|
||||
): Result<MediaUploadHandler>
|
||||
|
||||
|
|
@ -84,8 +84,8 @@ interface Timeline : AutoCloseable {
|
|||
file: File,
|
||||
thumbnailFile: File?,
|
||||
videoInfo: VideoInfo,
|
||||
body: String?,
|
||||
formattedBody: String?,
|
||||
caption: String?,
|
||||
formattedCaption: String?,
|
||||
progressCallback: ProgressCallback?
|
||||
): Result<MediaUploadHandler>
|
||||
|
||||
|
|
|
|||
|
|
@ -445,22 +445,22 @@ class RustMatrixRoom(
|
|||
file: File,
|
||||
thumbnailFile: File?,
|
||||
imageInfo: ImageInfo,
|
||||
body: String?,
|
||||
formattedBody: String?,
|
||||
caption: String?,
|
||||
formattedCaption: String?,
|
||||
progressCallback: ProgressCallback?,
|
||||
): Result<MediaUploadHandler> {
|
||||
return liveTimeline.sendImage(file, thumbnailFile, imageInfo, body, formattedBody, progressCallback)
|
||||
return liveTimeline.sendImage(file, thumbnailFile, imageInfo, caption, formattedCaption, progressCallback)
|
||||
}
|
||||
|
||||
override suspend fun sendVideo(
|
||||
file: File,
|
||||
thumbnailFile: File?,
|
||||
videoInfo: VideoInfo,
|
||||
body: String?,
|
||||
formattedBody: String?,
|
||||
caption: String?,
|
||||
formattedCaption: String?,
|
||||
progressCallback: ProgressCallback?,
|
||||
): Result<MediaUploadHandler> {
|
||||
return liveTimeline.sendVideo(file, thumbnailFile, videoInfo, body, formattedBody, progressCallback)
|
||||
return liveTimeline.sendVideo(file, thumbnailFile, videoInfo, caption, formattedCaption, progressCallback)
|
||||
}
|
||||
|
||||
override suspend fun sendAudio(file: File, audioInfo: AudioInfo, progressCallback: ProgressCallback?): Result<MediaUploadHandler> {
|
||||
|
|
|
|||
|
|
@ -326,8 +326,8 @@ class RustTimeline(
|
|||
file: File,
|
||||
thumbnailFile: File?,
|
||||
imageInfo: ImageInfo,
|
||||
body: String?,
|
||||
formattedBody: String?,
|
||||
caption: String?,
|
||||
formattedCaption: String?,
|
||||
progressCallback: ProgressCallback?,
|
||||
): Result<MediaUploadHandler> {
|
||||
return sendAttachment(listOfNotNull(file, thumbnailFile)) {
|
||||
|
|
@ -335,8 +335,8 @@ class RustTimeline(
|
|||
url = file.path,
|
||||
thumbnailUrl = thumbnailFile?.path,
|
||||
imageInfo = imageInfo.map(),
|
||||
caption = body,
|
||||
formattedCaption = formattedBody?.let {
|
||||
caption = caption,
|
||||
formattedCaption = formattedCaption?.let {
|
||||
FormattedBody(body = it, format = MessageFormat.Html)
|
||||
},
|
||||
storeInCache = true,
|
||||
|
|
@ -349,8 +349,8 @@ class RustTimeline(
|
|||
file: File,
|
||||
thumbnailFile: File?,
|
||||
videoInfo: VideoInfo,
|
||||
body: String?,
|
||||
formattedBody: String?,
|
||||
caption: String?,
|
||||
formattedCaption: String?,
|
||||
progressCallback: ProgressCallback?,
|
||||
): Result<MediaUploadHandler> {
|
||||
return sendAttachment(listOfNotNull(file, thumbnailFile)) {
|
||||
|
|
@ -358,8 +358,8 @@ class RustTimeline(
|
|||
url = file.path,
|
||||
thumbnailUrl = thumbnailFile?.path,
|
||||
videoInfo = videoInfo.map(),
|
||||
caption = body,
|
||||
formattedCaption = formattedBody?.let {
|
||||
caption = caption,
|
||||
formattedCaption = formattedCaption?.let {
|
||||
FormattedBody(body = it, format = MessageFormat.Html)
|
||||
},
|
||||
storeInCache = true,
|
||||
|
|
|
|||
|
|
@ -61,6 +61,7 @@ const val A_ROOM_RAW_NAME = "A room raw name"
|
|||
const val A_MESSAGE = "Hello world!"
|
||||
const val A_REPLY = "OK, I'll be there!"
|
||||
const val ANOTHER_MESSAGE = "Hello universe!"
|
||||
const val A_CAPTION = "A media caption"
|
||||
|
||||
const val A_REDACTION_REASON = "A redaction reason"
|
||||
|
||||
|
|
|
|||
|
|
@ -321,8 +321,8 @@ class FakeMatrixRoom(
|
|||
file: File,
|
||||
thumbnailFile: File?,
|
||||
imageInfo: ImageInfo,
|
||||
body: String?,
|
||||
formattedBody: String?,
|
||||
caption: String?,
|
||||
formattedCaption: String?,
|
||||
progressCallback: ProgressCallback?
|
||||
): Result<MediaUploadHandler> = simulateLongTask {
|
||||
simulateSendMediaProgress(progressCallback)
|
||||
|
|
@ -330,8 +330,8 @@ class FakeMatrixRoom(
|
|||
file,
|
||||
thumbnailFile,
|
||||
imageInfo,
|
||||
body,
|
||||
formattedBody,
|
||||
caption,
|
||||
formattedCaption,
|
||||
progressCallback,
|
||||
)
|
||||
}
|
||||
|
|
@ -340,8 +340,8 @@ class FakeMatrixRoom(
|
|||
file: File,
|
||||
thumbnailFile: File?,
|
||||
videoInfo: VideoInfo,
|
||||
body: String?,
|
||||
formattedBody: String?,
|
||||
caption: String?,
|
||||
formattedCaption: String?,
|
||||
progressCallback: ProgressCallback?
|
||||
): Result<MediaUploadHandler> = simulateLongTask {
|
||||
simulateSendMediaProgress(progressCallback)
|
||||
|
|
@ -349,8 +349,8 @@ class FakeMatrixRoom(
|
|||
file,
|
||||
thumbnailFile,
|
||||
videoInfo,
|
||||
body,
|
||||
formattedBody,
|
||||
caption,
|
||||
formattedCaption,
|
||||
progressCallback,
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -131,15 +131,15 @@ class FakeTimeline(
|
|||
file: File,
|
||||
thumbnailFile: File?,
|
||||
imageInfo: ImageInfo,
|
||||
body: String?,
|
||||
formattedBody: String?,
|
||||
caption: String?,
|
||||
formattedCaption: String?,
|
||||
progressCallback: ProgressCallback?,
|
||||
): Result<MediaUploadHandler> = sendImageLambda(
|
||||
file,
|
||||
thumbnailFile,
|
||||
imageInfo,
|
||||
body,
|
||||
formattedBody,
|
||||
caption,
|
||||
formattedCaption,
|
||||
progressCallback
|
||||
)
|
||||
|
||||
|
|
@ -158,15 +158,15 @@ class FakeTimeline(
|
|||
file: File,
|
||||
thumbnailFile: File?,
|
||||
videoInfo: VideoInfo,
|
||||
body: String?,
|
||||
formattedBody: String?,
|
||||
caption: String?,
|
||||
formattedCaption: String?,
|
||||
progressCallback: ProgressCallback?,
|
||||
): Result<MediaUploadHandler> = sendVideoLambda(
|
||||
file,
|
||||
thumbnailFile,
|
||||
videoInfo,
|
||||
body,
|
||||
formattedBody,
|
||||
caption,
|
||||
formattedCaption,
|
||||
progressCallback
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -106,8 +106,8 @@ class MediaSender @Inject constructor(
|
|||
file = uploadInfo.file,
|
||||
thumbnailFile = uploadInfo.thumbnailFile,
|
||||
imageInfo = uploadInfo.imageInfo,
|
||||
body = caption,
|
||||
formattedBody = formattedCaption,
|
||||
caption = caption,
|
||||
formattedCaption = formattedCaption,
|
||||
progressCallback = progressCallback
|
||||
)
|
||||
}
|
||||
|
|
@ -116,8 +116,8 @@ class MediaSender @Inject constructor(
|
|||
file = uploadInfo.file,
|
||||
thumbnailFile = uploadInfo.thumbnailFile,
|
||||
videoInfo = uploadInfo.videoInfo,
|
||||
body = caption,
|
||||
formattedBody = formattedCaption,
|
||||
caption = caption,
|
||||
formattedCaption = formattedCaption,
|
||||
progressCallback = progressCallback
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -11,6 +11,8 @@ import android.net.Uri
|
|||
import io.element.android.libraries.core.mimetype.MimeTypes
|
||||
import io.element.android.libraries.matrix.api.media.AudioInfo
|
||||
import io.element.android.libraries.matrix.api.media.FileInfo
|
||||
import io.element.android.libraries.matrix.api.media.ImageInfo
|
||||
import io.element.android.libraries.matrix.api.media.VideoInfo
|
||||
import io.element.android.libraries.mediaupload.api.MediaPreProcessor
|
||||
import io.element.android.libraries.mediaupload.api.MediaUploadInfo
|
||||
import io.element.android.tests.testutils.simulateLongTask
|
||||
|
|
@ -61,4 +63,45 @@ class FakeMediaPreProcessor : MediaPreProcessor {
|
|||
)
|
||||
)
|
||||
}
|
||||
|
||||
fun givenImageResult() {
|
||||
givenResult(
|
||||
Result.success(
|
||||
MediaUploadInfo.Image(
|
||||
file = File("image.jpg"),
|
||||
imageInfo = ImageInfo(
|
||||
height = 100,
|
||||
width = 100,
|
||||
mimetype = MimeTypes.Jpeg,
|
||||
size = 1000,
|
||||
thumbnailInfo = null,
|
||||
thumbnailSource = null,
|
||||
blurhash = null,
|
||||
),
|
||||
thumbnailFile = null,
|
||||
)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
fun givenVideoResult() {
|
||||
givenResult(
|
||||
Result.success(
|
||||
MediaUploadInfo.Video(
|
||||
file = File("image.jpg"),
|
||||
videoInfo = VideoInfo(
|
||||
duration = 1000.seconds,
|
||||
height = 100,
|
||||
width = 100,
|
||||
mimetype = MimeTypes.Mp4,
|
||||
size = 1000,
|
||||
thumbnailInfo = null,
|
||||
thumbnailSource = null,
|
||||
blurhash = null,
|
||||
),
|
||||
thumbnailFile = null,
|
||||
)
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -72,6 +72,7 @@ import io.element.android.wysiwyg.compose.RichTextEditorState
|
|||
import io.element.android.wysiwyg.display.TextDisplay
|
||||
import kotlinx.collections.immutable.ImmutableList
|
||||
import kotlinx.collections.immutable.persistentListOf
|
||||
import kotlinx.collections.immutable.toPersistentList
|
||||
import uniffi.wysiwyg_composer.MenuAction
|
||||
import kotlin.time.Duration.Companion.seconds
|
||||
|
||||
|
|
@ -125,62 +126,74 @@ fun TextComposer(
|
|||
|
||||
val composerOptionsButton: @Composable () -> Unit = remember {
|
||||
@Composable {
|
||||
ComposerOptionsButton(
|
||||
modifier = Modifier
|
||||
.size(48.dp),
|
||||
onClick = onAddAttachment
|
||||
)
|
||||
if (composerMode is MessageComposerMode.Attachment) {
|
||||
Spacer(modifier = Modifier.width(9.dp))
|
||||
} else {
|
||||
ComposerOptionsButton(
|
||||
modifier = Modifier
|
||||
.size(48.dp),
|
||||
onClick = onAddAttachment
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val placeholder = if (composerMode.inThread) {
|
||||
stringResource(id = CommonStrings.action_reply_in_thread)
|
||||
} else if (composerMode is MessageComposerMode.Attachment) {
|
||||
stringResource(id = R.string.rich_text_editor_composer_caption_placeholder)
|
||||
} else {
|
||||
stringResource(id = R.string.rich_text_editor_composer_placeholder)
|
||||
}
|
||||
val textInput: @Composable () -> Unit = when (state) {
|
||||
is TextEditorState.Rich -> {
|
||||
remember(state.richTextEditorState, subcomposing, composerMode, onResetComposerMode, onError) {
|
||||
@Composable {
|
||||
TextInput(
|
||||
state = state.richTextEditorState,
|
||||
subcomposing = subcomposing,
|
||||
placeholder = placeholder,
|
||||
composerMode = composerMode,
|
||||
onResetComposerMode = onResetComposerMode,
|
||||
resolveMentionDisplay = resolveMentionDisplay,
|
||||
resolveRoomMentionDisplay = { resolveMentionDisplay("@room", "#") },
|
||||
onError = onError,
|
||||
onTyping = onTyping,
|
||||
onSelectRichContent = onSelectRichContent,
|
||||
)
|
||||
val textInput: @Composable () -> Unit = if ((composerMode as? MessageComposerMode.Attachment)?.allowCaption == false) {
|
||||
{
|
||||
// No text input when in attachment mode and caption not allowed.
|
||||
}
|
||||
} else {
|
||||
when (state) {
|
||||
is TextEditorState.Rich -> {
|
||||
remember(state.richTextEditorState, subcomposing, composerMode, onResetComposerMode, onError) {
|
||||
@Composable {
|
||||
TextInput(
|
||||
state = state.richTextEditorState,
|
||||
subcomposing = subcomposing,
|
||||
placeholder = placeholder,
|
||||
composerMode = composerMode,
|
||||
onResetComposerMode = onResetComposerMode,
|
||||
resolveMentionDisplay = resolveMentionDisplay,
|
||||
resolveRoomMentionDisplay = { resolveMentionDisplay("@room", "#") },
|
||||
onError = onError,
|
||||
onTyping = onTyping,
|
||||
onSelectRichContent = onSelectRichContent,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
is TextEditorState.Markdown -> {
|
||||
@Composable {
|
||||
val style = ElementRichTextEditorStyle.composerStyle(hasFocus = state.hasFocus())
|
||||
TextInputBox(
|
||||
composerMode = composerMode,
|
||||
onResetComposerMode = onResetComposerMode,
|
||||
placeholder = placeholder,
|
||||
showPlaceholder = { state.state.text.value().isEmpty() },
|
||||
subcomposing = subcomposing,
|
||||
) {
|
||||
MarkdownTextInput(
|
||||
state = state.state,
|
||||
is TextEditorState.Markdown -> {
|
||||
@Composable {
|
||||
val style = ElementRichTextEditorStyle.composerStyle(hasFocus = state.hasFocus())
|
||||
TextInputBox(
|
||||
composerMode = composerMode,
|
||||
onResetComposerMode = onResetComposerMode,
|
||||
placeholder = placeholder,
|
||||
showPlaceholder = { state.state.text.value().isEmpty() },
|
||||
subcomposing = subcomposing,
|
||||
onTyping = onTyping,
|
||||
onReceiveSuggestion = onReceiveSuggestion,
|
||||
richTextEditorStyle = style,
|
||||
onSelectRichContent = onSelectRichContent,
|
||||
)
|
||||
) {
|
||||
MarkdownTextInput(
|
||||
state = state.state,
|
||||
subcomposing = subcomposing,
|
||||
onTyping = onTyping,
|
||||
onReceiveSuggestion = onReceiveSuggestion,
|
||||
richTextEditorStyle = style,
|
||||
onSelectRichContent = onSelectRichContent,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val canSendMessage = markdown.isNotBlank()
|
||||
val canSendMessage = markdown.isNotBlank() || composerMode is MessageComposerMode.Attachment
|
||||
val sendButton = @Composable {
|
||||
SendButton(
|
||||
canSendMessage = canSendMessage,
|
||||
|
|
@ -519,7 +532,7 @@ private fun aTextEditorStateRichList() = persistentListOf(
|
|||
internal fun TextComposerSimplePreview() = ElementPreview {
|
||||
PreviewColumn(
|
||||
items = aTextEditorStateMarkdownList()
|
||||
) { textEditorState ->
|
||||
) { _, textEditorState ->
|
||||
ATextComposer(
|
||||
state = textEditorState,
|
||||
voiceMessageState = VoiceMessageState.Idle,
|
||||
|
|
@ -534,7 +547,7 @@ internal fun TextComposerSimplePreview() = ElementPreview {
|
|||
internal fun TextComposerFormattingPreview() = ElementPreview {
|
||||
PreviewColumn(
|
||||
items = aTextEditorStateRichList()
|
||||
) { textEditorState ->
|
||||
) { _, textEditorState ->
|
||||
ATextComposer(
|
||||
state = textEditorState,
|
||||
voiceMessageState = VoiceMessageState.Idle,
|
||||
|
|
@ -550,7 +563,7 @@ internal fun TextComposerFormattingPreview() = ElementPreview {
|
|||
internal fun TextComposerEditPreview() = ElementPreview {
|
||||
PreviewColumn(
|
||||
items = aTextEditorStateRichList()
|
||||
) { textEditorState ->
|
||||
) { _, textEditorState ->
|
||||
ATextComposer(
|
||||
state = textEditorState,
|
||||
voiceMessageState = VoiceMessageState.Idle,
|
||||
|
|
@ -565,7 +578,7 @@ internal fun TextComposerEditPreview() = ElementPreview {
|
|||
internal fun MarkdownTextComposerEditPreview() = ElementPreview {
|
||||
PreviewColumn(
|
||||
items = aTextEditorStateMarkdownList()
|
||||
) { textEditorState ->
|
||||
) { _, textEditorState ->
|
||||
ATextComposer(
|
||||
state = textEditorState,
|
||||
voiceMessageState = VoiceMessageState.Idle,
|
||||
|
|
@ -580,7 +593,7 @@ internal fun MarkdownTextComposerEditPreview() = ElementPreview {
|
|||
internal fun TextComposerReplyPreview(@PreviewParameter(InReplyToDetailsProvider::class) inReplyToDetails: InReplyToDetails) = ElementPreview {
|
||||
PreviewColumn(
|
||||
items = aTextEditorStateRichList()
|
||||
) { textEditorState ->
|
||||
) { _, textEditorState ->
|
||||
ATextComposer(
|
||||
state = textEditorState,
|
||||
voiceMessageState = VoiceMessageState.Idle,
|
||||
|
|
@ -592,6 +605,22 @@ internal fun TextComposerReplyPreview(@PreviewParameter(InReplyToDetailsProvider
|
|||
}
|
||||
}
|
||||
|
||||
@PreviewsDayNight
|
||||
@Composable
|
||||
internal fun TextComposerCaptionPreview() = ElementPreview {
|
||||
val list = aTextEditorStateMarkdownList()
|
||||
PreviewColumn(
|
||||
items = (list + aTextEditorStateMarkdown(initialText = "NO_CAPTION", initialFocus = true)).toPersistentList()
|
||||
) { index, textEditorState ->
|
||||
ATextComposer(
|
||||
state = textEditorState,
|
||||
voiceMessageState = VoiceMessageState.Idle,
|
||||
composerMode = MessageComposerMode.Attachment(allowCaption = index < list.size),
|
||||
enableVoiceMessages = false,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@PreviewsDayNight
|
||||
@Composable
|
||||
internal fun TextComposerVoicePreview() = ElementPreview {
|
||||
|
|
@ -623,7 +652,7 @@ internal fun TextComposerVoicePreview() = ElementPreview {
|
|||
playbackProgress = 0.0f
|
||||
),
|
||||
)
|
||||
) { voiceMessageState ->
|
||||
) { _, voiceMessageState ->
|
||||
ATextComposer(
|
||||
state = aTextEditorStateRich(initialFocus = true),
|
||||
voiceMessageState = voiceMessageState,
|
||||
|
|
@ -636,14 +665,14 @@ internal fun TextComposerVoicePreview() = ElementPreview {
|
|||
@Composable
|
||||
private fun <T> PreviewColumn(
|
||||
items: ImmutableList<T>,
|
||||
view: @Composable (T) -> Unit,
|
||||
view: @Composable (Int, T) -> Unit,
|
||||
) {
|
||||
Column {
|
||||
items.forEach { item ->
|
||||
items.forEachIndexed { index, item ->
|
||||
Box(
|
||||
modifier = Modifier.height(IntrinsicSize.Min)
|
||||
) {
|
||||
view(item)
|
||||
view(index, item)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -18,6 +18,8 @@ import io.element.android.libraries.matrix.ui.messages.reply.eventId
|
|||
sealed interface MessageComposerMode {
|
||||
data object Normal : MessageComposerMode
|
||||
|
||||
data class Attachment(val allowCaption: Boolean) : MessageComposerMode
|
||||
|
||||
sealed interface Special : MessageComposerMode
|
||||
|
||||
data class Edit(
|
||||
|
|
@ -34,7 +36,8 @@ sealed interface MessageComposerMode {
|
|||
|
||||
val relatedEventId: EventId?
|
||||
get() = when (this) {
|
||||
is Normal -> null
|
||||
is Normal,
|
||||
is Attachment -> null
|
||||
is Edit -> eventOrTransactionId.eventId
|
||||
is Reply -> eventId
|
||||
}
|
||||
|
|
|
|||
|
|
@ -36,6 +36,7 @@ sealed interface TextEditorState {
|
|||
is Rich -> richTextEditorState.hasFocus
|
||||
}
|
||||
|
||||
// Note: for test only
|
||||
suspend fun setHtml(html: String) {
|
||||
when (this) {
|
||||
is Markdown -> Unit
|
||||
|
|
@ -43,6 +44,7 @@ sealed interface TextEditorState {
|
|||
}
|
||||
}
|
||||
|
||||
// Note: for test only
|
||||
suspend fun setMarkdown(text: String) {
|
||||
when (this) {
|
||||
is Markdown -> state.text.update(text, true)
|
||||
|
|
|
|||
|
|
@ -101,6 +101,7 @@ class KonsistPreviewTest {
|
|||
"SasEmojisPreview",
|
||||
"SecureBackupSetupViewChangePreview",
|
||||
"SelectedUserCannotRemovePreview",
|
||||
"TextComposerCaptionPreview",
|
||||
"TextComposerEditPreview",
|
||||
"TextComposerFormattingPreview",
|
||||
"TextComposerLinkDialogCreateLinkPreview",
|
||||
|
|
|
|||
|
|
@ -1,3 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:3b8b87bd63d1f6febead491dda1cecc0fa9fad0cdca317cf29086fbeec6a9231
|
||||
size 390555
|
||||
oid sha256:899eff34c421e13bf62d6828582c715f87588cfb17c1063aae65baae79a472cf
|
||||
size 394631
|
||||
|
|
|
|||
|
|
@ -1,3 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:6ba4294693123669a24fbdec8093d3c572c639325763c15a1aa53c8f1e4a7659
|
||||
size 15230
|
||||
oid sha256:c41c7438f46e62a4a6f115647040005aba9fd057599fbb17aba844337642d525
|
||||
size 15963
|
||||
|
|
|
|||
|
|
@ -1,3 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:16c19e5b2a95da604052a86bd3cdb00d4260ee7539d79e8bb96784e2a2920836
|
||||
size 47019
|
||||
oid sha256:4a9d51bdba64cbd7c453ac177e9c77fb6aa3c611ac16746701b22b024abf3560
|
||||
size 13770
|
||||
|
|
|
|||
|
|
@ -1,3 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:a42e8f411d6e54656104cf9472a00713b150ee9978b8bde26b22acd858478bfc
|
||||
size 84755
|
||||
oid sha256:9be7c12e6de6bd2975f11ff06eab1b6fa973edcda0ca90c93eed164cb1d6bf18
|
||||
size 14841
|
||||
|
|
|
|||
|
|
@ -0,0 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:9434a531c57fa65c9996f5b5c6254af73ac50433ceeeaa7f1dd1243fe3c3b1c6
|
||||
size 50355
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:1c36d5b0d29f1533829f80c33e042bb88648890ad2b629136f8a2af01c511f7a
|
||||
size 87977
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:391d3741acfd768614a9bc70e948f7fc49b37d75e65591721b922e678d520bac
|
||||
size 44773
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:713a314ecd36e4d95e5c287ab9a4b5968f5a5090dbb4910d9739e66969fdd424
|
||||
size 43417
|
||||
Loading…
Add table
Add a link
Reference in a new issue