Add warning when adding a caption.

This commit is contained in:
Benoit Marty 2024-12-02 15:53:40 +01:00
parent 4e688021cc
commit 0fcc90f64f
13 changed files with 260 additions and 43 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>