Merge pull request #3977 from element-hq/feature/bma/captionWarning
Add warning when adding a caption.
This commit is contained in:
commit
46ab03e550
26 changed files with 289 additions and 63 deletions
|
|
@ -402,26 +402,28 @@ class MessagesPresenter @AssistedInject constructor(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun handleActionAddCaption(
|
private suspend fun handleActionAddCaption(
|
||||||
targetEvent: TimelineItem.Event,
|
targetEvent: TimelineItem.Event,
|
||||||
composerState: MessageComposerState,
|
composerState: MessageComposerState,
|
||||||
) {
|
) {
|
||||||
val composerMode = MessageComposerMode.EditCaption(
|
val composerMode = MessageComposerMode.EditCaption(
|
||||||
eventOrTransactionId = targetEvent.eventOrTransactionId,
|
eventOrTransactionId = targetEvent.eventOrTransactionId,
|
||||||
content = "",
|
content = "",
|
||||||
|
showCaptionCompatibilityWarning = featureFlagsService.isFeatureEnabled(FeatureFlags.MediaCaptionWarning),
|
||||||
)
|
)
|
||||||
composerState.eventSink(
|
composerState.eventSink(
|
||||||
MessageComposerEvents.SetMode(composerMode)
|
MessageComposerEvents.SetMode(composerMode)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun handleActionEditCaption(
|
private suspend fun handleActionEditCaption(
|
||||||
targetEvent: TimelineItem.Event,
|
targetEvent: TimelineItem.Event,
|
||||||
composerState: MessageComposerState,
|
composerState: MessageComposerState,
|
||||||
) {
|
) {
|
||||||
val composerMode = MessageComposerMode.EditCaption(
|
val composerMode = MessageComposerMode.EditCaption(
|
||||||
eventOrTransactionId = targetEvent.eventOrTransactionId,
|
eventOrTransactionId = targetEvent.eventOrTransactionId,
|
||||||
content = (targetEvent.content as? TimelineItemEventContentWithAttachment)?.caption.orEmpty(),
|
content = (targetEvent.content as? TimelineItemEventContentWithAttachment)?.caption.orEmpty(),
|
||||||
|
showCaptionCompatibilityWarning = featureFlagsService.isFeatureEnabled(FeatureFlags.MediaCaptionWarning),
|
||||||
)
|
)
|
||||||
composerState.eventSink(
|
composerState.eventSink(
|
||||||
MessageComposerEvents.SetMode(composerMode)
|
MessageComposerEvents.SetMode(composerMode)
|
||||||
|
|
|
||||||
|
|
@ -74,6 +74,7 @@ class AttachmentsPreviewPresenter @AssistedInject constructor(
|
||||||
|
|
||||||
val userSentAttachment = remember { mutableStateOf(false) }
|
val userSentAttachment = remember { mutableStateOf(false) }
|
||||||
val allowCaption by featureFlagService.isFeatureEnabledFlow(FeatureFlags.MediaCaptionCreation).collectAsState(initial = false)
|
val allowCaption by featureFlagService.isFeatureEnabledFlow(FeatureFlags.MediaCaptionCreation).collectAsState(initial = false)
|
||||||
|
val showCaptionCompatibilityWarning by featureFlagService.isFeatureEnabledFlow(FeatureFlags.MediaCaptionWarning).collectAsState(initial = false)
|
||||||
|
|
||||||
val mediaUploadInfoState = remember { mutableStateOf<AsyncData<MediaUploadInfo>>(AsyncData.Uninitialized) }
|
val mediaUploadInfoState = remember { mutableStateOf<AsyncData<MediaUploadInfo>>(AsyncData.Uninitialized) }
|
||||||
LaunchedEffect(Unit) {
|
LaunchedEffect(Unit) {
|
||||||
|
|
@ -145,6 +146,7 @@ class AttachmentsPreviewPresenter @AssistedInject constructor(
|
||||||
sendActionState = sendActionState.value,
|
sendActionState = sendActionState.value,
|
||||||
textEditorState = textEditorState,
|
textEditorState = textEditorState,
|
||||||
allowCaption = allowCaption,
|
allowCaption = allowCaption,
|
||||||
|
showCaptionCompatibilityWarning = showCaptionCompatibilityWarning,
|
||||||
eventSink = ::handleEvents
|
eventSink = ::handleEvents
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,7 @@ data class AttachmentsPreviewState(
|
||||||
val sendActionState: SendActionState,
|
val sendActionState: SendActionState,
|
||||||
val textEditorState: TextEditorState,
|
val textEditorState: TextEditorState,
|
||||||
val allowCaption: Boolean,
|
val allowCaption: Boolean,
|
||||||
|
val showCaptionCompatibilityWarning: Boolean,
|
||||||
val eventSink: (AttachmentsPreviewEvents) -> Unit
|
val eventSink: (AttachmentsPreviewEvents) -> Unit
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -24,6 +24,7 @@ open class AttachmentsPreviewStateProvider : PreviewParameterProvider<Attachment
|
||||||
anAttachmentsPreviewState(sendActionState = SendActionState.Sending.Uploading(0.5f)),
|
anAttachmentsPreviewState(sendActionState = SendActionState.Sending.Uploading(0.5f)),
|
||||||
anAttachmentsPreviewState(sendActionState = SendActionState.Failure(RuntimeException("error"))),
|
anAttachmentsPreviewState(sendActionState = SendActionState.Failure(RuntimeException("error"))),
|
||||||
anAttachmentsPreviewState(allowCaption = false),
|
anAttachmentsPreviewState(allowCaption = false),
|
||||||
|
anAttachmentsPreviewState(showCaptionCompatibilityWarning = true),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -32,6 +33,7 @@ fun anAttachmentsPreviewState(
|
||||||
textEditorState: TextEditorState = aTextEditorStateMarkdown(),
|
textEditorState: TextEditorState = aTextEditorStateMarkdown(),
|
||||||
sendActionState: SendActionState = SendActionState.Idle,
|
sendActionState: SendActionState = SendActionState.Idle,
|
||||||
allowCaption: Boolean = true,
|
allowCaption: Boolean = true,
|
||||||
|
showCaptionCompatibilityWarning: Boolean = true,
|
||||||
) = AttachmentsPreviewState(
|
) = AttachmentsPreviewState(
|
||||||
attachment = Attachment.Media(
|
attachment = Attachment.Media(
|
||||||
localMedia = LocalMedia("file://path".toUri(), mediaInfo),
|
localMedia = LocalMedia("file://path".toUri(), mediaInfo),
|
||||||
|
|
@ -39,5 +41,6 @@ fun anAttachmentsPreviewState(
|
||||||
sendActionState = sendActionState,
|
sendActionState = sendActionState,
|
||||||
textEditorState = textEditorState,
|
textEditorState = textEditorState,
|
||||||
allowCaption = allowCaption,
|
allowCaption = allowCaption,
|
||||||
|
showCaptionCompatibilityWarning = showCaptionCompatibilityWarning,
|
||||||
eventSink = {}
|
eventSink = {}
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -173,7 +173,10 @@ private fun AttachmentsPreviewBottomActions(
|
||||||
modifier = modifier,
|
modifier = modifier,
|
||||||
state = state.textEditorState,
|
state = state.textEditorState,
|
||||||
voiceMessageState = VoiceMessageState.Idle,
|
voiceMessageState = VoiceMessageState.Idle,
|
||||||
composerMode = MessageComposerMode.Attachment(state.allowCaption),
|
composerMode = MessageComposerMode.Attachment(
|
||||||
|
allowCaption = state.allowCaption,
|
||||||
|
showCaptionCompatibilityWarning = state.showCaptionCompatibilityWarning,
|
||||||
|
),
|
||||||
onRequestFocus = {},
|
onRequestFocus = {},
|
||||||
onSendMessage = onSendClick,
|
onSendMessage = onSendClick,
|
||||||
showTextFormatting = false,
|
showTextFormatting = false,
|
||||||
|
|
|
||||||
|
|
@ -45,6 +45,8 @@ import io.element.android.libraries.core.mimetype.MimeTypes
|
||||||
import io.element.android.libraries.designsystem.components.avatar.AvatarData
|
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.designsystem.utils.snackbar.SnackbarDispatcher
|
import io.element.android.libraries.designsystem.utils.snackbar.SnackbarDispatcher
|
||||||
|
import io.element.android.libraries.featureflag.api.FeatureFlagService
|
||||||
|
import io.element.android.libraries.featureflag.api.FeatureFlags
|
||||||
import io.element.android.libraries.featureflag.test.FakeFeatureFlagService
|
import io.element.android.libraries.featureflag.test.FakeFeatureFlagService
|
||||||
import io.element.android.libraries.matrix.api.core.EventId
|
import io.element.android.libraries.matrix.api.core.EventId
|
||||||
import io.element.android.libraries.matrix.api.core.UserId
|
import io.element.android.libraries.matrix.api.core.UserId
|
||||||
|
|
@ -1007,6 +1009,37 @@ class MessagesPresenterTest {
|
||||||
composerMode = MessageComposerMode.EditCaption(
|
composerMode = MessageComposerMode.EditCaption(
|
||||||
eventOrTransactionId = AN_EVENT_ID.toEventOrTransactionId(),
|
eventOrTransactionId = AN_EVENT_ID.toEventOrTransactionId(),
|
||||||
content = A_CAPTION,
|
content = A_CAPTION,
|
||||||
|
showCaptionCompatibilityWarning = true,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `present - handle action edit caption without warning`() = runTest {
|
||||||
|
val messageEvent = aMessageEvent(
|
||||||
|
content = aTimelineItemImageContent(
|
||||||
|
caption = A_CAPTION,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
val composerRecorder = EventsRecorder<MessageComposerEvents>()
|
||||||
|
val presenter = createMessagesPresenter(
|
||||||
|
messageComposerPresenter = { aMessageComposerState(eventSink = composerRecorder) },
|
||||||
|
featureFlagService = FakeFeatureFlagService(
|
||||||
|
initialState = mapOf(FeatureFlags.MediaCaptionWarning.key to false)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
presenter.test {
|
||||||
|
val initialState = awaitItem()
|
||||||
|
initialState.eventSink(MessagesEvents.HandleAction(TimelineItemAction.EditCaption, messageEvent))
|
||||||
|
awaitItem()
|
||||||
|
composerRecorder.assertSingle(
|
||||||
|
MessageComposerEvents.SetMode(
|
||||||
|
composerMode = MessageComposerMode.EditCaption(
|
||||||
|
eventOrTransactionId = AN_EVENT_ID.toEventOrTransactionId(),
|
||||||
|
content = A_CAPTION,
|
||||||
|
showCaptionCompatibilityWarning = false,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
@ -1033,6 +1066,37 @@ class MessagesPresenterTest {
|
||||||
composerMode = MessageComposerMode.EditCaption(
|
composerMode = MessageComposerMode.EditCaption(
|
||||||
eventOrTransactionId = AN_EVENT_ID.toEventOrTransactionId(),
|
eventOrTransactionId = AN_EVENT_ID.toEventOrTransactionId(),
|
||||||
content = "",
|
content = "",
|
||||||
|
showCaptionCompatibilityWarning = true,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `present - handle action add caption without warning`() = runTest {
|
||||||
|
val composerRecorder = EventsRecorder<MessageComposerEvents>()
|
||||||
|
val presenter = createMessagesPresenter(
|
||||||
|
messageComposerPresenter = { aMessageComposerState(eventSink = composerRecorder) },
|
||||||
|
featureFlagService = FakeFeatureFlagService(
|
||||||
|
initialState = mapOf(FeatureFlags.MediaCaptionWarning.key to false)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
val messageEvent = aMessageEvent(
|
||||||
|
content = aTimelineItemImageContent(
|
||||||
|
caption = null,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
presenter.test {
|
||||||
|
val initialState = awaitItem()
|
||||||
|
initialState.eventSink(MessagesEvents.HandleAction(TimelineItemAction.AddCaption, messageEvent))
|
||||||
|
awaitItem()
|
||||||
|
composerRecorder.assertSingle(
|
||||||
|
MessageComposerEvents.SetMode(
|
||||||
|
composerMode = MessageComposerMode.EditCaption(
|
||||||
|
eventOrTransactionId = AN_EVENT_ID.toEventOrTransactionId(),
|
||||||
|
content = "",
|
||||||
|
showCaptionCompatibilityWarning = false,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
@ -1097,6 +1161,7 @@ class MessagesPresenterTest {
|
||||||
givenRoomInfo(aRoomInfo(id = roomId, name = ""))
|
givenRoomInfo(aRoomInfo(id = roomId, name = ""))
|
||||||
},
|
},
|
||||||
navigator: FakeMessagesNavigator = FakeMessagesNavigator(),
|
navigator: FakeMessagesNavigator = FakeMessagesNavigator(),
|
||||||
|
featureFlagService: FeatureFlagService = FakeFeatureFlagService(),
|
||||||
clipboardHelper: FakeClipboardHelper = FakeClipboardHelper(),
|
clipboardHelper: FakeClipboardHelper = FakeClipboardHelper(),
|
||||||
analyticsService: FakeAnalyticsService = FakeAnalyticsService(),
|
analyticsService: FakeAnalyticsService = FakeAnalyticsService(),
|
||||||
timelineEventSink: (TimelineEvents) -> Unit = {},
|
timelineEventSink: (TimelineEvents) -> Unit = {},
|
||||||
|
|
@ -1109,7 +1174,6 @@ class MessagesPresenterTest {
|
||||||
},
|
},
|
||||||
actionListEventSink: (ActionListEvents) -> Unit = {},
|
actionListEventSink: (ActionListEvents) -> Unit = {},
|
||||||
): MessagesPresenter {
|
): MessagesPresenter {
|
||||||
val featureFlagService = FakeFeatureFlagService()
|
|
||||||
return MessagesPresenter(
|
return MessagesPresenter(
|
||||||
room = matrixRoom,
|
room = matrixRoom,
|
||||||
composerPresenter = messageComposerPresenter,
|
composerPresenter = messageComposerPresenter,
|
||||||
|
|
|
||||||
|
|
@ -67,10 +67,22 @@ class AttachmentsPreviewPresenterTest {
|
||||||
@Test
|
@Test
|
||||||
fun `present - initial state`() = runTest {
|
fun `present - initial state`() = runTest {
|
||||||
createAttachmentsPreviewPresenter().test {
|
createAttachmentsPreviewPresenter().test {
|
||||||
skipItems(1)
|
skipItems(1)
|
||||||
val initialState = awaitItem()
|
val initialState = awaitItem()
|
||||||
assertThat(initialState.sendActionState).isEqualTo(SendActionState.Idle)
|
assertThat(initialState.sendActionState).isEqualTo(SendActionState.Idle)
|
||||||
assertThat(initialState.allowCaption).isTrue()
|
assertThat(initialState.allowCaption).isTrue()
|
||||||
|
assertThat(initialState.showCaptionCompatibilityWarning).isTrue()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `present - initial state no caption warning`() = runTest {
|
||||||
|
createAttachmentsPreviewPresenter(
|
||||||
|
showCaptionCompatibilityWarning = false,
|
||||||
|
).test {
|
||||||
|
skipItems(1)
|
||||||
|
val initialState = awaitItem()
|
||||||
|
assertThat(initialState.showCaptionCompatibilityWarning).isFalse()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -443,6 +455,7 @@ class AttachmentsPreviewPresenterTest {
|
||||||
onDoneListener: OnDoneListener = OnDoneListener { lambdaError() },
|
onDoneListener: OnDoneListener = OnDoneListener { lambdaError() },
|
||||||
mediaUploadOnSendQueueEnabled: Boolean = true,
|
mediaUploadOnSendQueueEnabled: Boolean = true,
|
||||||
allowCaption: Boolean = true,
|
allowCaption: Boolean = true,
|
||||||
|
showCaptionCompatibilityWarning: Boolean = true,
|
||||||
): AttachmentsPreviewPresenter {
|
): AttachmentsPreviewPresenter {
|
||||||
return AttachmentsPreviewPresenter(
|
return AttachmentsPreviewPresenter(
|
||||||
attachment = aMediaAttachment(localMedia),
|
attachment = aMediaAttachment(localMedia),
|
||||||
|
|
@ -454,6 +467,7 @@ class AttachmentsPreviewPresenterTest {
|
||||||
initialState = mapOf(
|
initialState = mapOf(
|
||||||
FeatureFlags.MediaUploadOnSendQueue.key to mediaUploadOnSendQueueEnabled,
|
FeatureFlags.MediaUploadOnSendQueue.key to mediaUploadOnSendQueueEnabled,
|
||||||
FeatureFlags.MediaCaptionCreation.key to allowCaption,
|
FeatureFlags.MediaCaptionCreation.key to allowCaption,
|
||||||
|
FeatureFlags.MediaCaptionWarning.key to showCaptionCompatibilityWarning,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -1586,7 +1586,12 @@ fun anEditMode(
|
||||||
fun anEditCaptionMode(
|
fun anEditCaptionMode(
|
||||||
eventOrTransactionId: EventOrTransactionId = AN_EVENT_ID.toEventOrTransactionId(),
|
eventOrTransactionId: EventOrTransactionId = AN_EVENT_ID.toEventOrTransactionId(),
|
||||||
caption: String = A_CAPTION,
|
caption: String = A_CAPTION,
|
||||||
) = MessageComposerMode.EditCaption(eventOrTransactionId, caption)
|
showCaptionCompatibilityWarning: Boolean = false,
|
||||||
|
) = MessageComposerMode.EditCaption(
|
||||||
|
eventOrTransactionId = eventOrTransactionId,
|
||||||
|
content = caption,
|
||||||
|
showCaptionCompatibilityWarning = showCaptionCompatibilityWarning,
|
||||||
|
)
|
||||||
|
|
||||||
fun aReplyMode() = MessageComposerMode.Reply(
|
fun aReplyMode() = MessageComposerMode.Reply(
|
||||||
replyToDetails = InReplyToDetails.Loading(AN_EVENT_ID),
|
replyToDetails = InReplyToDetails.Loading(AN_EVENT_ID),
|
||||||
|
|
|
||||||
|
|
@ -144,7 +144,14 @@ enum class FeatureFlags(
|
||||||
key = "feature.media_caption_creation",
|
key = "feature.media_caption_creation",
|
||||||
title = "Allow creation of media captions",
|
title = "Allow creation of media captions",
|
||||||
description = null,
|
description = null,
|
||||||
defaultValue = { buildMeta -> buildMeta.buildType != BuildType.RELEASE },
|
defaultValue = { true },
|
||||||
|
isFinished = false,
|
||||||
|
),
|
||||||
|
MediaCaptionWarning(
|
||||||
|
key = "feature.media_caption_creation_warning",
|
||||||
|
title = "Show a compatibility warning on media captions creation",
|
||||||
|
description = null,
|
||||||
|
defaultValue = { true },
|
||||||
isFinished = false,
|
isFinished = false,
|
||||||
),
|
),
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,73 @@
|
||||||
|
/*
|
||||||
|
* Copyright 2024 New Vector Ltd.
|
||||||
|
*
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
* Please see LICENSE in the repository root for full details.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package io.element.android.libraries.textcomposer
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import io.element.android.compound.theme.ElementTheme
|
||||||
|
import io.element.android.libraries.designsystem.components.BigIcon
|
||||||
|
import io.element.android.libraries.designsystem.preview.ElementPreview
|
||||||
|
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
|
||||||
|
import io.element.android.libraries.designsystem.theme.components.ModalBottomSheet
|
||||||
|
import io.element.android.libraries.designsystem.theme.components.OutlinedButton
|
||||||
|
import io.element.android.libraries.ui.strings.CommonStrings
|
||||||
|
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
@Composable
|
||||||
|
fun CaptionWarningBottomSheet(
|
||||||
|
onDismiss: () -> Unit,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
) {
|
||||||
|
ModalBottomSheet(
|
||||||
|
modifier = modifier,
|
||||||
|
onDismissRequest = onDismiss,
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(horizontal = 16.dp),
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
|
verticalArrangement = Arrangement.spacedBy(16.dp),
|
||||||
|
) {
|
||||||
|
BigIcon(
|
||||||
|
style = BigIcon.Style.AlertSolid,
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = stringResource(CommonStrings.screen_media_upload_preview_caption_warning),
|
||||||
|
style = ElementTheme.typography.fontBodyMdRegular,
|
||||||
|
color = ElementTheme.colors.textPrimary,
|
||||||
|
textAlign = TextAlign.Center,
|
||||||
|
)
|
||||||
|
OutlinedButton(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(vertical = 16.dp),
|
||||||
|
onClick = onDismiss,
|
||||||
|
text = stringResource(CommonStrings.action_ok),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@PreviewsDayNight
|
||||||
|
@Composable
|
||||||
|
internal fun CaptionWarningBottomSheetPreview() = ElementPreview {
|
||||||
|
CaptionWarningBottomSheet(
|
||||||
|
onDismiss = {},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -10,8 +10,10 @@ package io.element.android.libraries.textcomposer
|
||||||
import android.net.Uri
|
import android.net.Uri
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.border
|
import androidx.compose.foundation.border
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.BoxScope
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.IntrinsicSize
|
import androidx.compose.foundation.layout.IntrinsicSize
|
||||||
import androidx.compose.foundation.layout.Row
|
import androidx.compose.foundation.layout.Row
|
||||||
|
|
@ -26,8 +28,10 @@ import androidx.compose.foundation.layout.width
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.runtime.rememberUpdatedState
|
import androidx.compose.runtime.rememberUpdatedState
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.draw.clip
|
import androidx.compose.ui.draw.clip
|
||||||
|
|
@ -36,10 +40,12 @@ import androidx.compose.ui.text.style.TextOverflow
|
||||||
import androidx.compose.ui.tooling.preview.PreviewParameter
|
import androidx.compose.ui.tooling.preview.PreviewParameter
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import io.element.android.compound.theme.ElementTheme
|
import io.element.android.compound.theme.ElementTheme
|
||||||
|
import io.element.android.compound.tokens.generated.CompoundIcons
|
||||||
import io.element.android.libraries.designsystem.components.media.createFakeWaveform
|
import io.element.android.libraries.designsystem.components.media.createFakeWaveform
|
||||||
import io.element.android.libraries.designsystem.preview.ElementPreview
|
import io.element.android.libraries.designsystem.preview.ElementPreview
|
||||||
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
|
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
|
||||||
import io.element.android.libraries.designsystem.theme.components.CircularProgressIndicator
|
import io.element.android.libraries.designsystem.theme.components.CircularProgressIndicator
|
||||||
|
import io.element.android.libraries.designsystem.theme.components.Icon
|
||||||
import io.element.android.libraries.designsystem.theme.components.Text
|
import io.element.android.libraries.designsystem.theme.components.Text
|
||||||
import io.element.android.libraries.matrix.api.core.EventId
|
import io.element.android.libraries.matrix.api.core.EventId
|
||||||
import io.element.android.libraries.matrix.api.timeline.item.event.EventOrTransactionId
|
import io.element.android.libraries.matrix.api.timeline.item.event.EventOrTransactionId
|
||||||
|
|
@ -66,6 +72,7 @@ import io.element.android.libraries.textcomposer.model.VoiceMessageRecorderEvent
|
||||||
import io.element.android.libraries.textcomposer.model.VoiceMessageState
|
import io.element.android.libraries.textcomposer.model.VoiceMessageState
|
||||||
import io.element.android.libraries.textcomposer.model.aTextEditorStateMarkdown
|
import io.element.android.libraries.textcomposer.model.aTextEditorStateMarkdown
|
||||||
import io.element.android.libraries.textcomposer.model.aTextEditorStateRich
|
import io.element.android.libraries.textcomposer.model.aTextEditorStateRich
|
||||||
|
import io.element.android.libraries.textcomposer.model.showCaptionCompatibilityWarning
|
||||||
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.RichTextEditor
|
||||||
import io.element.android.wysiwyg.compose.RichTextEditorState
|
import io.element.android.wysiwyg.compose.RichTextEditorState
|
||||||
|
|
@ -121,8 +128,8 @@ fun TextComposer(
|
||||||
}
|
}
|
||||||
|
|
||||||
val layoutModifier = modifier
|
val layoutModifier = modifier
|
||||||
.fillMaxSize()
|
.fillMaxSize()
|
||||||
.height(IntrinsicSize.Min)
|
.height(IntrinsicSize.Min)
|
||||||
|
|
||||||
val composerOptionsButton: @Composable () -> Unit = remember(composerMode) {
|
val composerOptionsButton: @Composable () -> Unit = remember(composerMode) {
|
||||||
@Composable {
|
@Composable {
|
||||||
|
|
@ -146,7 +153,7 @@ fun TextComposer(
|
||||||
|
|
||||||
val placeholder = if (composerMode.inThread) {
|
val placeholder = if (composerMode.inThread) {
|
||||||
stringResource(id = CommonStrings.action_reply_in_thread)
|
stringResource(id = CommonStrings.action_reply_in_thread)
|
||||||
} else if (composerMode is MessageComposerMode.Attachment) {
|
} else if (composerMode is MessageComposerMode.Attachment || composerMode is MessageComposerMode.EditCaption) {
|
||||||
stringResource(id = R.string.rich_text_editor_composer_caption_placeholder)
|
stringResource(id = R.string.rich_text_editor_composer_caption_placeholder)
|
||||||
} else {
|
} else {
|
||||||
stringResource(id = R.string.rich_text_editor_composer_placeholder)
|
stringResource(id = R.string.rich_text_editor_composer_placeholder)
|
||||||
|
|
@ -182,7 +189,7 @@ fun TextComposer(
|
||||||
composerMode = composerMode,
|
composerMode = composerMode,
|
||||||
onResetComposerMode = onResetComposerMode,
|
onResetComposerMode = onResetComposerMode,
|
||||||
placeholder = placeholder,
|
placeholder = placeholder,
|
||||||
showPlaceholder = { state.state.text.value().isEmpty() },
|
showPlaceholder = state.state.text.value().isEmpty(),
|
||||||
subcomposing = subcomposing,
|
subcomposing = subcomposing,
|
||||||
) {
|
) {
|
||||||
MarkdownTextInput(
|
MarkdownTextInput(
|
||||||
|
|
@ -337,8 +344,8 @@ private fun StandardLayout(
|
||||||
if (voiceMessageState is VoiceMessageState.Preview || voiceMessageState is VoiceMessageState.Recording) {
|
if (voiceMessageState is VoiceMessageState.Preview || voiceMessageState is VoiceMessageState.Recording) {
|
||||||
Box(
|
Box(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.padding(bottom = 5.dp, top = 5.dp, end = 3.dp, start = 3.dp)
|
.padding(bottom = 5.dp, top = 5.dp, end = 3.dp, start = 3.dp)
|
||||||
.size(48.dp),
|
.size(48.dp),
|
||||||
contentAlignment = Alignment.Center,
|
contentAlignment = Alignment.Center,
|
||||||
) {
|
) {
|
||||||
voiceDeleteButton()
|
voiceDeleteButton()
|
||||||
|
|
@ -348,8 +355,8 @@ private fun StandardLayout(
|
||||||
}
|
}
|
||||||
Box(
|
Box(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.padding(bottom = 8.dp, top = 8.dp)
|
.padding(bottom = 8.dp, top = 8.dp)
|
||||||
.weight(1f)
|
.weight(1f)
|
||||||
) {
|
) {
|
||||||
voiceRecording()
|
voiceRecording()
|
||||||
}
|
}
|
||||||
|
|
@ -362,16 +369,16 @@ private fun StandardLayout(
|
||||||
}
|
}
|
||||||
Box(
|
Box(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.padding(bottom = 8.dp, top = 8.dp)
|
.padding(bottom = 8.dp, top = 8.dp)
|
||||||
.weight(1f)
|
.weight(1f)
|
||||||
) {
|
) {
|
||||||
textInput()
|
textInput()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Box(
|
Box(
|
||||||
Modifier
|
Modifier
|
||||||
.padding(bottom = 5.dp, top = 5.dp, end = 6.dp, start = 6.dp)
|
.padding(bottom = 5.dp, top = 5.dp, end = 6.dp, start = 6.dp)
|
||||||
.size(48.dp),
|
.size(48.dp),
|
||||||
contentAlignment = Alignment.Center,
|
contentAlignment = Alignment.Center,
|
||||||
) {
|
) {
|
||||||
endButton()
|
endButton()
|
||||||
|
|
@ -393,8 +400,8 @@ private fun TextFormattingLayout(
|
||||||
) {
|
) {
|
||||||
Box(
|
Box(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.weight(1f)
|
.weight(1f)
|
||||||
.padding(horizontal = 12.dp)
|
.padding(horizontal = 12.dp)
|
||||||
) {
|
) {
|
||||||
textInput()
|
textInput()
|
||||||
}
|
}
|
||||||
|
|
@ -428,9 +435,9 @@ private fun TextInputBox(
|
||||||
composerMode: MessageComposerMode,
|
composerMode: MessageComposerMode,
|
||||||
onResetComposerMode: () -> Unit,
|
onResetComposerMode: () -> Unit,
|
||||||
placeholder: String,
|
placeholder: String,
|
||||||
showPlaceholder: () -> Boolean,
|
showPlaceholder: Boolean,
|
||||||
subcomposing: Boolean,
|
subcomposing: Boolean,
|
||||||
textInput: @Composable () -> Unit,
|
textInput: @Composable BoxScope.() -> Unit,
|
||||||
) {
|
) {
|
||||||
val bgColor = ElementTheme.colors.bgSubtleSecondary
|
val bgColor = ElementTheme.colors.bgSubtleSecondary
|
||||||
val borderColor = ElementTheme.colors.borderDisabled
|
val borderColor = ElementTheme.colors.borderDisabled
|
||||||
|
|
@ -438,11 +445,11 @@ private fun TextInputBox(
|
||||||
|
|
||||||
Column(
|
Column(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.clip(roundedCorners)
|
.clip(roundedCorners)
|
||||||
.border(0.5.dp, borderColor, roundedCorners)
|
.border(0.5.dp, borderColor, roundedCorners)
|
||||||
.background(color = bgColor)
|
.background(color = bgColor)
|
||||||
.requiredHeightIn(min = 42.dp)
|
.requiredHeightIn(min = 42.dp)
|
||||||
.fillMaxSize(),
|
.fillMaxSize(),
|
||||||
) {
|
) {
|
||||||
if (composerMode is MessageComposerMode.Special) {
|
if (composerMode is MessageComposerMode.Special) {
|
||||||
ComposerModeView(
|
ComposerModeView(
|
||||||
|
|
@ -453,15 +460,15 @@ private fun TextInputBox(
|
||||||
val defaultTypography = ElementTheme.typography.fontBodyLgRegular
|
val defaultTypography = ElementTheme.typography.fontBodyLgRegular
|
||||||
Box(
|
Box(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.padding(top = 4.dp, bottom = 4.dp, start = 12.dp, end = 12.dp)
|
.padding(top = 4.dp, bottom = 4.dp, start = 12.dp, end = 12.dp)
|
||||||
// Apply test tag only once, otherwise 2 nodes will have it (both the normal and subcomposing one) and tests will fail
|
// Apply test tag only once, otherwise 2 nodes will have it (both the normal and subcomposing one) and tests will fail
|
||||||
.then(if (!subcomposing) Modifier.testTag(TestTags.textEditor) else Modifier),
|
.then(if (!subcomposing) Modifier.testTag(TestTags.textEditor) else Modifier),
|
||||||
contentAlignment = Alignment.CenterStart,
|
contentAlignment = Alignment.CenterStart,
|
||||||
) {
|
) {
|
||||||
// Placeholder
|
// Placeholder
|
||||||
if (showPlaceholder()) {
|
if (showPlaceholder) {
|
||||||
Text(
|
Text(
|
||||||
placeholder,
|
text = placeholder,
|
||||||
style = defaultTypography.copy(
|
style = defaultTypography.copy(
|
||||||
color = ElementTheme.colors.textSecondary,
|
color = ElementTheme.colors.textSecondary,
|
||||||
),
|
),
|
||||||
|
|
@ -471,6 +478,24 @@ private fun TextInputBox(
|
||||||
}
|
}
|
||||||
|
|
||||||
textInput()
|
textInput()
|
||||||
|
|
||||||
|
if (showPlaceholder && composerMode.showCaptionCompatibilityWarning()) {
|
||||||
|
var showBottomSheet by remember { mutableStateOf(false) }
|
||||||
|
Icon(
|
||||||
|
modifier = Modifier
|
||||||
|
.clickable { showBottomSheet = true }
|
||||||
|
.padding(horizontal = 8.dp, vertical = 4.dp)
|
||||||
|
.align(Alignment.CenterEnd),
|
||||||
|
imageVector = CompoundIcons.InfoSolid(),
|
||||||
|
tint = ElementTheme.colors.iconCriticalPrimary,
|
||||||
|
contentDescription = null,
|
||||||
|
)
|
||||||
|
if (showBottomSheet) {
|
||||||
|
CaptionWarningBottomSheet(
|
||||||
|
onDismiss = { showBottomSheet = false },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -492,7 +517,7 @@ private fun TextInput(
|
||||||
composerMode = composerMode,
|
composerMode = composerMode,
|
||||||
onResetComposerMode = onResetComposerMode,
|
onResetComposerMode = onResetComposerMode,
|
||||||
placeholder = placeholder,
|
placeholder = placeholder,
|
||||||
showPlaceholder = { state.messageHtml.isEmpty() },
|
showPlaceholder = state.messageHtml.isEmpty(),
|
||||||
subcomposing = subcomposing,
|
subcomposing = subcomposing,
|
||||||
) {
|
) {
|
||||||
RichTextEditor(
|
RichTextEditor(
|
||||||
|
|
@ -501,8 +526,8 @@ private fun TextInput(
|
||||||
// This prevents it gaining focus and mutating the state.
|
// This prevents it gaining focus and mutating the state.
|
||||||
registerStateUpdates = !subcomposing,
|
registerStateUpdates = !subcomposing,
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.padding(top = 6.dp, bottom = 6.dp)
|
.padding(top = 6.dp, bottom = 6.dp)
|
||||||
.fillMaxWidth(),
|
.fillMaxWidth(),
|
||||||
style = ElementRichTextEditorStyle.composerStyle(hasFocus = state.hasFocus),
|
style = ElementRichTextEditorStyle.composerStyle(hasFocus = state.hasFocus),
|
||||||
resolveMentionDisplay = resolveMentionDisplay,
|
resolveMentionDisplay = resolveMentionDisplay,
|
||||||
resolveRoomMentionDisplay = resolveRoomMentionDisplay,
|
resolveRoomMentionDisplay = resolveRoomMentionDisplay,
|
||||||
|
|
@ -602,13 +627,14 @@ internal fun TextComposerEditCaptionPreview() = ElementPreview {
|
||||||
internal fun TextComposerAddCaptionPreview() = ElementPreview {
|
internal fun TextComposerAddCaptionPreview() = ElementPreview {
|
||||||
PreviewColumn(
|
PreviewColumn(
|
||||||
items = aTextEditorStateRichList()
|
items = aTextEditorStateRichList()
|
||||||
) { _, textEditorState ->
|
) { index, textEditorState ->
|
||||||
ATextComposer(
|
ATextComposer(
|
||||||
state = textEditorState,
|
state = textEditorState,
|
||||||
voiceMessageState = VoiceMessageState.Idle,
|
voiceMessageState = VoiceMessageState.Idle,
|
||||||
composerMode = aMessageComposerModeEditCaption(
|
composerMode = aMessageComposerModeEditCaption(
|
||||||
// No caption so that the UI will be in add caption mode
|
// No caption so that the UI will be in add caption mode
|
||||||
content = "",
|
content = "",
|
||||||
|
showCompatibilityWarning = index == 0,
|
||||||
),
|
),
|
||||||
enableVoiceMessages = false,
|
enableVoiceMessages = false,
|
||||||
)
|
)
|
||||||
|
|
@ -657,7 +683,10 @@ internal fun TextComposerCaptionPreview() = ElementPreview {
|
||||||
ATextComposer(
|
ATextComposer(
|
||||||
state = textEditorState,
|
state = textEditorState,
|
||||||
voiceMessageState = VoiceMessageState.Idle,
|
voiceMessageState = VoiceMessageState.Idle,
|
||||||
composerMode = MessageComposerMode.Attachment(allowCaption = index < list.size),
|
composerMode = MessageComposerMode.Attachment(
|
||||||
|
allowCaption = index < list.size,
|
||||||
|
showCaptionCompatibilityWarning = index == 0,
|
||||||
|
),
|
||||||
enableVoiceMessages = false,
|
enableVoiceMessages = false,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
@ -762,9 +791,11 @@ fun aMessageComposerModeEdit(
|
||||||
fun aMessageComposerModeEditCaption(
|
fun aMessageComposerModeEditCaption(
|
||||||
eventOrTransactionId: EventOrTransactionId = EventId("$1234").toEventOrTransactionId(),
|
eventOrTransactionId: EventOrTransactionId = EventId("$1234").toEventOrTransactionId(),
|
||||||
content: String,
|
content: String,
|
||||||
|
showCompatibilityWarning: Boolean = false,
|
||||||
) = MessageComposerMode.EditCaption(
|
) = MessageComposerMode.EditCaption(
|
||||||
eventOrTransactionId = eventOrTransactionId,
|
eventOrTransactionId = eventOrTransactionId,
|
||||||
content = content
|
content = content,
|
||||||
|
showCaptionCompatibilityWarning = showCompatibilityWarning,
|
||||||
)
|
)
|
||||||
|
|
||||||
fun aMessageComposerModeReply(
|
fun aMessageComposerModeReply(
|
||||||
|
|
|
||||||
|
|
@ -18,7 +18,10 @@ import io.element.android.libraries.matrix.ui.messages.reply.eventId
|
||||||
sealed interface MessageComposerMode {
|
sealed interface MessageComposerMode {
|
||||||
data object Normal : MessageComposerMode
|
data object Normal : MessageComposerMode
|
||||||
|
|
||||||
data class Attachment(val allowCaption: Boolean) : MessageComposerMode
|
data class Attachment(
|
||||||
|
val allowCaption: Boolean,
|
||||||
|
val showCaptionCompatibilityWarning: Boolean,
|
||||||
|
) : MessageComposerMode
|
||||||
|
|
||||||
sealed interface Special : MessageComposerMode
|
sealed interface Special : MessageComposerMode
|
||||||
|
|
||||||
|
|
@ -29,7 +32,8 @@ sealed interface MessageComposerMode {
|
||||||
|
|
||||||
data class EditCaption(
|
data class EditCaption(
|
||||||
val eventOrTransactionId: EventOrTransactionId,
|
val eventOrTransactionId: EventOrTransactionId,
|
||||||
val content: String
|
val content: String,
|
||||||
|
val showCaptionCompatibilityWarning: Boolean,
|
||||||
) : Special
|
) : Special
|
||||||
|
|
||||||
data class Reply(
|
data class Reply(
|
||||||
|
|
@ -51,3 +55,11 @@ sealed interface MessageComposerMode {
|
||||||
replyToDetails.eventContent is MessageContent &&
|
replyToDetails.eventContent is MessageContent &&
|
||||||
(replyToDetails.eventContent as MessageContent).isThreaded
|
(replyToDetails.eventContent as MessageContent).isThreaded
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun MessageComposerMode.showCaptionCompatibilityWarning(): Boolean {
|
||||||
|
return when (this) {
|
||||||
|
is MessageComposerMode.Attachment -> showCaptionCompatibilityWarning
|
||||||
|
is MessageComposerMode.EditCaption -> showCaptionCompatibilityWarning && content.isEmpty()
|
||||||
|
else -> false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@
|
||||||
<string name="rich_text_editor_bullet_list">"Toggle bullet list"</string>
|
<string name="rich_text_editor_bullet_list">"Toggle bullet list"</string>
|
||||||
<string name="rich_text_editor_close_formatting_options">"Close formatting options"</string>
|
<string name="rich_text_editor_close_formatting_options">"Close formatting options"</string>
|
||||||
<string name="rich_text_editor_code_block">"Toggle code block"</string>
|
<string name="rich_text_editor_code_block">"Toggle code block"</string>
|
||||||
<string name="rich_text_editor_composer_caption_placeholder">"Optional caption…"</string>
|
<string name="rich_text_editor_composer_caption_placeholder">"Add a caption"</string>
|
||||||
<string name="rich_text_editor_composer_placeholder">"Message…"</string>
|
<string name="rich_text_editor_composer_placeholder">"Message…"</string>
|
||||||
<string name="rich_text_editor_create_link">"Create a link"</string>
|
<string name="rich_text_editor_create_link">"Create a link"</string>
|
||||||
<string name="rich_text_editor_edit_link">"Edit link"</string>
|
<string name="rich_text_editor_edit_link">"Edit link"</string>
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,3 @@
|
||||||
version https://git-lfs.github.com/spec/v1
|
version https://git-lfs.github.com/spec/v1
|
||||||
oid sha256:2399ee4cd37cbafec07e87bfab90e66ee3df5b6aba602d5cd9bd1114c7365642
|
oid sha256:7839db8763b29e38ad67380e09101e48fd3a650080967fb5e51a8bf6411d0427
|
||||||
size 397379
|
size 397423
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,3 @@
|
||||||
version https://git-lfs.github.com/spec/v1
|
version https://git-lfs.github.com/spec/v1
|
||||||
oid sha256:3a92cb782d97553f364fab3df3fe159557a26aad8b7e5c176b40616c2f05abdd
|
oid sha256:2c40163401ccd77a79d314bd373bb09d9b9bd0bd8d0afe281792c19d6fd593d3
|
||||||
size 51410
|
size 51465
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,3 @@
|
||||||
version https://git-lfs.github.com/spec/v1
|
version https://git-lfs.github.com/spec/v1
|
||||||
oid sha256:361ff23fd2ff99aecfdffd10b45e9235d86183d4856cb2a3e99f85b9e04c2d59
|
oid sha256:1cba25287c6d2beee594937c3139b3800630379e1b5a552c5cca886f8646bbfe
|
||||||
size 51376
|
size 51429
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,3 @@
|
||||||
version https://git-lfs.github.com/spec/v1
|
version https://git-lfs.github.com/spec/v1
|
||||||
oid sha256:efbfed755b29293009f45fca33f58863b612772de9a1d55593c979dbb04ff6f2
|
oid sha256:b4f33aa3545c29c25b3c40e6de90772e4e1f0685095429bdbcc4216e9020161c
|
||||||
size 88981
|
size 89038
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
version https://git-lfs.github.com/spec/v1
|
||||||
|
oid sha256:7839db8763b29e38ad67380e09101e48fd3a650080967fb5e51a8bf6411d0427
|
||||||
|
size 397423
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
version https://git-lfs.github.com/spec/v1
|
||||||
|
oid sha256:9bcb98d543eba3d43a58b9d17f753c65ccbae2df6f6ebf444c2eccae482a7466
|
||||||
|
size 18474
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
version https://git-lfs.github.com/spec/v1
|
||||||
|
oid sha256:e77770bc5d582ad53fc1ad7d3d86b8df85e2c0f8fb997145a1c695ca14cf1e93
|
||||||
|
size 17258
|
||||||
|
|
@ -1,3 +1,3 @@
|
||||||
version https://git-lfs.github.com/spec/v1
|
version https://git-lfs.github.com/spec/v1
|
||||||
oid sha256:97d9c333fdc5684971e22593b246ca2751657691dad90ec89d891379e7a0b47f
|
oid sha256:8c4220a5d9401d42a905dcf204bd273e8039d77a889209d4eb35de7c384ed05c
|
||||||
size 60161
|
size 61113
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,3 @@
|
||||||
version https://git-lfs.github.com/spec/v1
|
version https://git-lfs.github.com/spec/v1
|
||||||
oid sha256:c08a7e954c11ff61a9130c54c0b273a5b8996c9095a4dae07cb4b5bc357fc15f
|
oid sha256:009a1fc06c9a2e2bc112c4483002b43e9710c5ced3b2668c40c91b7c54d43b30
|
||||||
size 58458
|
size 59382
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,3 @@
|
||||||
version https://git-lfs.github.com/spec/v1
|
version https://git-lfs.github.com/spec/v1
|
||||||
oid sha256:1b9b24eafbcaa2143c199a0522fa1aae98d9c7bddb80364602f0662567a2f1a1
|
oid sha256:2eb0eaa26e48d182bd3dc78671cb43f02c24ef5f15fb4e6e8b47da2f3d37ce7b
|
||||||
size 56485
|
size 56567
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,3 @@
|
||||||
version https://git-lfs.github.com/spec/v1
|
version https://git-lfs.github.com/spec/v1
|
||||||
oid sha256:fc7a5491acff69b64ea73c1a62b7ae9e148ce57768697de2ec280327cd72298d
|
oid sha256:fcfa2769e764b164375e6f6ef5194ce9ef686b6f76e16848270380a88beb175a
|
||||||
size 55402
|
size 55518
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,3 @@
|
||||||
version https://git-lfs.github.com/spec/v1
|
version https://git-lfs.github.com/spec/v1
|
||||||
oid sha256:1967fa34ef6bd37c373f68badaed4e41b04efbbf929187e6ded4cfb903715325
|
oid sha256:4f6f448d800b741e7e59a44b0f660f4702603e394df6223b1d968c862266083e
|
||||||
size 59539
|
size 59832
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,3 @@
|
||||||
version https://git-lfs.github.com/spec/v1
|
version https://git-lfs.github.com/spec/v1
|
||||||
oid sha256:430b30b65f5908fce562be613afa41131c4fbe8849db3d3f5a3fa424417469a4
|
oid sha256:81bfbaf9030bc40ca6744dd6bf8170243243ebcd98fbc3de71fed85798d60412
|
||||||
size 57827
|
size 58150
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue