Merge pull request #3977 from element-hq/feature/bma/captionWarning

Add warning when adding a caption.
This commit is contained in:
Benoit Marty 2024-12-02 17:02:48 +01:00 committed by GitHub
commit 46ab03e550
26 changed files with 289 additions and 63 deletions

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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 = {},
)
}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:7839db8763b29e38ad67380e09101e48fd3a650080967fb5e51a8bf6411d0427
size 397423

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:9bcb98d543eba3d43a58b9d17f753c65ccbae2df6f6ebf444c2eccae482a7466
size 18474

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:e77770bc5d582ad53fc1ad7d3d86b8df85e2c0f8fb997145a1c695ca14cf1e93
size 17258

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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