Merge pull request #3902 from element-hq/feature/bma/editMediaCaption

Allow to set caption when uploading file and audio files, and allow adding / edit / remove caption on Event with attachment (also works on local echo)
This commit is contained in:
Benoit Marty 2024-11-21 14:50:59 +01:00 committed by GitHub
commit e8af711777
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
62 changed files with 970 additions and 381 deletions

View file

@ -43,6 +43,7 @@ import io.element.android.features.messages.impl.timeline.components.customreact
import io.element.android.features.messages.impl.timeline.components.reactionsummary.ReactionSummaryState
import io.element.android.features.messages.impl.timeline.components.receipt.bottomsheet.ReadReceiptBottomSheetState
import io.element.android.features.messages.impl.timeline.model.TimelineItem
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemEventContentWithAttachment
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemPollContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemStateContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemTextBasedContent
@ -273,6 +274,9 @@ class MessagesPresenter @AssistedInject constructor(
TimelineItemAction.CopyLink -> handleCopyLink(targetEvent)
TimelineItemAction.Redact -> handleActionRedact(targetEvent)
TimelineItemAction.Edit -> handleActionEdit(targetEvent, composerState, enableTextFormatting)
TimelineItemAction.AddCaption -> handleActionAddCaption(targetEvent, composerState)
TimelineItemAction.EditCaption -> handleActionEditCaption(targetEvent, composerState)
TimelineItemAction.RemoveCaption -> handleRemoveCaption(targetEvent)
TimelineItemAction.Reply,
TimelineItemAction.ReplyInThread -> handleActionReply(targetEvent, composerState, timelineProtectionState)
TimelineItemAction.ViewSource -> handleShowDebugInfoAction(targetEvent)
@ -285,6 +289,16 @@ class MessagesPresenter @AssistedInject constructor(
}
}
private suspend fun handleRemoveCaption(targetEvent: TimelineItem.Event) {
timelineController.invokeOnCurrentTimeline {
editCaption(
eventOrTransactionId = targetEvent.eventOrTransactionId,
caption = null,
formattedCaption = null,
)
}
}
private suspend fun handlePinAction(targetEvent: TimelineItem.Event) {
if (targetEvent.eventId == null) return
analyticsService.capture(
@ -387,6 +401,32 @@ class MessagesPresenter @AssistedInject constructor(
}
}
private fun handleActionAddCaption(
targetEvent: TimelineItem.Event,
composerState: MessageComposerState,
) {
val composerMode = MessageComposerMode.EditCaption(
eventOrTransactionId = targetEvent.eventOrTransactionId,
content = "",
)
composerState.eventSink(
MessageComposerEvents.SetMode(composerMode)
)
}
private fun handleActionEditCaption(
targetEvent: TimelineItem.Event,
composerState: MessageComposerState,
) {
val composerMode = MessageComposerMode.EditCaption(
eventOrTransactionId = targetEvent.eventOrTransactionId,
content = (targetEvent.content as? TimelineItemEventContentWithAttachment)?.caption.orEmpty(),
)
composerState.eventSink(
MessageComposerEvents.SetMode(composerMode)
)
}
private suspend fun handleActionReply(
targetEvent: TimelineItem.Event,
composerState: MessageComposerState,

View file

@ -12,7 +12,6 @@ import io.element.android.features.messages.impl.actionlist.ActionListState
import io.element.android.features.messages.impl.actionlist.anActionListState
import io.element.android.features.messages.impl.crypto.identity.IdentityChangeState
import io.element.android.features.messages.impl.crypto.identity.anIdentityChangeState
import io.element.android.features.messages.impl.messagecomposer.AttachmentsState
import io.element.android.features.messages.impl.messagecomposer.MessageComposerState
import io.element.android.features.messages.impl.messagecomposer.aMessageComposerState
import io.element.android.features.messages.impl.pinned.banner.PinnedMessagesBannerState
@ -62,16 +61,6 @@ open class MessagesStateProvider : PreviewParameterProvider<MessagesState> {
enableVoiceMessages = true,
voiceMessageComposerState = aVoiceMessageComposerState(showPermissionRationaleDialog = true),
),
aMessagesState(
composerState = aMessageComposerState(
attachmentsState = AttachmentsState.Sending.Processing(persistentListOf())
),
),
aMessagesState(
composerState = aMessageComposerState(
attachmentsState = AttachmentsState.Sending.Uploading(0.33f)
),
),
aMessagesState(
roomCallState = anOngoingCallState(),
),

View file

@ -83,8 +83,6 @@ import io.element.android.features.networkmonitor.api.ui.ConnectivityIndicatorVi
import io.element.android.features.roomcall.api.RoomCallState
import io.element.android.libraries.androidutils.ui.hideKeyboard
import io.element.android.libraries.designsystem.atomic.molecules.IconTitlePlaceholdersRowMolecule
import io.element.android.libraries.designsystem.components.ProgressDialog
import io.element.android.libraries.designsystem.components.ProgressDialogType
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.CompositeAvatar
@ -134,7 +132,6 @@ fun MessagesView(
AttachmentStateView(
state = state.composerState.attachmentsState,
onPreviewAttachments = onPreviewAttachments,
onCancel = { state.composerState.eventSink(MessageComposerEvents.CancelSendAttachment) },
)
val snackbarHostState = rememberSnackbarHostState(snackbarMessage = state.snackbarMessage)
@ -280,7 +277,6 @@ private fun ReinviteDialog(state: MessagesState) {
private fun AttachmentStateView(
state: AttachmentsState,
onPreviewAttachments: (ImmutableList<Attachment>) -> Unit,
onCancel: () -> Unit,
) {
when (state) {
AttachmentsState.None -> Unit
@ -290,17 +286,6 @@ private fun AttachmentStateView(
latestOnPreviewAttachments(state.attachments)
}
}
is AttachmentsState.Sending -> {
ProgressDialog(
type = when (state) {
is AttachmentsState.Sending.Uploading -> ProgressDialogType.Determinate(state.progress)
is AttachmentsState.Sending.Processing -> ProgressDialogType.Indeterminate
},
text = stringResource(id = CommonStrings.common_sending),
showCancelButton = true,
onDismissRequest = onCancel,
)
}
}
}

View file

@ -27,10 +27,12 @@ import io.element.android.features.messages.impl.crypto.sendfailure.VerifiedUser
import io.element.android.features.messages.impl.timeline.model.TimelineItem
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemCallNotifyContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemEventContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemEventContentWithAttachment
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemLegacyCallInviteContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemPollContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemRedactedContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemStateContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemVoiceContent
import io.element.android.features.messages.impl.timeline.model.event.canBeCopied
import io.element.android.features.messages.impl.timeline.model.event.canBeForwarded
import io.element.android.features.messages.impl.timeline.model.event.canReact
@ -154,6 +156,18 @@ class DefaultActionListPresenter @AssistedInject constructor(
}
if (timelineItem.isEditable) {
add(TimelineItemAction.Edit)
} else {
// Caption
if (timelineItem.isMine &&
timelineItem.content is TimelineItemEventContentWithAttachment &&
timelineItem.content !is TimelineItemVoiceContent) {
if (timelineItem.content.caption == null) {
add(TimelineItemAction.AddCaption)
} else {
add(TimelineItemAction.EditCaption)
add(TimelineItemAction.RemoveCaption)
}
}
}
if (canRedact && timelineItem.content is TimelineItemPollContent && !timelineItem.content.isEnded) {
add(TimelineItemAction.EndPoll)

View file

@ -28,6 +28,9 @@ sealed class TimelineItemAction(
data object Reply : TimelineItemAction(CommonStrings.action_reply, CompoundDrawables.ic_compound_reply)
data object ReplyInThread : TimelineItemAction(CommonStrings.action_reply_in_thread, CompoundDrawables.ic_compound_reply)
data object Edit : TimelineItemAction(CommonStrings.action_edit, CompoundDrawables.ic_compound_edit)
data object EditCaption : TimelineItemAction(CommonStrings.action_edit_caption, CompoundDrawables.ic_compound_edit)
data object AddCaption : TimelineItemAction(CommonStrings.action_add_caption, CompoundDrawables.ic_compound_edit)
data object RemoveCaption : TimelineItemAction(CommonStrings.action_remove_caption, CompoundDrawables.ic_compound_delete, destructive = true)
data object ViewSource : TimelineItemAction(CommonStrings.action_view_source, CommonDrawables.ic_developer_options)
data object ReportContent : TimelineItemAction(CommonStrings.action_report_content, CompoundDrawables.ic_compound_chat_problem, destructive = true)
data object EndPoll : TimelineItemAction(CommonStrings.action_end_poll, CompoundDrawables.ic_compound_polls_end)

View file

@ -9,9 +9,6 @@ package io.element.android.features.messages.impl.attachments.preview
import androidx.compose.runtime.Immutable
import io.element.android.features.messages.impl.attachments.Attachment
import io.element.android.libraries.core.bool.orFalse
import io.element.android.libraries.core.mimetype.MimeTypes.isMimeTypeImage
import io.element.android.libraries.core.mimetype.MimeTypes.isMimeTypeVideo
import io.element.android.libraries.textcomposer.model.TextEditorState
data class AttachmentsPreviewState(
@ -20,9 +17,8 @@ data class AttachmentsPreviewState(
val textEditorState: TextEditorState,
val eventSink: (AttachmentsPreviewEvents) -> Unit
) {
val allowCaption: Boolean = (attachment as? Attachment.Media)?.localMedia?.info?.mimeType?.let {
it.isMimeTypeImage() || it.isMimeTypeVideo()
}.orFalse()
// Keep the val to eventually set to false for some mimetypes.
val allowCaption: Boolean = true
}
@Immutable

View file

@ -31,7 +31,6 @@ sealed interface MessageComposerEvents {
data object Poll : PickAttachmentSource
}
data class ToggleTextFormatting(val enabled: Boolean) : MessageComposerEvents
data object CancelSendAttachment : MessageComposerEvents
data class Error(val error: Throwable) : MessageComposerEvents
data class TypingNotice(val isTyping: Boolean) : MessageComposerEvents
data class SuggestionReceived(val suggestion: Suggestion?) : MessageComposerEvents

View file

@ -42,7 +42,6 @@ import io.element.android.libraries.di.RoomScope
import io.element.android.libraries.di.SingleIn
import io.element.android.libraries.featureflag.api.FeatureFlagService
import io.element.android.libraries.featureflag.api.FeatureFlags
import io.element.android.libraries.matrix.api.core.ProgressCallback
import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.permalink.PermalinkBuilder
import io.element.android.libraries.matrix.api.permalink.PermalinkData
@ -81,7 +80,6 @@ import kotlinx.collections.immutable.toPersistentList
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.FlowPreview
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.collect
@ -89,11 +87,9 @@ import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.debounce
import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.flow.merge
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
import timber.log.Timber
import javax.inject.Inject
import kotlin.coroutines.coroutineContext
import kotlin.time.Duration.Companion.seconds
import io.element.android.libraries.core.mimetype.MimeTypes.Any as AnyMimeTypes
@ -180,26 +176,12 @@ class MessageComposerPresenter @Inject constructor(
val isFullScreen = rememberSaveable {
mutableStateOf(false)
}
val ongoingSendAttachmentJob = remember { mutableStateOf<Job?>(null) }
var showAttachmentSourcePicker: Boolean by remember { mutableStateOf(false) }
val sendTypingNotifications by sessionPreferencesStore.isSendTypingNotificationsEnabled().collectAsState(initial = true)
val roomAliasSuggestions by roomAliasSuggestionsDataSource.getAllRoomAliasSuggestions().collectAsState(initial = emptyList())
LaunchedEffect(attachmentsState.value) {
when (val attachmentStateValue = attachmentsState.value) {
is AttachmentsState.Sending.Processing -> {
ongoingSendAttachmentJob.value = localCoroutineScope.sendAttachment(
attachmentStateValue.attachments.first(),
attachmentsState,
)
}
else -> Unit
}
}
LaunchedEffect(cameraPermissionState.permissionGranted) {
if (cameraPermissionState.permissionGranted) {
when (pendingEvent) {
@ -272,7 +254,7 @@ class MessageComposerPresenter @Inject constructor(
when (event) {
MessageComposerEvents.ToggleFullScreenState -> isFullScreen.value = !isFullScreen.value
MessageComposerEvents.CloseSpecialMode -> {
if (messageComposerContext.composerMode is MessageComposerMode.Edit) {
if (messageComposerContext.composerMode.isEditing) {
localCoroutineScope.launch {
resetComposer(markdownTextEditorState, richTextEditorState, fromEdit = true)
}
@ -338,12 +320,6 @@ class MessageComposerPresenter @Inject constructor(
showAttachmentSourcePicker = false
// Navigation to the create poll screen is done at the view layer
}
is MessageComposerEvents.CancelSendAttachment -> {
ongoingSendAttachmentJob.value?.let {
it.cancel()
ongoingSendAttachmentJob.value == null
}
}
is MessageComposerEvents.ToggleTextFormatting -> {
showAttachmentSourcePicker = false
localCoroutineScope.toggleTextFormatting(event.enabled, markdownTextEditorState, richTextEditorState)
@ -455,7 +431,15 @@ class MessageComposerPresenter @Inject constructor(
}
}
}
is MessageComposerMode.EditCaption -> {
timelineController.invokeOnCurrentTimeline {
editCaption(
capturedMode.eventOrTransactionId,
caption = message.markdown,
formattedCaption = message.html
)
}
}
is MessageComposerMode.Reply -> {
timelineController.invokeOnCurrentTimeline {
replyMessage(capturedMode.eventId, message.markdown, message.html, message.intentionalMentions)
@ -505,17 +489,7 @@ class MessageComposerPresenter @Inject constructor(
formattedFileSize = null
)
val mediaAttachment = Attachment.Media(localMedia)
val isPreviewable = when {
MimeTypes.isImage(localMedia.info.mimeType) -> true
MimeTypes.isVideo(localMedia.info.mimeType) -> true
MimeTypes.isAudio(localMedia.info.mimeType) -> true
else -> false
}
attachmentsState.value = if (isPreviewable) {
AttachmentsState.Previewing(persistentListOf(mediaAttachment))
} else {
AttachmentsState.Sending.Processing(persistentListOf(mediaAttachment))
}
attachmentsState.value = AttachmentsState.Previewing(persistentListOf(mediaAttachment))
}
private suspend fun sendMedia(
@ -523,18 +497,10 @@ class MessageComposerPresenter @Inject constructor(
mimeType: String,
attachmentState: MutableState<AttachmentsState>,
) = runCatching {
val context = coroutineContext
val progressCallback = object : ProgressCallback {
override fun onProgress(current: Long, total: Long) {
if (context.isActive) {
attachmentState.value = AttachmentsState.Sending.Uploading(current.toFloat() / total.toFloat())
}
}
}
mediaSender.sendMedia(
uri = uri,
mimeType = mimeType,
progressCallback = progressCallback
progressCallback = null,
).getOrThrow()
}
.onSuccess {
@ -612,6 +578,10 @@ class MessageComposerPresenter @Inject constructor(
mode.eventOrTransactionId.eventId?.let { eventId -> ComposerDraftType.Edit(eventId) }
}
is MessageComposerMode.Reply -> ComposerDraftType.Reply(mode.eventId)
is MessageComposerMode.EditCaption -> {
// TODO Need a new type to save caption in the SDK
null
}
}
return if (draftType == null || message.markdown.isBlank()) {
null
@ -686,7 +656,14 @@ class MessageComposerPresenter @Inject constructor(
val currentComposerMode = messageComposerContext.composerMode
when (newComposerMode) {
is MessageComposerMode.Edit -> {
if (currentComposerMode !is MessageComposerMode.Edit) {
if (currentComposerMode.isEditing.not()) {
val draft = createDraftFromState(markdownTextEditorState, richTextEditorState)
updateDraft(draft, isVolatile = true).join()
}
setText(newComposerMode.content, markdownTextEditorState, richTextEditorState)
}
is MessageComposerMode.EditCaption -> {
if (currentComposerMode.isEditing.not()) {
val draft = createDraftFromState(markdownTextEditorState, richTextEditorState)
updateDraft(draft, isVolatile = true).join()
}
@ -694,7 +671,7 @@ class MessageComposerPresenter @Inject constructor(
}
else -> {
// When coming from edit, just clear the composer as it'd be weird to reset a volatile draft in this scenario.
if (currentComposerMode is MessageComposerMode.Edit) {
if (currentComposerMode.isEditing) {
setText("", markdownTextEditorState, richTextEditorState)
}
}

View file

@ -35,8 +35,4 @@ data class MessageComposerState(
sealed interface AttachmentsState {
data object None : AttachmentsState
data class Previewing(val attachments: ImmutableList<Attachment>) : AttachmentsState
sealed interface Sending : AttachmentsState {
data class Processing(val attachments: ImmutableList<Attachment>) : Sending
data class Uploading(val progress: Float) : Sending
}
}

View file

@ -0,0 +1,128 @@
/*
* 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.features.messages.impl.timeline.components.event
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import io.element.android.compound.theme.ElementTheme
import io.element.android.features.messages.impl.timeline.components.layout.ContentAvoidingLayout
import io.element.android.features.messages.impl.timeline.components.layout.ContentAvoidingLayoutData
import io.element.android.libraries.designsystem.theme.components.Text
/**
* package-private, you should only use TimelineItemFileView and TimelineItemAudioView.
*/
@Composable
fun TimelineItemAttachmentView(
filename: String,
fileExtensionAndSize: String,
caption: String?,
onContentLayoutChange: (ContentAvoidingLayoutData) -> Unit,
modifier: Modifier = Modifier,
icon: (@Composable () -> Unit) = {},
) {
Column(
modifier = modifier,
) {
TimelineItemAttachmentHeaderView(
filename = filename,
fileExtensionAndSize = fileExtensionAndSize,
hasCaption = caption != null,
onContentLayoutChange = onContentLayoutChange,
icon = icon,
)
if (caption != null) {
TimelineItemAttachmentCaptionView(
modifier = Modifier.padding(top = 4.dp),
caption = caption,
onContentLayoutChange = onContentLayoutChange,
)
}
}
}
@Composable
private fun TimelineItemAttachmentHeaderView(
filename: String,
fileExtensionAndSize: String,
hasCaption: Boolean,
onContentLayoutChange: (ContentAvoidingLayoutData) -> Unit,
modifier: Modifier = Modifier,
icon: (@Composable () -> Unit),
) {
val iconSize = 32.dp
val spacing = 8.dp
Row(
modifier = modifier,
) {
Box(
modifier = Modifier
.size(iconSize)
.clip(CircleShape)
.background(ElementTheme.materialColors.background),
contentAlignment = Alignment.Center,
) {
icon()
}
Spacer(Modifier.width(spacing))
Column {
Text(
text = filename,
color = ElementTheme.materialColors.primary,
maxLines = 2,
style = ElementTheme.typography.fontBodyLgRegular,
overflow = TextOverflow.Ellipsis
)
Text(
text = fileExtensionAndSize,
color = ElementTheme.materialColors.secondary,
style = ElementTheme.typography.fontBodySmRegular,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
onTextLayout = if (hasCaption) {
{}
} else {
ContentAvoidingLayout.measureLastTextLine(
onContentLayoutChange = onContentLayoutChange,
extraWidth = iconSize + spacing
)
},
)
}
}
}
@Composable
private fun TimelineItemAttachmentCaptionView(
caption: String,
onContentLayoutChange: (ContentAvoidingLayoutData) -> Unit,
modifier: Modifier = Modifier,
) {
Text(
modifier = modifier,
text = caption,
color = ElementTheme.materialColors.primary,
style = ElementTheme.typography.fontBodyLgRegular,
onTextLayout = ContentAvoidingLayout.measureLastTextLine(
onContentLayoutChange = onContentLayoutChange,
)
)
}

View file

@ -7,32 +7,20 @@
package io.element.android.features.messages.impl.timeline.components.event
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.GraphicEq
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.dp
import io.element.android.compound.theme.ElementTheme
import io.element.android.features.messages.impl.timeline.components.layout.ContentAvoidingLayout
import io.element.android.features.messages.impl.timeline.components.layout.ContentAvoidingLayoutData
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemAudioContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemAudioContentProvider
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.theme.components.Icon
import io.element.android.libraries.designsystem.theme.components.Text
@Composable
fun TimelineItemAudioView(
@ -40,18 +28,13 @@ fun TimelineItemAudioView(
onContentLayoutChange: (ContentAvoidingLayoutData) -> Unit,
modifier: Modifier = Modifier,
) {
val iconSize = 32.dp
val spacing = 8.dp
Row(
TimelineItemAttachmentView(
filename = content.filename,
fileExtensionAndSize = content.fileExtensionAndSize,
caption = content.caption,
onContentLayoutChange = onContentLayoutChange,
modifier = modifier,
) {
Box(
modifier = Modifier
.size(iconSize)
.clip(CircleShape)
.background(ElementTheme.materialColors.background),
contentAlignment = Alignment.Center,
) {
icon = {
Icon(
imageVector = Icons.Outlined.GraphicEq,
contentDescription = null,
@ -60,28 +43,7 @@ fun TimelineItemAudioView(
.size(16.dp),
)
}
Spacer(Modifier.width(spacing))
Column {
Text(
text = content.bestDescription,
color = ElementTheme.materialColors.primary,
maxLines = 2,
style = ElementTheme.typography.fontBodyLgRegular,
overflow = TextOverflow.Ellipsis
)
Text(
text = content.fileExtensionAndSize,
color = ElementTheme.materialColors.secondary,
style = ElementTheme.typography.fontBodySmRegular,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
onTextLayout = ContentAvoidingLayout.measureLastTextLine(
onContentLayoutChange = onContentLayoutChange,
extraWidth = iconSize + spacing
)
)
}
}
)
}
@PreviewsDayNight

View file

@ -7,24 +7,13 @@
package io.element.android.features.messages.impl.timeline.components.event
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.rotate
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.dp
import io.element.android.compound.theme.ElementTheme
import io.element.android.features.messages.impl.timeline.components.layout.ContentAvoidingLayout
import io.element.android.features.messages.impl.timeline.components.layout.ContentAvoidingLayoutData
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemFileContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemFileContentProvider
@ -32,7 +21,6 @@ import io.element.android.libraries.designsystem.icons.CompoundDrawables
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.theme.components.Icon
import io.element.android.libraries.designsystem.theme.components.Text
@Composable
fun TimelineItemFileView(
@ -40,18 +28,13 @@ fun TimelineItemFileView(
onContentLayoutChange: (ContentAvoidingLayoutData) -> Unit,
modifier: Modifier = Modifier,
) {
val iconSize = 32.dp
val spacing = 8.dp
Row(
TimelineItemAttachmentView(
filename = content.filename,
fileExtensionAndSize = content.fileExtensionAndSize,
caption = content.caption,
onContentLayoutChange = onContentLayoutChange,
modifier = modifier,
) {
Box(
modifier = Modifier
.size(iconSize)
.clip(CircleShape)
.background(ElementTheme.materialColors.background),
contentAlignment = Alignment.Center,
) {
icon = {
Icon(
resourceId = CompoundDrawables.ic_compound_attachment,
contentDescription = null,
@ -61,28 +44,7 @@ fun TimelineItemFileView(
.rotate(-45f),
)
}
Spacer(Modifier.width(spacing))
Column {
Text(
text = content.bestDescription,
color = ElementTheme.materialColors.primary,
maxLines = 2,
style = ElementTheme.typography.fontBodyLgRegular,
overflow = TextOverflow.Ellipsis
)
Text(
text = content.fileExtensionAndSize,
color = ElementTheme.materialColors.secondary,
style = ElementTheme.typography.fontBodySmRegular,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
onTextLayout = ContentAvoidingLayout.measureLastTextLine(
onContentLayoutChange = onContentLayoutChange,
extraWidth = iconSize + spacing
)
)
}
}
)
}
@PreviewsDayNight

View file

@ -17,13 +17,18 @@ open class TimelineItemAudioContentProvider : PreviewParameterProvider<TimelineI
get() = sequenceOf(
aTimelineItemAudioContent("A sound.mp3"),
aTimelineItemAudioContent("A bigger name sound.mp3"),
aTimelineItemAudioContent("An even bigger bigger bigger bigger bigger bigger bigger sound name which doesn't fit .mp3"),
aTimelineItemAudioContent("An even bigger bigger bigger bigger bigger bigger bigger sound name which doesn't fit.mp3"),
aTimelineItemAudioContent(caption = "A caption"),
aTimelineItemAudioContent(caption = "An even bigger bigger bigger bigger bigger bigger bigger caption"),
)
}
fun aTimelineItemAudioContent(fileName: String = "A sound.mp3") = TimelineItemAudioContent(
fun aTimelineItemAudioContent(
fileName: String = "A sound.mp3",
caption: String? = null,
) = TimelineItemAudioContent(
filename = fileName,
caption = null,
caption = caption,
formattedCaption = null,
isEdited = false,
mimeType = MimeTypes.Mp3,

View file

@ -16,15 +16,18 @@ open class TimelineItemFileContentProvider : PreviewParameterProvider<TimelineIt
get() = sequenceOf(
aTimelineItemFileContent(),
aTimelineItemFileContent("A bigger name file.pdf"),
aTimelineItemFileContent("An even bigger bigger bigger bigger bigger bigger bigger file name which doesn't fit .pdf"),
aTimelineItemFileContent("An even bigger bigger bigger bigger bigger bigger bigger file name which doesn't fit.pdf"),
aTimelineItemFileContent(caption = "A caption"),
aTimelineItemFileContent(caption = "An even bigger bigger bigger bigger bigger bigger bigger caption"),
)
}
fun aTimelineItemFileContent(
fileName: String = "A file.pdf",
caption: String? = null,
) = TimelineItemFileContent(
filename = fileName,
caption = null,
caption = caption,
formattedCaption = null,
isEdited = false,
thumbnailSource = null,

View file

@ -29,6 +29,7 @@ import io.element.android.features.messages.impl.timeline.model.event.TimelineIt
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemImageContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemTextContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemVideoContent
import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemImageContent
import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemPollContent
import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemTextContent
import io.element.android.features.messages.impl.timeline.protection.aTimelineProtectionState
@ -59,6 +60,7 @@ import io.element.android.libraries.matrix.api.timeline.item.event.EventOrTransa
import io.element.android.libraries.matrix.api.timeline.item.event.toEventOrTransactionId
import io.element.android.libraries.matrix.test.AN_AVATAR_URL
import io.element.android.libraries.matrix.test.AN_EVENT_ID
import io.element.android.libraries.matrix.test.A_CAPTION
import io.element.android.libraries.matrix.test.A_ROOM_ID
import io.element.android.libraries.matrix.test.A_SESSION_ID
import io.element.android.libraries.matrix.test.A_SESSION_ID_2
@ -82,6 +84,7 @@ import io.element.android.tests.testutils.lambda.assert
import io.element.android.tests.testutils.lambda.lambdaError
import io.element.android.tests.testutils.lambda.lambdaRecorder
import io.element.android.tests.testutils.lambda.value
import io.element.android.tests.testutils.test
import io.element.android.tests.testutils.testCoroutineDispatchers
import kotlinx.collections.immutable.persistentListOf
import kotlinx.coroutines.ExperimentalCoroutinesApi
@ -972,6 +975,103 @@ class MessagesPresenterTest {
}
}
@Test
fun `present - handle action edit caption`() = runTest {
val messageEvent = aMessageEvent(
content = aTimelineItemImageContent(
caption = A_CAPTION,
)
)
val composerRecorder = EventsRecorder<MessageComposerEvents>()
val presenter = createMessagesPresenter(
messageComposerPresenter = { aMessageComposerState(eventSink = composerRecorder) },
)
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,
)
)
)
}
}
@Test
fun `present - handle action add caption`() = runTest {
val composerRecorder = EventsRecorder<MessageComposerEvents>()
val presenter = createMessagesPresenter(
messageComposerPresenter = { aMessageComposerState(eventSink = composerRecorder) },
)
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 = "",
)
)
)
}
}
@Test
fun `present - handle action remove caption`() = runTest {
val messageEvent = aMessageEvent(
content = aTimelineItemImageContent(
caption = A_CAPTION,
)
)
val editCaptionLambda = lambdaRecorder { _: EventOrTransactionId, _: String?, _: String? -> Result.success(Unit) }
val timeline = FakeTimeline().apply {
this.editCaptionLambda = editCaptionLambda
}
val room = FakeMatrixRoom(
liveTimeline = timeline,
canUserSendMessageResult = { _, _ -> Result.success(true) },
canRedactOwnResult = { Result.success(true) },
canRedactOtherResult = { Result.success(true) },
canUserJoinCallResult = { Result.success(true) },
typingNoticeResult = { Result.success(Unit) },
canUserPinUnpinResult = { Result.success(true) },
)
val presenter = createMessagesPresenter(
matrixRoom = room,
)
presenter.test {
skipItems(1)
val initialState = awaitItem()
initialState.eventSink(MessagesEvents.HandleAction(TimelineItemAction.RemoveCaption, messageEvent))
editCaptionLambda.assertions().isCalledOnce().with(value(AN_EVENT_ID.toEventOrTransactionId()), value(null), value(null))
}
}
@Test
fun `present - handle action view in timeline, it should have no effect`() = runTest {
val messageEvent = aMessageEvent(
content = aTimelineItemTextContent()
)
val presenter = createMessagesPresenter()
presenter.test {
skipItems(1)
val initialState = awaitItem()
initialState.eventSink(MessagesEvents.HandleAction(TimelineItemAction.ViewInTimeline, messageEvent))
// No op!
}
}
private fun TestScope.createMessagesPresenter(
coroutineDispatchers: CoroutineDispatchers = testCoroutineDispatchers(),
matrixRoom: MatrixRoom = FakeMatrixRoom(

View file

@ -29,12 +29,14 @@ import io.element.android.features.poll.api.pollcontent.aPollAnswerItemList
import io.element.android.libraries.matrix.api.room.MatrixRoom
import io.element.android.libraries.matrix.api.timeline.item.event.LocalEventSendState
import io.element.android.libraries.matrix.test.AN_EVENT_ID
import io.element.android.libraries.matrix.test.A_CAPTION
import io.element.android.libraries.matrix.test.A_MESSAGE
import io.element.android.libraries.matrix.test.A_USER_ID
import io.element.android.libraries.matrix.test.room.FakeMatrixRoom
import io.element.android.libraries.matrix.test.room.aRoomInfo
import io.element.android.libraries.preferences.test.InMemoryAppPreferencesStore
import io.element.android.tests.testutils.WarmUpRule
import io.element.android.tests.testutils.test
import kotlinx.collections.immutable.persistentListOf
import kotlinx.coroutines.test.runTest
import org.junit.Rule
@ -184,6 +186,51 @@ class ActionListPresenterTest {
}
}
@Test
fun `present - compute for others message in a thread`() = runTest {
val presenter = createActionListPresenter(isDeveloperModeEnabled = true, isPinFeatureEnabled = true)
presenter.test {
val initialState = awaitItem()
val messageEvent = aMessageEvent(
isMine = false,
isEditable = false,
isThreaded = true,
content = TimelineItemTextContent(body = A_MESSAGE, htmlDocument = null, isEdited = false, formattedBody = null)
)
initialState.eventSink.invoke(
ActionListEvents.ComputeForMessage(
event = messageEvent,
userEventPermissions = aUserEventPermissions(
canRedactOwn = false,
canRedactOther = false,
canSendMessage = true,
canSendReaction = true,
canPinUnpin = true,
)
)
)
val successState = awaitItem()
assertThat(successState.target).isEqualTo(
ActionListState.Target.Success(
event = messageEvent,
displayEmojiReactions = true,
verifiedUserSendFailure = VerifiedUserSendFailure.None,
actions = persistentListOf(
TimelineItemAction.ReplyInThread,
TimelineItemAction.Forward,
TimelineItemAction.Pin,
TimelineItemAction.Copy,
TimelineItemAction.CopyLink,
TimelineItemAction.ViewSource,
TimelineItemAction.ReportContent,
)
)
)
initialState.eventSink.invoke(ActionListEvents.Clear)
assertThat(awaitItem().target).isEqualTo(ActionListState.Target.None)
}
}
@Test
fun `present - compute for others message cannot sent message`() = runTest {
val presenter = createActionListPresenter(isDeveloperModeEnabled = true, isPinFeatureEnabled = true)
@ -373,6 +420,51 @@ class ActionListPresenterTest {
}
}
@Test
fun `present - compute for my message in a thread`() = runTest {
val presenter = createActionListPresenter(isDeveloperModeEnabled = true, isPinFeatureEnabled = true)
presenter.test {
val initialState = awaitItem()
val messageEvent = aMessageEvent(
isMine = true,
isThreaded = true,
content = TimelineItemTextContent(body = A_MESSAGE, htmlDocument = null, isEdited = false, formattedBody = null)
)
initialState.eventSink.invoke(
ActionListEvents.ComputeForMessage(
event = messageEvent,
userEventPermissions = aUserEventPermissions(
canRedactOwn = true,
canRedactOther = false,
canSendMessage = true,
canSendReaction = true,
canPinUnpin = true,
)
)
)
val successState = awaitItem()
assertThat(successState.target).isEqualTo(
ActionListState.Target.Success(
event = messageEvent,
displayEmojiReactions = true,
verifiedUserSendFailure = VerifiedUserSendFailure.None,
actions = persistentListOf(
TimelineItemAction.ReplyInThread,
TimelineItemAction.Forward,
TimelineItemAction.Edit,
TimelineItemAction.Pin,
TimelineItemAction.Copy,
TimelineItemAction.CopyLink,
TimelineItemAction.ViewSource,
TimelineItemAction.Redact,
)
)
)
initialState.eventSink.invoke(ActionListEvents.Clear)
assertThat(awaitItem().target).isEqualTo(ActionListState.Target.None)
}
}
@Test
fun `present - compute for my message cannot redact`() = runTest {
val presenter = createActionListPresenter(isDeveloperModeEnabled = true, isPinFeatureEnabled = true)
@ -444,8 +536,6 @@ class ActionListPresenterTest {
),
)
)
// val loadingState = awaitItem()
// assertThat(loadingState.target).isEqualTo(ActionListState.Target.Loading(messageEvent))
val successState = awaitItem()
assertThat(successState.target).isEqualTo(
ActionListState.Target.Success(
@ -455,6 +545,56 @@ class ActionListPresenterTest {
actions = persistentListOf(
TimelineItemAction.Reply,
TimelineItemAction.Forward,
TimelineItemAction.AddCaption,
TimelineItemAction.Pin,
TimelineItemAction.CopyLink,
TimelineItemAction.ViewSource,
TimelineItemAction.Redact,
)
)
)
initialState.eventSink.invoke(ActionListEvents.Clear)
assertThat(awaitItem().target).isEqualTo(ActionListState.Target.None)
}
}
@Test
fun `present - compute for a media with caption item`() = runTest {
val presenter = createActionListPresenter(isDeveloperModeEnabled = true, isPinFeatureEnabled = true)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
val initialState = awaitItem()
val messageEvent = aMessageEvent(
isMine = true,
isEditable = false,
content = aTimelineItemImageContent(
caption = A_CAPTION,
),
)
initialState.eventSink.invoke(
ActionListEvents.ComputeForMessage(
event = messageEvent,
userEventPermissions = aUserEventPermissions(
canRedactOwn = true,
canRedactOther = false,
canSendMessage = true,
canSendReaction = true,
canPinUnpin = true,
),
)
)
val successState = awaitItem()
assertThat(successState.target).isEqualTo(
ActionListState.Target.Success(
event = messageEvent,
displayEmojiReactions = true,
verifiedUserSendFailure = VerifiedUserSendFailure.None,
actions = persistentListOf(
TimelineItemAction.Reply,
TimelineItemAction.Forward,
TimelineItemAction.EditCaption,
TimelineItemAction.RemoveCaption,
TimelineItemAction.Pin,
TimelineItemAction.CopyLink,
TimelineItemAction.ViewSource,

View file

@ -21,6 +21,7 @@ import io.element.android.features.messages.impl.attachments.preview.SendActionS
import io.element.android.features.messages.impl.fixtures.aMediaAttachment
import io.element.android.libraries.androidutils.file.TemporaryUriDeleter
import io.element.android.libraries.matrix.api.core.ProgressCallback
import io.element.android.libraries.matrix.api.media.AudioInfo
import io.element.android.libraries.matrix.api.media.FileInfo
import io.element.android.libraries.matrix.api.media.ImageInfo
import io.element.android.libraries.matrix.api.media.VideoInfo
@ -41,6 +42,7 @@ import io.element.android.tests.testutils.fake.FakeTemporaryUriDeleter
import io.element.android.tests.testutils.lambda.any
import io.element.android.tests.testutils.lambda.lambdaRecorder
import io.element.android.tests.testutils.lambda.value
import io.element.android.tests.testutils.test
import io.mockk.mockk
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.advanceUntilIdle
@ -60,7 +62,7 @@ class AttachmentsPreviewPresenterTest {
@Test
fun `present - send media success scenario`() = runTest {
val sendFileResult = lambdaRecorder<File, FileInfo, ProgressCallback?, Result<FakeMediaUploadHandler>> { _, _, _ ->
val sendFileResult = lambdaRecorder<File, FileInfo, String?, String?, ProgressCallback?, Result<FakeMediaUploadHandler>> { _, _, _, _, _ ->
Result.success(FakeMediaUploadHandler())
}
val room = FakeMatrixRoom(
@ -189,10 +191,46 @@ class AttachmentsPreviewPresenterTest {
}
}
@Test
fun `present - send audio with caption success scenario`() = runTest {
val sendAudioResult =
lambdaRecorder<File, AudioInfo, String?, String?, ProgressCallback?, Result<FakeMediaUploadHandler>> { _, _, _, _, _ ->
Result.success(FakeMediaUploadHandler())
}
val mediaPreProcessor = FakeMediaPreProcessor().apply {
givenAudioResult()
}
val room = FakeMatrixRoom(
sendAudioResult = sendAudioResult,
)
val onDoneListener = lambdaRecorder<Unit> { }
val presenter = createAttachmentsPreviewPresenter(
room = room,
mediaPreProcessor = mediaPreProcessor,
onDoneListener = { onDoneListener() },
)
presenter.test {
val initialState = awaitItem()
assertThat(initialState.sendActionState).isEqualTo(SendActionState.Idle)
initialState.textEditorState.setMarkdown(A_CAPTION)
initialState.eventSink(AttachmentsPreviewEvents.SendAttachment)
assertThat(awaitItem().sendActionState).isEqualTo(SendActionState.Sending.Processing)
advanceUntilIdle()
sendAudioResult.assertions().isCalledOnce().with(
any(),
any(),
value(A_CAPTION),
any(),
any(),
)
onDoneListener.assertions().isCalledOnce()
}
}
@Test
fun `present - send media failure scenario`() = runTest {
val failure = MediaPreProcessor.Failure(null)
val sendFileResult = lambdaRecorder<File, FileInfo, ProgressCallback?, Result<FakeMediaUploadHandler>> { _, _, _ ->
val sendFileResult = lambdaRecorder<File, FileInfo, String?, String?, ProgressCallback?, Result<FakeMediaUploadHandler>> { _, _, _, _, _ ->
Result.failure(failure)
}
val room = FakeMatrixRoom(

View file

@ -30,9 +30,7 @@ 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.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.core.ProgressCallback
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.media.FileInfo
import io.element.android.libraries.matrix.api.media.ImageInfo
import io.element.android.libraries.matrix.api.media.VideoInfo
import io.element.android.libraries.matrix.api.permalink.PermalinkBuilder
@ -49,6 +47,7 @@ import io.element.android.libraries.matrix.api.timeline.item.event.InReplyTo
import io.element.android.libraries.matrix.api.timeline.item.event.toEventOrTransactionId
import io.element.android.libraries.matrix.test.ANOTHER_MESSAGE
import io.element.android.libraries.matrix.test.AN_EVENT_ID
import io.element.android.libraries.matrix.test.A_CAPTION
import io.element.android.libraries.matrix.test.A_MESSAGE
import io.element.android.libraries.matrix.test.A_REPLY
import io.element.android.libraries.matrix.test.A_ROOM_ID
@ -58,7 +57,6 @@ import io.element.android.libraries.matrix.test.A_USER_ID_2
import io.element.android.libraries.matrix.test.A_USER_ID_3
import io.element.android.libraries.matrix.test.A_USER_ID_4
import io.element.android.libraries.matrix.test.core.aBuildMeta
import io.element.android.libraries.matrix.test.media.FakeMediaUploadHandler
import io.element.android.libraries.matrix.test.permalink.FakePermalinkBuilder
import io.element.android.libraries.matrix.test.permalink.FakePermalinkParser
import io.element.android.libraries.matrix.test.room.FakeMatrixRoom
@ -90,6 +88,7 @@ import io.element.android.tests.testutils.lambda.any
import io.element.android.tests.testutils.lambda.assert
import io.element.android.tests.testutils.lambda.lambdaRecorder
import io.element.android.tests.testutils.lambda.value
import io.element.android.tests.testutils.test
import io.element.android.tests.testutils.waitForPredicate
import io.mockk.mockk
import kotlinx.collections.immutable.persistentListOf
@ -211,6 +210,91 @@ class MessageComposerPresenterTest {
}
}
@Test
fun `present - change mode to edit caption`() = runTest {
val loadDraftLambda = lambdaRecorder { _: RoomId, _: Boolean ->
ComposerDraft(A_MESSAGE, A_MESSAGE, ComposerDraftType.NewMessage)
}
val updateDraftLambda = lambdaRecorder { _: RoomId, _: ComposerDraft?, _: Boolean -> }
val draftService = FakeComposerDraftService().apply {
this.loadDraftLambda = loadDraftLambda
this.saveDraftLambda = updateDraftLambda
}
val presenter = createPresenter(
coroutineScope = this,
draftService = draftService,
)
moleculeFlow(RecompositionMode.Immediate) {
val state = presenter.present()
remember(state, state.textEditorState.messageHtml()) { state }
}.test {
var state = awaitFirstItem()
val mode = anEditCaptionMode(caption = A_CAPTION)
state.eventSink.invoke(MessageComposerEvents.SetMode(mode))
state = awaitItem()
assertThat(state.mode).isEqualTo(mode)
assertThat(state.textEditorState.messageHtml()).isEqualTo(A_CAPTION)
state = backToNormalMode(state)
// The caption that was being edited is cleared and volatile draft is loaded
assertThat(state.textEditorState.messageHtml()).isEqualTo(A_MESSAGE)
assert(loadDraftLambda)
.isCalledExactly(2)
.withSequence(
// Automatic load of draft
listOf(value(A_ROOM_ID), value(false)),
// Load of volatile draft when closing edit mode
listOf(value(A_ROOM_ID), value(true))
)
assert(updateDraftLambda)
.isCalledOnce()
.with(value(A_ROOM_ID), any(), value(true))
}
}
@Test
fun `present - change mode to edit caption and send the caption`() = runTest {
val editCaptionLambda = lambdaRecorder { _: EventOrTransactionId, _: String?, _: String? ->
Result.success(Unit)
}
val timeline = FakeTimeline().apply {
this.editCaptionLambda = editCaptionLambda
}
val fakeMatrixRoom = FakeMatrixRoom(
liveTimeline = timeline,
typingNoticeResult = { Result.success(Unit) }
)
val presenter = createPresenter(
coroutineScope = this,
room = fakeMatrixRoom,
isRichTextEditorEnabled = false,
)
val permalinkBuilder = FakePermalinkBuilder(permalinkForUserLambda = { Result.success("") })
presenter.test {
var state = awaitFirstItem()
val mode = anEditCaptionMode(caption = A_CAPTION)
state.eventSink.invoke(MessageComposerEvents.SetMode(mode))
state = awaitItem()
assertThat(state.mode).isEqualTo(mode)
assertThat(state.textEditorState.messageMarkdown(permalinkBuilder)).isEqualTo(A_CAPTION)
state.eventSink.invoke(MessageComposerEvents.SendMessage)
val messageSentState = awaitItem()
assertThat(messageSentState.textEditorState.messageMarkdown(permalinkBuilder)).isEqualTo("")
waitForPredicate { analyticsService.capturedEvents.size == 1 }
assertThat(analyticsService.capturedEvents).containsExactly(
Composer(
inThread = false,
isEditing = true,
isReply = false,
messageType = Composer.MessageType.Text,
)
)
assert(editCaptionLambda)
.isCalledOnce()
.with(value(AN_EVENT_ID.toEventOrTransactionId()), value(A_CAPTION), value(null))
}
}
@Test
fun `present - change mode to reply after edit`() = runTest {
val loadDraftLambda = lambdaRecorder { _: RoomId, _: Boolean ->
@ -684,17 +768,8 @@ class MessageComposerPresenterTest {
}
@Test
fun `present - Pick file from storage`() = runTest {
val sendFileResult = lambdaRecorder<File, FileInfo, ProgressCallback?, Result<FakeMediaUploadHandler>> { _, _, _ ->
Result.success(FakeMediaUploadHandler())
}
fun `present - Pick file from storage will open the preview`() = runTest {
val room = FakeMatrixRoom(
progressCallbackValues = listOf(
Pair(0, 10),
Pair(5, 10),
Pair(10, 10)
),
sendFileResult = sendFileResult,
typingNoticeResult = { Result.success(Unit) }
)
val presenter = createPresenter(this, room = room)
@ -705,13 +780,7 @@ class MessageComposerPresenterTest {
initialState.eventSink(MessageComposerEvents.PickAttachmentSource.FromFiles)
val sendingState = awaitItem()
assertThat(sendingState.showAttachmentSourcePicker).isFalse()
assertThat(sendingState.attachmentsState).isInstanceOf(AttachmentsState.Sending.Processing::class.java)
assertThat(awaitItem().attachmentsState).isEqualTo(AttachmentsState.Sending.Uploading(0f))
assertThat(awaitItem().attachmentsState).isEqualTo(AttachmentsState.Sending.Uploading(0.5f))
assertThat(awaitItem().attachmentsState).isEqualTo(AttachmentsState.Sending.Uploading(1f))
val sentState = awaitItem()
assertThat(sentState.attachmentsState).isEqualTo(AttachmentsState.None)
sendFileResult.assertions().isCalledOnce()
assertThat(sendingState.attachmentsState).isInstanceOf(AttachmentsState.Previewing::class.java)
}
}
@ -851,48 +920,6 @@ class MessageComposerPresenterTest {
}
}
@Test
fun `present - Uploading media failure can be recovered from`() = runTest {
val sendFileResult = lambdaRecorder<File, FileInfo, ProgressCallback?, Result<FakeMediaUploadHandler>> { _, _, _ ->
Result.failure(Exception())
}
val room = FakeMatrixRoom(
sendFileResult = sendFileResult,
typingNoticeResult = { Result.success(Unit) }
)
val presenter = createPresenter(this, room = room)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
val initialState = awaitFirstItem()
initialState.eventSink(MessageComposerEvents.PickAttachmentSource.FromFiles)
val sendingState = awaitItem()
assertThat(sendingState.attachmentsState).isInstanceOf(AttachmentsState.Sending::class.java)
val finalState = awaitItem()
assertThat(finalState.attachmentsState).isInstanceOf(AttachmentsState.None::class.java)
snackbarDispatcher.snackbarMessage.test {
// Assert error message received
assertThat(awaitItem()).isNotNull()
}
}
}
@Test
fun `present - CancelSendAttachment stops media upload`() = runTest {
val presenter = createPresenter(this)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
val initialState = awaitFirstItem()
initialState.eventSink(MessageComposerEvents.PickAttachmentSource.FromFiles)
val sendingState = awaitItem()
assertThat(sendingState.showAttachmentSourcePicker).isFalse()
assertThat(sendingState.attachmentsState).isInstanceOf(AttachmentsState.Sending.Processing::class.java)
sendingState.eventSink(MessageComposerEvents.CancelSendAttachment)
assertThat(awaitItem().attachmentsState).isEqualTo(AttachmentsState.None)
}
}
@Test
fun `present - errors are tracked`() = runTest {
val testException = Exception("Test error")
@ -1487,17 +1514,17 @@ class MessageComposerPresenterTest {
isRichTextEditorEnabled: Boolean = true,
draftService: ComposerDraftService = FakeComposerDraftService(),
) = MessageComposerPresenter(
coroutineScope,
room,
pickerProvider,
featureFlagService,
sessionPreferencesStore,
localMediaFactory,
MediaSender(mediaPreProcessor, room, InMemorySessionPreferencesStore()),
snackbarDispatcher,
analyticsService,
DefaultMessageComposerContext(),
TestRichTextEditorStateFactory(),
appCoroutineScope = coroutineScope,
room = room,
mediaPickerProvider = pickerProvider,
featureFlagService = featureFlagService,
sessionPreferencesStore = sessionPreferencesStore,
localMediaFactory = localMediaFactory,
mediaSender = MediaSender(mediaPreProcessor, room, InMemorySessionPreferencesStore()),
snackbarDispatcher = snackbarDispatcher,
analyticsService = analyticsService,
messageComposerContext = DefaultMessageComposerContext(),
richTextEditorStateFactory = TestRichTextEditorStateFactory(),
roomAliasSuggestionsDataSource = FakeRoomAliasSuggestionsDataSource(),
permissionsPresenterFactory = FakePermissionsPresenterFactory(permissionPresenter),
permalinkParser = permalinkParser,
@ -1525,6 +1552,11 @@ fun anEditMode(
message: String = A_MESSAGE,
) = MessageComposerMode.Edit(eventOrTransactionId, message)
fun anEditCaptionMode(
eventOrTransactionId: EventOrTransactionId = AN_EVENT_ID.toEventOrTransactionId(),
caption: String = A_CAPTION,
) = MessageComposerMode.EditCaption(eventOrTransactionId, caption)
fun aReplyMode() = MessageComposerMode.Reply(
replyToDetails = InReplyToDetails.Loading(AN_EVENT_ID),
hideImage = false,

View file

@ -116,7 +116,7 @@ class SharePresenterTest {
@Test
fun `present - send media ok`() = runTest {
val sendFileResult = lambdaRecorder<File, FileInfo, ProgressCallback?, Result<FakeMediaUploadHandler>> { _, _, _ ->
val sendFileResult = lambdaRecorder<File, FileInfo, String?, String?, ProgressCallback?, Result<FakeMediaUploadHandler>> { _, _, _, _, _ ->
Result.success(FakeMediaUploadHandler())
}
val matrixRoom = FakeMatrixRoom(

View file

@ -147,9 +147,21 @@ interface MatrixRoom : Closeable {
progressCallback: ProgressCallback?
): Result<MediaUploadHandler>
suspend fun sendAudio(file: File, audioInfo: AudioInfo, progressCallback: ProgressCallback?): Result<MediaUploadHandler>
suspend fun sendAudio(
file: File,
audioInfo: AudioInfo,
caption: String?,
formattedCaption: String?,
progressCallback: ProgressCallback?,
): Result<MediaUploadHandler>
suspend fun sendFile(file: File, fileInfo: FileInfo, progressCallback: ProgressCallback?): Result<MediaUploadHandler>
suspend fun sendFile(
file: File,
fileInfo: FileInfo,
caption: String?,
formattedCaption: String?,
progressCallback: ProgressCallback?,
): Result<MediaUploadHandler>
suspend fun toggleReaction(emoji: String, eventOrTransactionId: EventOrTransactionId): Result<Unit>

View file

@ -59,10 +59,17 @@ interface Timeline : AutoCloseable {
suspend fun editMessage(
eventOrTransactionId: EventOrTransactionId,
body: String, htmlBody: String?,
body: String,
htmlBody: String?,
intentionalMentions: List<IntentionalMention>,
): Result<Unit>
suspend fun editCaption(
eventOrTransactionId: EventOrTransactionId,
caption: String?,
formattedCaption: String?,
): Result<Unit>
suspend fun replyMessage(
eventId: EventId,
body: String,
@ -91,9 +98,21 @@ interface Timeline : AutoCloseable {
suspend fun redactEvent(eventOrTransactionId: EventOrTransactionId, reason: String?): Result<Unit>
suspend fun sendAudio(file: File, audioInfo: AudioInfo, progressCallback: ProgressCallback?): Result<MediaUploadHandler>
suspend fun sendAudio(
file: File,
audioInfo: AudioInfo,
caption: String?,
formattedCaption: String?,
progressCallback: ProgressCallback?,
): Result<MediaUploadHandler>
suspend fun sendFile(file: File, fileInfo: FileInfo, progressCallback: ProgressCallback?): Result<MediaUploadHandler>
suspend fun sendFile(
file: File,
fileInfo: FileInfo,
caption: String?,
formattedCaption: String?,
progressCallback: ProgressCallback?,
): Result<MediaUploadHandler>
suspend fun toggleReaction(emoji: String, eventOrTransactionId: EventOrTransactionId): Result<Unit>

View file

@ -57,6 +57,7 @@ enum class Target(open val filter: String) {
MATRIX_SDK_HTTP_CLIENT("matrix_sdk::http_client"),
MATRIX_SDK_CLIENT("matrix_sdk::client"),
MATRIX_SDK_OIDC("matrix_sdk::oidc"),
MATRIX_SDK_SEND_QUEUE("matrix_sdk::send_queue"),
MATRIX_SDK_SLIDING_SYNC("matrix_sdk::sliding_sync"),
MATRIX_SDK_BASE_SLIDING_SYNC("matrix_sdk_base::sliding_sync"),
MATRIX_SDK_UI_TIMELINE("matrix_sdk_ui::timeline"),

View file

@ -467,12 +467,36 @@ class RustMatrixRoom(
return liveTimeline.sendVideo(file, thumbnailFile, videoInfo, caption, formattedCaption, progressCallback)
}
override suspend fun sendAudio(file: File, audioInfo: AudioInfo, progressCallback: ProgressCallback?): Result<MediaUploadHandler> {
return liveTimeline.sendAudio(file, audioInfo, progressCallback)
override suspend fun sendAudio(
file: File,
audioInfo: AudioInfo,
caption: String?,
formattedCaption: String?,
progressCallback: ProgressCallback?,
): Result<MediaUploadHandler> {
return liveTimeline.sendAudio(
file = file,
audioInfo = audioInfo,
caption = caption,
formattedCaption = formattedCaption,
progressCallback = progressCallback,
)
}
override suspend fun sendFile(file: File, fileInfo: FileInfo, progressCallback: ProgressCallback?): Result<MediaUploadHandler> {
return liveTimeline.sendFile(file, fileInfo, progressCallback)
override suspend fun sendFile(
file: File,
fileInfo: FileInfo,
caption: String?,
formattedCaption: String?,
progressCallback: ProgressCallback?,
): Result<MediaUploadHandler> {
return liveTimeline.sendFile(
file,
fileInfo,
caption,
formattedCaption,
progressCallback,
)
}
override suspend fun toggleReaction(emoji: String, eventOrTransactionId: EventOrTransactionId): Result<Unit> {

View file

@ -295,22 +295,40 @@ class RustTimeline(
body: String,
htmlBody: String?,
intentionalMentions: List<IntentionalMention>,
): Result<Unit> =
withContext(dispatcher) {
runCatching<Unit> {
val editedContent = EditedContent.RoomMessage(
content = MessageEventContent.from(
body = body,
htmlBody = htmlBody,
intentionalMentions = intentionalMentions
),
)
inner.edit(
newContent = editedContent,
eventOrTransactionId = eventOrTransactionId.toRustEventOrTransactionId(),
)
}
): Result<Unit> = withContext(dispatcher) {
runCatching<Unit> {
val editedContent = EditedContent.RoomMessage(
content = MessageEventContent.from(
body = body,
htmlBody = htmlBody,
intentionalMentions = intentionalMentions
),
)
inner.edit(
newContent = editedContent,
eventOrTransactionId = eventOrTransactionId.toRustEventOrTransactionId(),
)
}
}
override suspend fun editCaption(
eventOrTransactionId: EventOrTransactionId,
caption: String?,
formattedCaption: String?,
): Result<Unit> = withContext(dispatcher) {
runCatching<Unit> {
val editedContent = EditedContent.MediaCaption(
caption = caption,
formattedCaption = formattedCaption?.let {
FormattedBody(body = it, format = MessageFormat.Html)
},
)
inner.edit(
newContent = editedContent,
eventOrTransactionId = eventOrTransactionId.toRustEventOrTransactionId(),
)
}
}
override suspend fun replyMessage(
eventId: EventId,
@ -373,29 +391,44 @@ class RustTimeline(
}
}
override suspend fun sendAudio(file: File, audioInfo: AudioInfo, progressCallback: ProgressCallback?): Result<MediaUploadHandler> {
override suspend fun sendAudio(
file: File,
audioInfo: AudioInfo,
caption: String?,
formattedCaption: String?,
progressCallback: ProgressCallback?,
): Result<MediaUploadHandler> {
val useSendQueue = featureFlagsService.isFeatureEnabled(FeatureFlags.MediaUploadOnSendQueue)
return sendAttachment(listOf(file)) {
inner.sendAudio(
url = file.path,
audioInfo = audioInfo.map(),
// Maybe allow a caption in the future?
caption = null,
formattedCaption = null,
caption = caption,
formattedCaption = formattedCaption?.let {
FormattedBody(body = it, format = MessageFormat.Html)
},
useSendQueue = useSendQueue,
progressWatcher = progressCallback?.toProgressWatcher()
)
}
}
override suspend fun sendFile(file: File, fileInfo: FileInfo, progressCallback: ProgressCallback?): Result<MediaUploadHandler> {
override suspend fun sendFile(
file: File,
fileInfo: FileInfo,
caption: String?,
formattedCaption: String?,
progressCallback: ProgressCallback?,
): Result<MediaUploadHandler> {
val useSendQueue = featureFlagsService.isFeatureEnabled(FeatureFlags.MediaUploadOnSendQueue)
return sendAttachment(listOf(file)) {
inner.sendFile(
url = file.path,
fileInfo = fileInfo.map(),
caption = null,
formattedCaption = null,
caption = caption,
formattedCaption = formattedCaption?.let {
FormattedBody(body = it, format = MessageFormat.Html)
},
useSendQueue = useSendQueue,
progressWatcher = progressCallback?.toProgressWatcher(),
)

View file

@ -92,10 +92,10 @@ class FakeMatrixRoom(
{ _, _, _, _, _, _ -> lambdaError() },
private val sendVideoResult: (File, File?, VideoInfo, String?, String?, ProgressCallback?) -> Result<FakeMediaUploadHandler> =
{ _, _, _, _, _, _ -> lambdaError() },
private val sendFileResult: (File, FileInfo, ProgressCallback?) -> Result<FakeMediaUploadHandler> =
{ _, _, _ -> lambdaError() },
private val sendAudioResult: (File, AudioInfo, ProgressCallback?) -> Result<FakeMediaUploadHandler> =
{ _, _, _ -> lambdaError() },
private val sendFileResult: (File, FileInfo, String?, String?, ProgressCallback?) -> Result<FakeMediaUploadHandler> =
{ _, _, _, _, _ -> lambdaError() },
private val sendAudioResult: (File, AudioInfo, String?, String?, ProgressCallback?) -> Result<FakeMediaUploadHandler> =
{ _, _, _, _, _ -> lambdaError() },
private val sendVoiceMessageResult: (File, AudioInfo, List<Float>, ProgressCallback?) -> Result<FakeMediaUploadHandler> =
{ _, _, _, _ -> lambdaError() },
private val setNameResult: (String) -> Result<Unit> = { lambdaError() },
@ -354,12 +354,16 @@ class FakeMatrixRoom(
override suspend fun sendAudio(
file: File,
audioInfo: AudioInfo,
caption: String?,
formattedCaption: String?,
progressCallback: ProgressCallback?
): Result<MediaUploadHandler> = simulateLongTask {
simulateSendMediaProgress(progressCallback)
sendAudioResult(
file,
audioInfo,
caption,
formattedCaption,
progressCallback,
)
}
@ -367,12 +371,16 @@ class FakeMatrixRoom(
override suspend fun sendFile(
file: File,
fileInfo: FileInfo,
caption: String?,
formattedCaption: String?,
progressCallback: ProgressCallback?
): Result<MediaUploadHandler> = simulateLongTask {
simulateSendMediaProgress(progressCallback)
sendFileResult(
file,
fileInfo,
caption,
formattedCaption,
progressCallback,
)
}

View file

@ -92,6 +92,24 @@ class FakeTimeline(
intentionalMentions
)
var editCaptionLambda: (
eventOrTransactionId: EventOrTransactionId,
caption: String?,
formattedCaption: String?,
) -> Result<Unit> = { _, _, _ ->
lambdaError()
}
override suspend fun editCaption(
eventOrTransactionId: EventOrTransactionId,
caption: String?,
formattedCaption: String?,
): Result<Unit> = editCaptionLambda(
eventOrTransactionId,
caption,
formattedCaption,
)
var replyMessageLambda: (
eventId: EventId,
body: String,
@ -173,36 +191,48 @@ class FakeTimeline(
var sendAudioLambda: (
file: File,
audioInfo: AudioInfo,
caption: String?,
formattedCaption: String?,
progressCallback: ProgressCallback?,
) -> Result<MediaUploadHandler> = { _, _, _ ->
) -> Result<MediaUploadHandler> = { _, _, _, _, _ ->
Result.success(FakeMediaUploadHandler())
}
override suspend fun sendAudio(
file: File,
audioInfo: AudioInfo,
caption: String?,
formattedCaption: String?,
progressCallback: ProgressCallback?,
): Result<MediaUploadHandler> = sendAudioLambda(
file,
audioInfo,
caption,
formattedCaption,
progressCallback
)
var sendFileLambda: (
file: File,
fileInfo: FileInfo,
caption: String?,
formattedCaption: String?,
progressCallback: ProgressCallback?,
) -> Result<MediaUploadHandler> = { _, _, _ ->
) -> Result<MediaUploadHandler> = { _, _, _, _, _ ->
Result.success(FakeMediaUploadHandler())
}
override suspend fun sendFile(
file: File,
fileInfo: FileInfo,
caption: String?,
formattedCaption: String?,
progressCallback: ProgressCallback?,
): Result<MediaUploadHandler> = sendFileLambda(
file,
fileInfo,
caption,
formattedCaption,
progressCallback
)

View file

@ -125,6 +125,8 @@ class MediaSender @Inject constructor(
sendAudio(
file = uploadInfo.file,
audioInfo = uploadInfo.audioInfo,
caption = caption,
formattedCaption = formattedCaption,
progressCallback = progressCallback
)
}
@ -140,6 +142,8 @@ class MediaSender @Inject constructor(
sendFile(
file = uploadInfo.file,
fileInfo = uploadInfo.fileInfo,
caption = caption,
formattedCaption = formattedCaption,
progressCallback = progressCallback
)
}

View file

@ -91,7 +91,7 @@ class MediaSenderTest {
@OptIn(ExperimentalCoroutinesApi::class)
@Test
fun `given a cancellation in the media upload when sending the job is cancelled`() = runTest(StandardTestDispatcher()) {
val sendFileResult = lambdaRecorder<File, FileInfo, ProgressCallback?, Result<FakeMediaUploadHandler>> { _, _, _ ->
val sendFileResult = lambdaRecorder<File, FileInfo, String?, String?, ProgressCallback?, Result<FakeMediaUploadHandler>> { _, _, _, _, _ ->
Result.success(FakeMediaUploadHandler())
}
val room = FakeMatrixRoom(

View file

@ -47,6 +47,16 @@ internal fun ComposerModeView(
when (composerMode) {
is MessageComposerMode.Edit -> {
EditingModeView(
text = stringResource(CommonStrings.common_editing),
modifier = modifier,
onResetComposerMode = onResetComposerMode,
)
}
is MessageComposerMode.EditCaption -> {
EditingModeView(
text = stringResource(
if (composerMode.content.isEmpty()) CommonStrings.common_adding_caption else CommonStrings.common_editing_caption
),
modifier = modifier,
onResetComposerMode = onResetComposerMode,
)
@ -65,6 +75,7 @@ internal fun ComposerModeView(
@Composable
private fun EditingModeView(
onResetComposerMode: () -> Unit,
text: String,
modifier: Modifier = Modifier,
) {
Row(
@ -76,14 +87,14 @@ private fun EditingModeView(
) {
Icon(
imageVector = CompoundIcons.Edit(),
contentDescription = stringResource(CommonStrings.common_editing),
contentDescription = null,
tint = ElementTheme.materialColors.secondary,
modifier = Modifier
.padding(vertical = 8.dp)
.size(16.dp),
)
Text(
stringResource(CommonStrings.common_editing),
text = text,
style = ElementTheme.typography.fontBodySmRegular,
textAlign = TextAlign.Start,
color = ElementTheme.materialColors.secondary,

View file

@ -121,19 +121,25 @@ fun TextComposer(
}
val layoutModifier = modifier
.fillMaxSize()
.height(IntrinsicSize.Min)
.fillMaxSize()
.height(IntrinsicSize.Min)
val composerOptionsButton: @Composable () -> Unit = remember {
val composerOptionsButton: @Composable () -> Unit = remember(composerMode) {
@Composable {
if (composerMode is MessageComposerMode.Attachment) {
Spacer(modifier = Modifier.width(9.dp))
} else {
ComposerOptionsButton(
modifier = Modifier
.size(48.dp),
onClick = onAddAttachment
)
when (composerMode) {
is MessageComposerMode.Attachment -> {
Spacer(modifier = Modifier.width(9.dp))
}
is MessageComposerMode.EditCaption -> {
Spacer(modifier = Modifier.width(16.dp))
}
else -> {
ComposerOptionsButton(
modifier = Modifier
.size(48.dp),
onClick = onAddAttachment
)
}
}
}
}
@ -331,8 +337,8 @@ private fun StandardLayout(
if (voiceMessageState is VoiceMessageState.Preview || voiceMessageState is VoiceMessageState.Recording) {
Box(
modifier = Modifier
.padding(bottom = 5.dp, top = 5.dp, end = 3.dp, start = 3.dp)
.size(48.dp),
.padding(bottom = 5.dp, top = 5.dp, end = 3.dp, start = 3.dp)
.size(48.dp),
contentAlignment = Alignment.Center,
) {
voiceDeleteButton()
@ -342,8 +348,8 @@ private fun StandardLayout(
}
Box(
modifier = Modifier
.padding(bottom = 8.dp, top = 8.dp)
.weight(1f)
.padding(bottom = 8.dp, top = 8.dp)
.weight(1f)
) {
voiceRecording()
}
@ -356,16 +362,16 @@ private fun StandardLayout(
}
Box(
modifier = Modifier
.padding(bottom = 8.dp, top = 8.dp)
.weight(1f)
.padding(bottom = 8.dp, top = 8.dp)
.weight(1f)
) {
textInput()
}
}
Box(
Modifier
.padding(bottom = 5.dp, top = 5.dp, end = 6.dp, start = 6.dp)
.size(48.dp),
Modifier
.padding(bottom = 5.dp, top = 5.dp, end = 6.dp, start = 6.dp)
.size(48.dp),
contentAlignment = Alignment.Center,
) {
endButton()
@ -387,8 +393,8 @@ private fun TextFormattingLayout(
) {
Box(
modifier = Modifier
.weight(1f)
.padding(horizontal = 12.dp)
.weight(1f)
.padding(horizontal = 12.dp)
) {
textInput()
}
@ -432,11 +438,11 @@ private fun TextInputBox(
Column(
modifier = Modifier
.clip(roundedCorners)
.border(0.5.dp, borderColor, roundedCorners)
.background(color = bgColor)
.requiredHeightIn(min = 42.dp)
.fillMaxSize(),
.clip(roundedCorners)
.border(0.5.dp, borderColor, roundedCorners)
.background(color = bgColor)
.requiredHeightIn(min = 42.dp)
.fillMaxSize(),
) {
if (composerMode is MessageComposerMode.Special) {
ComposerModeView(
@ -447,9 +453,9 @@ private fun TextInputBox(
val defaultTypography = ElementTheme.typography.fontBodyLgRegular
Box(
modifier = Modifier
.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
.then(if (!subcomposing) Modifier.testTag(TestTags.textEditor) else Modifier),
.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
.then(if (!subcomposing) Modifier.testTag(TestTags.textEditor) else Modifier),
contentAlignment = Alignment.CenterStart,
) {
// Placeholder
@ -495,8 +501,8 @@ private fun TextInput(
// This prevents it gaining focus and mutating the state.
registerStateUpdates = !subcomposing,
modifier = Modifier
.padding(top = 6.dp, bottom = 6.dp)
.fillMaxWidth(),
.padding(top = 6.dp, bottom = 6.dp)
.fillMaxWidth(),
style = ElementRichTextEditorStyle.composerStyle(hasFocus = state.hasFocus),
resolveMentionDisplay = resolveMentionDisplay,
resolveRoomMentionDisplay = resolveRoomMentionDisplay,
@ -573,6 +579,42 @@ internal fun TextComposerEditPreview() = ElementPreview {
}
}
@PreviewsDayNight
@Composable
internal fun TextComposerEditCaptionPreview() = ElementPreview {
PreviewColumn(
items = aTextEditorStateRichList()
) { _, textEditorState ->
ATextComposer(
state = textEditorState,
voiceMessageState = VoiceMessageState.Idle,
composerMode = aMessageComposerModeEditCaption(
// Set an existing caption so that the UI will be in edit caption mode
content = "An existing caption",
),
enableVoiceMessages = false,
)
}
}
@PreviewsDayNight
@Composable
internal fun TextComposerAddCaptionPreview() = ElementPreview {
PreviewColumn(
items = aTextEditorStateRichList()
) { _, textEditorState ->
ATextComposer(
state = textEditorState,
voiceMessageState = VoiceMessageState.Idle,
composerMode = aMessageComposerModeEditCaption(
// No caption so that the UI will be in add caption mode
content = "",
),
enableVoiceMessages = false,
)
}
}
@PreviewsDayNight
@Composable
internal fun MarkdownTextComposerEditPreview() = ElementPreview {
@ -717,6 +759,14 @@ fun aMessageComposerModeEdit(
content = content
)
fun aMessageComposerModeEditCaption(
eventOrTransactionId: EventOrTransactionId = EventId("$1234").toEventOrTransactionId(),
content: String,
) = MessageComposerMode.EditCaption(
eventOrTransactionId = eventOrTransactionId,
content = content
)
fun aMessageComposerModeReply(
replyToDetails: InReplyToDetails,
hideImage: Boolean = false,

View file

@ -53,16 +53,16 @@ internal fun SendButton(
onClick = onClick,
enabled = canSendMessage,
) {
val iconVector = when (composerMode) {
is MessageComposerMode.Edit -> CompoundIcons.Check()
val iconVector = when {
composerMode.isEditing -> CompoundIcons.Check()
else -> CompoundIcons.SendSolid()
}
val iconStartPadding = when (composerMode) {
is MessageComposerMode.Edit -> 0.dp
val iconStartPadding = when {
composerMode.isEditing -> 0.dp
else -> 2.dp
}
val contentDescription = when (composerMode) {
is MessageComposerMode.Edit -> stringResource(CommonStrings.action_edit)
val contentDescription = when {
composerMode.isEditing -> stringResource(CommonStrings.action_edit)
else -> stringResource(CommonStrings.action_send)
}
Box(

View file

@ -27,6 +27,11 @@ sealed interface MessageComposerMode {
val content: String
) : Special
data class EditCaption(
val eventOrTransactionId: EventOrTransactionId,
val content: String
) : Special
data class Reply(
val replyToDetails: InReplyToDetails,
val hideImage: Boolean,
@ -34,16 +39,8 @@ sealed interface MessageComposerMode {
val eventId: EventId = replyToDetails.eventId()
}
val relatedEventId: EventId?
get() = when (this) {
is Normal,
is Attachment -> null
is Edit -> eventOrTransactionId.eventId
is Reply -> eventId
}
val isEditing: Boolean
get() = this is Edit
get() = this is Edit || this is EditCaption
val isReply: Boolean
get() = this is Reply

View file

@ -32,6 +32,7 @@
<string name="a11y_voice_message_record">"Record voice message."</string>
<string name="a11y_voice_message_stop_recording">"Stop recording"</string>
<string name="action_accept">"Accept"</string>
<string name="action_add_caption">"Add caption"</string>
<string name="action_add_to_timeline">"Add to timeline"</string>
<string name="action_back">"Back"</string>
<string name="action_call">"Call"</string>
@ -57,6 +58,7 @@
<string name="action_discard">"Discard"</string>
<string name="action_done">"Done"</string>
<string name="action_edit">"Edit"</string>
<string name="action_edit_caption">"Edit caption"</string>
<string name="action_edit_poll">"Edit poll"</string>
<string name="action_enable">"Enable"</string>
<string name="action_end_poll">"End poll"</string>
@ -91,6 +93,7 @@
<string name="action_react">"React"</string>
<string name="action_reject">"Reject"</string>
<string name="action_remove">"Remove"</string>
<string name="action_remove_caption">"Remove caption"</string>
<string name="action_reply">"Reply"</string>
<string name="action_reply_in_thread">"Reply in thread"</string>
<string name="action_report_bug">"Report bug"</string>
@ -123,6 +126,7 @@
<string name="action_yes">"Yes"</string>
<string name="common_about">"About"</string>
<string name="common_acceptable_use_policy">"Acceptable use policy"</string>
<string name="common_adding_caption">"Adding caption"</string>
<string name="common_advanced_settings">"Advanced settings"</string>
<string name="common_analytics">"Analytics"</string>
<string name="common_appearance">"Appearance"</string>
@ -143,6 +147,7 @@
<string name="common_do_not_show_this_again">"Do not show this again"</string>
<string name="common_edited_suffix">"(edited)"</string>
<string name="common_editing">"Editing"</string>
<string name="common_editing_caption">"Editing caption"</string>
<string name="common_emote">"* %1$s %2$s"</string>
<string name="common_encryption">"Encryption"</string>
<string name="common_encryption_enabled">"Encryption enabled"</string>

View file

@ -101,8 +101,10 @@ class KonsistPreviewTest {
"SasEmojisPreview",
"SecureBackupSetupViewChangePreview",
"SelectedUserCannotRemovePreview",
"TextComposerAddCaptionPreview",
"TextComposerCaptionPreview",
"TextComposerEditPreview",
"TextComposerEditCaptionPreview",
"TextComposerFormattingPreview",
"TextComposerLinkDialogCreateLinkPreview",
"TextComposerLinkDialogCreateLinkWithoutTextPreview",

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:9e0ec7e2a9dbe70a2deddf2bd621ac721b03e1b5bd367020fc68485e4d387ba8
size 16610
oid sha256:3250fa9767aec264308719c6fdf6cea94d5ccea21bfa8a735d7df5d79b572555
size 21214

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:a44e40c0d586b41a0e47aa7d40a564eb820c2692051e795c87c460df92e81ca6
size 17739
oid sha256:4ba70221a149ce0f8abaa4e4f870011bea82630edf99ecacea560a737f56346f
size 22215

View file

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

View file

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

View file

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

View file

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

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:78ce6620309ead376b0be9a5e33da0ac935f0d6cc243d45ecfc3c69115388110
size 21248
oid sha256:727085653ecf863579d7fc952bf1d09aab8a41548e087435c1dd7b2b82350910
size 21484

View file

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

View file

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

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:c66fb54a0b0f1c5fc30f2ff0be7a6f5e7d9bdc426f058825495a5c5194cffea6
size 20763
oid sha256:3cfd7c3d4fa0a2a289a15438092cb272d57be91ea221d73865a04b5ddf58b5ed
size 20996

View file

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

View file

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

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:3ce4299e4c585abf3635b37c6003ac50a8ed25ffa0e9258dfbc07d61fb04569f
size 61074
oid sha256:7d1ed9a282d3363fa4e28d924a750d0a46f7564533f687ab102d559e2dd1606b
size 59518

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:035d793ee33682785b98974c6357b99bfb879f8a86cba7117bb2b9c4c4bc13d5
size 49533
oid sha256:9660c7ea6ca5101c6b5fa9e774d4063d4011b9f8fdb64bf8e32cbc89c231531c
size 62605

View file

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

View file

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

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:4ad0d2f2e326fd05997ef115e31971727067543c543883029411b735ce8909ff
size 40004
oid sha256:3ce4299e4c585abf3635b37c6003ac50a8ed25ffa0e9258dfbc07d61fb04569f
size 61074

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:281639101ede7b20bee08efdeac8c346fd13f9a9c4e3f322b6a6f37a417647b4
size 39953
oid sha256:035d793ee33682785b98974c6357b99bfb879f8a86cba7117bb2b9c4c4bc13d5
size 49533

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:7e9412f3e84b63be532188e13a976d1954404466136633f3f8cd41851617e753
size 60668
oid sha256:76faea3ec5b9e01a7e1ad77dea4f44b060bee26cf1d0b56df8eabb692cf764df
size 59225

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:1da55e4b116cbf0ebd74afee2a47d34b99d87192cdf9a5f8698118fcd4bbe239
size 45466
oid sha256:0f9df03bc6cba3fbf8ae454768064c9141b05e319c40c568a0a3edf3a53fed4e
size 61961

View file

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

View file

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

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:48c4460c9b57f0a438c9d0ae268eaa3823bddf3f1054e5c6b5caa64c6d075c6c
size 37392
oid sha256:7e9412f3e84b63be532188e13a976d1954404466136633f3f8cd41851617e753
size 60668

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:8226bbd316c5c4ac01f5660b262623be32ec0a274c696c7a547c8baf59bf069b
size 37171
oid sha256:1da55e4b116cbf0ebd74afee2a47d34b99d87192cdf9a5f8698118fcd4bbe239
size 45466

View file

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

View file

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

View file

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

View file

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