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 b37cabd703
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.reactionsummary.ReactionSummaryState
import io.element.android.features.messages.impl.timeline.components.receipt.bottomsheet.ReadReceiptBottomSheetState 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.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.TimelineItemPollContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemStateContent import io.element.android.features.messages.impl.timeline.model.event.TimelineItemStateContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemTextBasedContent import io.element.android.features.messages.impl.timeline.model.event.TimelineItemTextBasedContent
@ -273,6 +274,9 @@ class MessagesPresenter @AssistedInject constructor(
TimelineItemAction.CopyLink -> handleCopyLink(targetEvent) TimelineItemAction.CopyLink -> handleCopyLink(targetEvent)
TimelineItemAction.Redact -> handleActionRedact(targetEvent) TimelineItemAction.Redact -> handleActionRedact(targetEvent)
TimelineItemAction.Edit -> handleActionEdit(targetEvent, composerState, enableTextFormatting) TimelineItemAction.Edit -> handleActionEdit(targetEvent, composerState, enableTextFormatting)
TimelineItemAction.AddCaption -> handleActionAddCaption(targetEvent, composerState)
TimelineItemAction.EditCaption -> handleActionEditCaption(targetEvent, composerState)
TimelineItemAction.RemoveCaption -> handleRemoveCaption(targetEvent)
TimelineItemAction.Reply, TimelineItemAction.Reply,
TimelineItemAction.ReplyInThread -> handleActionReply(targetEvent, composerState, timelineProtectionState) TimelineItemAction.ReplyInThread -> handleActionReply(targetEvent, composerState, timelineProtectionState)
TimelineItemAction.ViewSource -> handleShowDebugInfoAction(targetEvent) 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) { private suspend fun handlePinAction(targetEvent: TimelineItem.Event) {
if (targetEvent.eventId == null) return if (targetEvent.eventId == null) return
analyticsService.capture( 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( private suspend fun handleActionReply(
targetEvent: TimelineItem.Event, targetEvent: TimelineItem.Event,
composerState: MessageComposerState, 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.actionlist.anActionListState
import io.element.android.features.messages.impl.crypto.identity.IdentityChangeState 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.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.MessageComposerState
import io.element.android.features.messages.impl.messagecomposer.aMessageComposerState import io.element.android.features.messages.impl.messagecomposer.aMessageComposerState
import io.element.android.features.messages.impl.pinned.banner.PinnedMessagesBannerState import io.element.android.features.messages.impl.pinned.banner.PinnedMessagesBannerState
@ -62,16 +61,6 @@ open class MessagesStateProvider : PreviewParameterProvider<MessagesState> {
enableVoiceMessages = true, enableVoiceMessages = true,
voiceMessageComposerState = aVoiceMessageComposerState(showPermissionRationaleDialog = true), voiceMessageComposerState = aVoiceMessageComposerState(showPermissionRationaleDialog = true),
), ),
aMessagesState(
composerState = aMessageComposerState(
attachmentsState = AttachmentsState.Sending.Processing(persistentListOf())
),
),
aMessagesState(
composerState = aMessageComposerState(
attachmentsState = AttachmentsState.Sending.Uploading(0.33f)
),
),
aMessagesState( aMessagesState(
roomCallState = anOngoingCallState(), 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.features.roomcall.api.RoomCallState
import io.element.android.libraries.androidutils.ui.hideKeyboard import io.element.android.libraries.androidutils.ui.hideKeyboard
import io.element.android.libraries.designsystem.atomic.molecules.IconTitlePlaceholdersRowMolecule 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.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.components.avatar.CompositeAvatar import io.element.android.libraries.designsystem.components.avatar.CompositeAvatar
@ -134,7 +132,6 @@ fun MessagesView(
AttachmentStateView( AttachmentStateView(
state = state.composerState.attachmentsState, state = state.composerState.attachmentsState,
onPreviewAttachments = onPreviewAttachments, onPreviewAttachments = onPreviewAttachments,
onCancel = { state.composerState.eventSink(MessageComposerEvents.CancelSendAttachment) },
) )
val snackbarHostState = rememberSnackbarHostState(snackbarMessage = state.snackbarMessage) val snackbarHostState = rememberSnackbarHostState(snackbarMessage = state.snackbarMessage)
@ -280,7 +277,6 @@ private fun ReinviteDialog(state: MessagesState) {
private fun AttachmentStateView( private fun AttachmentStateView(
state: AttachmentsState, state: AttachmentsState,
onPreviewAttachments: (ImmutableList<Attachment>) -> Unit, onPreviewAttachments: (ImmutableList<Attachment>) -> Unit,
onCancel: () -> Unit,
) { ) {
when (state) { when (state) {
AttachmentsState.None -> Unit AttachmentsState.None -> Unit
@ -290,17 +286,6 @@ private fun AttachmentStateView(
latestOnPreviewAttachments(state.attachments) 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.TimelineItem
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemCallNotifyContent 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.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.TimelineItemLegacyCallInviteContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemPollContent 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.TimelineItemRedactedContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemStateContent 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.canBeCopied
import io.element.android.features.messages.impl.timeline.model.event.canBeForwarded import io.element.android.features.messages.impl.timeline.model.event.canBeForwarded
import io.element.android.features.messages.impl.timeline.model.event.canReact import io.element.android.features.messages.impl.timeline.model.event.canReact
@ -154,6 +156,18 @@ class DefaultActionListPresenter @AssistedInject constructor(
} }
if (timelineItem.isEditable) { if (timelineItem.isEditable) {
add(TimelineItemAction.Edit) 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) { if (canRedact && timelineItem.content is TimelineItemPollContent && !timelineItem.content.isEnded) {
add(TimelineItemAction.EndPoll) 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 Reply : TimelineItemAction(CommonStrings.action_reply, CompoundDrawables.ic_compound_reply)
data object ReplyInThread : TimelineItemAction(CommonStrings.action_reply_in_thread, 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 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 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 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) 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 androidx.compose.runtime.Immutable
import io.element.android.features.messages.impl.attachments.Attachment 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 import io.element.android.libraries.textcomposer.model.TextEditorState
data class AttachmentsPreviewState( data class AttachmentsPreviewState(
@ -20,9 +17,8 @@ data class AttachmentsPreviewState(
val textEditorState: TextEditorState, val textEditorState: TextEditorState,
val eventSink: (AttachmentsPreviewEvents) -> Unit val eventSink: (AttachmentsPreviewEvents) -> Unit
) { ) {
val allowCaption: Boolean = (attachment as? Attachment.Media)?.localMedia?.info?.mimeType?.let { // Keep the val to eventually set to false for some mimetypes.
it.isMimeTypeImage() || it.isMimeTypeVideo() val allowCaption: Boolean = true
}.orFalse()
} }
@Immutable @Immutable

View file

@ -31,7 +31,6 @@ sealed interface MessageComposerEvents {
data object Poll : PickAttachmentSource data object Poll : PickAttachmentSource
} }
data class ToggleTextFormatting(val enabled: Boolean) : MessageComposerEvents data class ToggleTextFormatting(val enabled: Boolean) : MessageComposerEvents
data object CancelSendAttachment : MessageComposerEvents
data class Error(val error: Throwable) : MessageComposerEvents data class Error(val error: Throwable) : MessageComposerEvents
data class TypingNotice(val isTyping: Boolean) : MessageComposerEvents data class TypingNotice(val isTyping: Boolean) : MessageComposerEvents
data class SuggestionReceived(val suggestion: Suggestion?) : 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.di.SingleIn
import io.element.android.libraries.featureflag.api.FeatureFlagService import io.element.android.libraries.featureflag.api.FeatureFlagService
import io.element.android.libraries.featureflag.api.FeatureFlags 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.core.UserId
import io.element.android.libraries.matrix.api.permalink.PermalinkBuilder import io.element.android.libraries.matrix.api.permalink.PermalinkBuilder
import io.element.android.libraries.matrix.api.permalink.PermalinkData 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.CancellationException
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.FlowPreview import kotlinx.coroutines.FlowPreview
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.collect import kotlinx.coroutines.flow.collect
@ -89,11 +87,9 @@ import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.debounce import kotlinx.coroutines.flow.debounce
import kotlinx.coroutines.flow.filter import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.flow.merge import kotlinx.coroutines.flow.merge
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import timber.log.Timber import timber.log.Timber
import javax.inject.Inject import javax.inject.Inject
import kotlin.coroutines.coroutineContext
import kotlin.time.Duration.Companion.seconds import kotlin.time.Duration.Companion.seconds
import io.element.android.libraries.core.mimetype.MimeTypes.Any as AnyMimeTypes import io.element.android.libraries.core.mimetype.MimeTypes.Any as AnyMimeTypes
@ -180,26 +176,12 @@ class MessageComposerPresenter @Inject constructor(
val isFullScreen = rememberSaveable { val isFullScreen = rememberSaveable {
mutableStateOf(false) mutableStateOf(false)
} }
val ongoingSendAttachmentJob = remember { mutableStateOf<Job?>(null) }
var showAttachmentSourcePicker: Boolean by remember { mutableStateOf(false) } var showAttachmentSourcePicker: Boolean by remember { mutableStateOf(false) }
val sendTypingNotifications by sessionPreferencesStore.isSendTypingNotificationsEnabled().collectAsState(initial = true) val sendTypingNotifications by sessionPreferencesStore.isSendTypingNotificationsEnabled().collectAsState(initial = true)
val roomAliasSuggestions by roomAliasSuggestionsDataSource.getAllRoomAliasSuggestions().collectAsState(initial = emptyList()) 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) { LaunchedEffect(cameraPermissionState.permissionGranted) {
if (cameraPermissionState.permissionGranted) { if (cameraPermissionState.permissionGranted) {
when (pendingEvent) { when (pendingEvent) {
@ -272,7 +254,7 @@ class MessageComposerPresenter @Inject constructor(
when (event) { when (event) {
MessageComposerEvents.ToggleFullScreenState -> isFullScreen.value = !isFullScreen.value MessageComposerEvents.ToggleFullScreenState -> isFullScreen.value = !isFullScreen.value
MessageComposerEvents.CloseSpecialMode -> { MessageComposerEvents.CloseSpecialMode -> {
if (messageComposerContext.composerMode is MessageComposerMode.Edit) { if (messageComposerContext.composerMode.isEditing) {
localCoroutineScope.launch { localCoroutineScope.launch {
resetComposer(markdownTextEditorState, richTextEditorState, fromEdit = true) resetComposer(markdownTextEditorState, richTextEditorState, fromEdit = true)
} }
@ -338,12 +320,6 @@ class MessageComposerPresenter @Inject constructor(
showAttachmentSourcePicker = false showAttachmentSourcePicker = false
// Navigation to the create poll screen is done at the view layer // 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 -> { is MessageComposerEvents.ToggleTextFormatting -> {
showAttachmentSourcePicker = false showAttachmentSourcePicker = false
localCoroutineScope.toggleTextFormatting(event.enabled, markdownTextEditorState, richTextEditorState) 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 -> { is MessageComposerMode.Reply -> {
timelineController.invokeOnCurrentTimeline { timelineController.invokeOnCurrentTimeline {
replyMessage(capturedMode.eventId, message.markdown, message.html, message.intentionalMentions) replyMessage(capturedMode.eventId, message.markdown, message.html, message.intentionalMentions)
@ -505,17 +489,7 @@ class MessageComposerPresenter @Inject constructor(
formattedFileSize = null formattedFileSize = null
) )
val mediaAttachment = Attachment.Media(localMedia) val mediaAttachment = Attachment.Media(localMedia)
val isPreviewable = when { attachmentsState.value = AttachmentsState.Previewing(persistentListOf(mediaAttachment))
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))
}
} }
private suspend fun sendMedia( private suspend fun sendMedia(
@ -523,18 +497,10 @@ class MessageComposerPresenter @Inject constructor(
mimeType: String, mimeType: String,
attachmentState: MutableState<AttachmentsState>, attachmentState: MutableState<AttachmentsState>,
) = runCatching { ) = 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( mediaSender.sendMedia(
uri = uri, uri = uri,
mimeType = mimeType, mimeType = mimeType,
progressCallback = progressCallback progressCallback = null,
).getOrThrow() ).getOrThrow()
} }
.onSuccess { .onSuccess {
@ -612,6 +578,10 @@ class MessageComposerPresenter @Inject constructor(
mode.eventOrTransactionId.eventId?.let { eventId -> ComposerDraftType.Edit(eventId) } mode.eventOrTransactionId.eventId?.let { eventId -> ComposerDraftType.Edit(eventId) }
} }
is MessageComposerMode.Reply -> ComposerDraftType.Reply(mode.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()) { return if (draftType == null || message.markdown.isBlank()) {
null null
@ -686,7 +656,14 @@ class MessageComposerPresenter @Inject constructor(
val currentComposerMode = messageComposerContext.composerMode val currentComposerMode = messageComposerContext.composerMode
when (newComposerMode) { when (newComposerMode) {
is MessageComposerMode.Edit -> { 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) val draft = createDraftFromState(markdownTextEditorState, richTextEditorState)
updateDraft(draft, isVolatile = true).join() updateDraft(draft, isVolatile = true).join()
} }
@ -694,7 +671,7 @@ class MessageComposerPresenter @Inject constructor(
} }
else -> { else -> {
// When coming from edit, just clear the composer as it'd be weird to reset a volatile draft in this scenario. // 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) setText("", markdownTextEditorState, richTextEditorState)
} }
} }

View file

@ -35,8 +35,4 @@ data class MessageComposerState(
sealed interface AttachmentsState { sealed interface AttachmentsState {
data object None : AttachmentsState data object None : AttachmentsState
data class Previewing(val attachments: ImmutableList<Attachment>) : 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 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.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.GraphicEq import androidx.compose.material.icons.outlined.GraphicEq
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
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.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.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.components.layout.ContentAvoidingLayoutData
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemAudioContent 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.features.messages.impl.timeline.model.event.TimelineItemAudioContentProvider
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.Icon import io.element.android.libraries.designsystem.theme.components.Icon
import io.element.android.libraries.designsystem.theme.components.Text
@Composable @Composable
fun TimelineItemAudioView( fun TimelineItemAudioView(
@ -40,18 +28,13 @@ fun TimelineItemAudioView(
onContentLayoutChange: (ContentAvoidingLayoutData) -> Unit, onContentLayoutChange: (ContentAvoidingLayoutData) -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
val iconSize = 32.dp TimelineItemAttachmentView(
val spacing = 8.dp filename = content.filename,
Row( fileExtensionAndSize = content.fileExtensionAndSize,
caption = content.caption,
onContentLayoutChange = onContentLayoutChange,
modifier = modifier, modifier = modifier,
) { icon = {
Box(
modifier = Modifier
.size(iconSize)
.clip(CircleShape)
.background(ElementTheme.materialColors.background),
contentAlignment = Alignment.Center,
) {
Icon( Icon(
imageVector = Icons.Outlined.GraphicEq, imageVector = Icons.Outlined.GraphicEq,
contentDescription = null, contentDescription = null,
@ -60,28 +43,7 @@ fun TimelineItemAudioView(
.size(16.dp), .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 @PreviewsDayNight

View file

@ -7,24 +7,13 @@
package io.element.android.features.messages.impl.timeline.components.event 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.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
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.rotate import androidx.compose.ui.draw.rotate
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.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.components.layout.ContentAvoidingLayoutData
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemFileContent import io.element.android.features.messages.impl.timeline.model.event.TimelineItemFileContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemFileContentProvider 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.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.Icon import io.element.android.libraries.designsystem.theme.components.Icon
import io.element.android.libraries.designsystem.theme.components.Text
@Composable @Composable
fun TimelineItemFileView( fun TimelineItemFileView(
@ -40,18 +28,13 @@ fun TimelineItemFileView(
onContentLayoutChange: (ContentAvoidingLayoutData) -> Unit, onContentLayoutChange: (ContentAvoidingLayoutData) -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
val iconSize = 32.dp TimelineItemAttachmentView(
val spacing = 8.dp filename = content.filename,
Row( fileExtensionAndSize = content.fileExtensionAndSize,
caption = content.caption,
onContentLayoutChange = onContentLayoutChange,
modifier = modifier, modifier = modifier,
) { icon = {
Box(
modifier = Modifier
.size(iconSize)
.clip(CircleShape)
.background(ElementTheme.materialColors.background),
contentAlignment = Alignment.Center,
) {
Icon( Icon(
resourceId = CompoundDrawables.ic_compound_attachment, resourceId = CompoundDrawables.ic_compound_attachment,
contentDescription = null, contentDescription = null,
@ -61,28 +44,7 @@ fun TimelineItemFileView(
.rotate(-45f), .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 @PreviewsDayNight

View file

@ -17,13 +17,18 @@ open class TimelineItemAudioContentProvider : PreviewParameterProvider<TimelineI
get() = sequenceOf( get() = sequenceOf(
aTimelineItemAudioContent("A sound.mp3"), aTimelineItemAudioContent("A sound.mp3"),
aTimelineItemAudioContent("A bigger name 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, filename = fileName,
caption = null, caption = caption,
formattedCaption = null, formattedCaption = null,
isEdited = false, isEdited = false,
mimeType = MimeTypes.Mp3, mimeType = MimeTypes.Mp3,

View file

@ -16,15 +16,18 @@ open class TimelineItemFileContentProvider : PreviewParameterProvider<TimelineIt
get() = sequenceOf( get() = sequenceOf(
aTimelineItemFileContent(), aTimelineItemFileContent(),
aTimelineItemFileContent("A bigger name file.pdf"), 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( fun aTimelineItemFileContent(
fileName: String = "A file.pdf", fileName: String = "A file.pdf",
caption: String? = null,
) = TimelineItemFileContent( ) = TimelineItemFileContent(
filename = fileName, filename = fileName,
caption = null, caption = caption,
formattedCaption = null, formattedCaption = null,
isEdited = false, isEdited = false,
thumbnailSource = null, 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.TimelineItemImageContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemTextContent 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.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.aTimelineItemPollContent
import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemTextContent import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemTextContent
import io.element.android.features.messages.impl.timeline.protection.aTimelineProtectionState 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.api.timeline.item.event.toEventOrTransactionId
import io.element.android.libraries.matrix.test.AN_AVATAR_URL 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.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_ROOM_ID
import io.element.android.libraries.matrix.test.A_SESSION_ID import io.element.android.libraries.matrix.test.A_SESSION_ID
import io.element.android.libraries.matrix.test.A_SESSION_ID_2 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.lambdaError
import io.element.android.tests.testutils.lambda.lambdaRecorder import io.element.android.tests.testutils.lambda.lambdaRecorder
import io.element.android.tests.testutils.lambda.value import io.element.android.tests.testutils.lambda.value
import io.element.android.tests.testutils.test
import io.element.android.tests.testutils.testCoroutineDispatchers import io.element.android.tests.testutils.testCoroutineDispatchers
import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.persistentListOf
import kotlinx.coroutines.ExperimentalCoroutinesApi 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( private fun TestScope.createMessagesPresenter(
coroutineDispatchers: CoroutineDispatchers = testCoroutineDispatchers(), coroutineDispatchers: CoroutineDispatchers = testCoroutineDispatchers(),
matrixRoom: MatrixRoom = FakeMatrixRoom( 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.room.MatrixRoom
import io.element.android.libraries.matrix.api.timeline.item.event.LocalEventSendState 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.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_MESSAGE
import io.element.android.libraries.matrix.test.A_USER_ID 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.FakeMatrixRoom
import io.element.android.libraries.matrix.test.room.aRoomInfo import io.element.android.libraries.matrix.test.room.aRoomInfo
import io.element.android.libraries.preferences.test.InMemoryAppPreferencesStore import io.element.android.libraries.preferences.test.InMemoryAppPreferencesStore
import io.element.android.tests.testutils.WarmUpRule import io.element.android.tests.testutils.WarmUpRule
import io.element.android.tests.testutils.test
import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.persistentListOf
import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.runTest
import org.junit.Rule 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 @Test
fun `present - compute for others message cannot sent message`() = runTest { fun `present - compute for others message cannot sent message`() = runTest {
val presenter = createActionListPresenter(isDeveloperModeEnabled = true, isPinFeatureEnabled = true) 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 @Test
fun `present - compute for my message cannot redact`() = runTest { fun `present - compute for my message cannot redact`() = runTest {
val presenter = createActionListPresenter(isDeveloperModeEnabled = true, isPinFeatureEnabled = true) 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() val successState = awaitItem()
assertThat(successState.target).isEqualTo( assertThat(successState.target).isEqualTo(
ActionListState.Target.Success( ActionListState.Target.Success(
@ -455,6 +545,56 @@ class ActionListPresenterTest {
actions = persistentListOf( actions = persistentListOf(
TimelineItemAction.Reply, TimelineItemAction.Reply,
TimelineItemAction.Forward, 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.Pin,
TimelineItemAction.CopyLink, TimelineItemAction.CopyLink,
TimelineItemAction.ViewSource, 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.features.messages.impl.fixtures.aMediaAttachment
import io.element.android.libraries.androidutils.file.TemporaryUriDeleter import io.element.android.libraries.androidutils.file.TemporaryUriDeleter
import io.element.android.libraries.matrix.api.core.ProgressCallback 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.FileInfo
import io.element.android.libraries.matrix.api.media.ImageInfo 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.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.any
import io.element.android.tests.testutils.lambda.lambdaRecorder import io.element.android.tests.testutils.lambda.lambdaRecorder
import io.element.android.tests.testutils.lambda.value import io.element.android.tests.testutils.lambda.value
import io.element.android.tests.testutils.test
import io.mockk.mockk import io.mockk.mockk
import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.advanceUntilIdle import kotlinx.coroutines.test.advanceUntilIdle
@ -60,7 +62,7 @@ class AttachmentsPreviewPresenterTest {
@Test @Test
fun `present - send media success scenario`() = runTest { 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()) Result.success(FakeMediaUploadHandler())
} }
val room = FakeMatrixRoom( 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 @Test
fun `present - send media failure scenario`() = runTest { fun `present - send media failure scenario`() = runTest {
val failure = MediaPreProcessor.Failure(null) 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) Result.failure(failure)
} }
val room = FakeMatrixRoom( 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.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.ProgressCallback
import io.element.android.libraries.matrix.api.core.RoomId 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.ImageInfo
import io.element.android.libraries.matrix.api.media.VideoInfo import io.element.android.libraries.matrix.api.media.VideoInfo
import io.element.android.libraries.matrix.api.permalink.PermalinkBuilder import io.element.android.libraries.matrix.api.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.api.timeline.item.event.toEventOrTransactionId
import io.element.android.libraries.matrix.test.ANOTHER_MESSAGE 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.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_MESSAGE
import io.element.android.libraries.matrix.test.A_REPLY import io.element.android.libraries.matrix.test.A_REPLY
import io.element.android.libraries.matrix.test.A_ROOM_ID 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_3
import io.element.android.libraries.matrix.test.A_USER_ID_4 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.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.FakePermalinkBuilder
import io.element.android.libraries.matrix.test.permalink.FakePermalinkParser import io.element.android.libraries.matrix.test.permalink.FakePermalinkParser
import io.element.android.libraries.matrix.test.room.FakeMatrixRoom 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.assert
import io.element.android.tests.testutils.lambda.lambdaRecorder import io.element.android.tests.testutils.lambda.lambdaRecorder
import io.element.android.tests.testutils.lambda.value import io.element.android.tests.testutils.lambda.value
import io.element.android.tests.testutils.test
import io.element.android.tests.testutils.waitForPredicate import io.element.android.tests.testutils.waitForPredicate
import io.mockk.mockk import io.mockk.mockk
import kotlinx.collections.immutable.persistentListOf 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 @Test
fun `present - change mode to reply after edit`() = runTest { fun `present - change mode to reply after edit`() = runTest {
val loadDraftLambda = lambdaRecorder { _: RoomId, _: Boolean -> val loadDraftLambda = lambdaRecorder { _: RoomId, _: Boolean ->
@ -684,17 +768,8 @@ class MessageComposerPresenterTest {
} }
@Test @Test
fun `present - Pick file from storage`() = runTest { fun `present - Pick file from storage will open the preview`() = runTest {
val sendFileResult = lambdaRecorder<File, FileInfo, ProgressCallback?, Result<FakeMediaUploadHandler>> { _, _, _ ->
Result.success(FakeMediaUploadHandler())
}
val room = FakeMatrixRoom( val room = FakeMatrixRoom(
progressCallbackValues = listOf(
Pair(0, 10),
Pair(5, 10),
Pair(10, 10)
),
sendFileResult = sendFileResult,
typingNoticeResult = { Result.success(Unit) } typingNoticeResult = { Result.success(Unit) }
) )
val presenter = createPresenter(this, room = room) val presenter = createPresenter(this, room = room)
@ -705,13 +780,7 @@ class MessageComposerPresenterTest {
initialState.eventSink(MessageComposerEvents.PickAttachmentSource.FromFiles) initialState.eventSink(MessageComposerEvents.PickAttachmentSource.FromFiles)
val sendingState = awaitItem() val sendingState = awaitItem()
assertThat(sendingState.showAttachmentSourcePicker).isFalse() assertThat(sendingState.showAttachmentSourcePicker).isFalse()
assertThat(sendingState.attachmentsState).isInstanceOf(AttachmentsState.Sending.Processing::class.java) assertThat(sendingState.attachmentsState).isInstanceOf(AttachmentsState.Previewing::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()
} }
} }
@ -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 @Test
fun `present - errors are tracked`() = runTest { fun `present - errors are tracked`() = runTest {
val testException = Exception("Test error") val testException = Exception("Test error")
@ -1487,17 +1514,17 @@ class MessageComposerPresenterTest {
isRichTextEditorEnabled: Boolean = true, isRichTextEditorEnabled: Boolean = true,
draftService: ComposerDraftService = FakeComposerDraftService(), draftService: ComposerDraftService = FakeComposerDraftService(),
) = MessageComposerPresenter( ) = MessageComposerPresenter(
coroutineScope, appCoroutineScope = coroutineScope,
room, room = room,
pickerProvider, mediaPickerProvider = pickerProvider,
featureFlagService, featureFlagService = featureFlagService,
sessionPreferencesStore, sessionPreferencesStore = sessionPreferencesStore,
localMediaFactory, localMediaFactory = localMediaFactory,
MediaSender(mediaPreProcessor, room, InMemorySessionPreferencesStore()), mediaSender = MediaSender(mediaPreProcessor, room, InMemorySessionPreferencesStore()),
snackbarDispatcher, snackbarDispatcher = snackbarDispatcher,
analyticsService, analyticsService = analyticsService,
DefaultMessageComposerContext(), messageComposerContext = DefaultMessageComposerContext(),
TestRichTextEditorStateFactory(), richTextEditorStateFactory = TestRichTextEditorStateFactory(),
roomAliasSuggestionsDataSource = FakeRoomAliasSuggestionsDataSource(), roomAliasSuggestionsDataSource = FakeRoomAliasSuggestionsDataSource(),
permissionsPresenterFactory = FakePermissionsPresenterFactory(permissionPresenter), permissionsPresenterFactory = FakePermissionsPresenterFactory(permissionPresenter),
permalinkParser = permalinkParser, permalinkParser = permalinkParser,
@ -1525,6 +1552,11 @@ fun anEditMode(
message: String = A_MESSAGE, message: String = A_MESSAGE,
) = MessageComposerMode.Edit(eventOrTransactionId, 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( fun aReplyMode() = MessageComposerMode.Reply(
replyToDetails = InReplyToDetails.Loading(AN_EVENT_ID), replyToDetails = InReplyToDetails.Loading(AN_EVENT_ID),
hideImage = false, hideImage = false,

View file

@ -116,7 +116,7 @@ class SharePresenterTest {
@Test @Test
fun `present - send media ok`() = runTest { 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()) Result.success(FakeMediaUploadHandler())
} }
val matrixRoom = FakeMatrixRoom( val matrixRoom = FakeMatrixRoom(

View file

@ -147,9 +147,21 @@ interface MatrixRoom : Closeable {
progressCallback: ProgressCallback? progressCallback: ProgressCallback?
): Result<MediaUploadHandler> ): 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> suspend fun toggleReaction(emoji: String, eventOrTransactionId: EventOrTransactionId): Result<Unit>

View file

@ -59,10 +59,17 @@ interface Timeline : AutoCloseable {
suspend fun editMessage( suspend fun editMessage(
eventOrTransactionId: EventOrTransactionId, eventOrTransactionId: EventOrTransactionId,
body: String, htmlBody: String?, body: String,
htmlBody: String?,
intentionalMentions: List<IntentionalMention>, intentionalMentions: List<IntentionalMention>,
): Result<Unit> ): Result<Unit>
suspend fun editCaption(
eventOrTransactionId: EventOrTransactionId,
caption: String?,
formattedCaption: String?,
): Result<Unit>
suspend fun replyMessage( suspend fun replyMessage(
eventId: EventId, eventId: EventId,
body: String, body: String,
@ -91,9 +98,21 @@ interface Timeline : AutoCloseable {
suspend fun redactEvent(eventOrTransactionId: EventOrTransactionId, reason: String?): Result<Unit> 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> 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_HTTP_CLIENT("matrix_sdk::http_client"),
MATRIX_SDK_CLIENT("matrix_sdk::client"), MATRIX_SDK_CLIENT("matrix_sdk::client"),
MATRIX_SDK_OIDC("matrix_sdk::oidc"), MATRIX_SDK_OIDC("matrix_sdk::oidc"),
MATRIX_SDK_SEND_QUEUE("matrix_sdk::send_queue"),
MATRIX_SDK_SLIDING_SYNC("matrix_sdk::sliding_sync"), MATRIX_SDK_SLIDING_SYNC("matrix_sdk::sliding_sync"),
MATRIX_SDK_BASE_SLIDING_SYNC("matrix_sdk_base::sliding_sync"), MATRIX_SDK_BASE_SLIDING_SYNC("matrix_sdk_base::sliding_sync"),
MATRIX_SDK_UI_TIMELINE("matrix_sdk_ui::timeline"), 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) return liveTimeline.sendVideo(file, thumbnailFile, videoInfo, caption, formattedCaption, progressCallback)
} }
override suspend fun sendAudio(file: File, audioInfo: AudioInfo, progressCallback: ProgressCallback?): Result<MediaUploadHandler> { override suspend fun sendAudio(
return liveTimeline.sendAudio(file, audioInfo, progressCallback) 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> { override suspend fun sendFile(
return liveTimeline.sendFile(file, fileInfo, progressCallback) 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> { override suspend fun toggleReaction(emoji: String, eventOrTransactionId: EventOrTransactionId): Result<Unit> {

View file

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

View file

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

View file

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

View file

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

View file

@ -91,7 +91,7 @@ class MediaSenderTest {
@OptIn(ExperimentalCoroutinesApi::class) @OptIn(ExperimentalCoroutinesApi::class)
@Test @Test
fun `given a cancellation in the media upload when sending the job is cancelled`() = runTest(StandardTestDispatcher()) { 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()) Result.success(FakeMediaUploadHandler())
} }
val room = FakeMatrixRoom( val room = FakeMatrixRoom(

View file

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

View file

@ -121,19 +121,25 @@ fun TextComposer(
} }
val layoutModifier = modifier val layoutModifier = modifier
.fillMaxSize() .fillMaxSize()
.height(IntrinsicSize.Min) .height(IntrinsicSize.Min)
val composerOptionsButton: @Composable () -> Unit = remember { val composerOptionsButton: @Composable () -> Unit = remember(composerMode) {
@Composable { @Composable {
if (composerMode is MessageComposerMode.Attachment) { when (composerMode) {
Spacer(modifier = Modifier.width(9.dp)) is MessageComposerMode.Attachment -> {
} else { Spacer(modifier = Modifier.width(9.dp))
ComposerOptionsButton( }
modifier = Modifier is MessageComposerMode.EditCaption -> {
.size(48.dp), Spacer(modifier = Modifier.width(16.dp))
onClick = onAddAttachment }
) 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) { 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()
@ -342,8 +348,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()
} }
@ -356,16 +362,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()
@ -387,8 +393,8 @@ private fun TextFormattingLayout(
) { ) {
Box( Box(
modifier = Modifier modifier = Modifier
.weight(1f) .weight(1f)
.padding(horizontal = 12.dp) .padding(horizontal = 12.dp)
) { ) {
textInput() textInput()
} }
@ -432,11 +438,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(
@ -447,9 +453,9 @@ 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
@ -495,8 +501,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,
@ -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 @PreviewsDayNight
@Composable @Composable
internal fun MarkdownTextComposerEditPreview() = ElementPreview { internal fun MarkdownTextComposerEditPreview() = ElementPreview {
@ -717,6 +759,14 @@ fun aMessageComposerModeEdit(
content = content content = content
) )
fun aMessageComposerModeEditCaption(
eventOrTransactionId: EventOrTransactionId = EventId("$1234").toEventOrTransactionId(),
content: String,
) = MessageComposerMode.EditCaption(
eventOrTransactionId = eventOrTransactionId,
content = content
)
fun aMessageComposerModeReply( fun aMessageComposerModeReply(
replyToDetails: InReplyToDetails, replyToDetails: InReplyToDetails,
hideImage: Boolean = false, hideImage: Boolean = false,

View file

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

View file

@ -27,6 +27,11 @@ sealed interface MessageComposerMode {
val content: String val content: String
) : Special ) : Special
data class EditCaption(
val eventOrTransactionId: EventOrTransactionId,
val content: String
) : Special
data class Reply( data class Reply(
val replyToDetails: InReplyToDetails, val replyToDetails: InReplyToDetails,
val hideImage: Boolean, val hideImage: Boolean,
@ -34,16 +39,8 @@ sealed interface MessageComposerMode {
val eventId: EventId = replyToDetails.eventId() 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 val isEditing: Boolean
get() = this is Edit get() = this is Edit || this is EditCaption
val isReply: Boolean val isReply: Boolean
get() = this is Reply 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_record">"Record voice message."</string>
<string name="a11y_voice_message_stop_recording">"Stop recording"</string> <string name="a11y_voice_message_stop_recording">"Stop recording"</string>
<string name="action_accept">"Accept"</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_add_to_timeline">"Add to timeline"</string>
<string name="action_back">"Back"</string> <string name="action_back">"Back"</string>
<string name="action_call">"Call"</string> <string name="action_call">"Call"</string>
@ -57,6 +58,7 @@
<string name="action_discard">"Discard"</string> <string name="action_discard">"Discard"</string>
<string name="action_done">"Done"</string> <string name="action_done">"Done"</string>
<string name="action_edit">"Edit"</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_edit_poll">"Edit poll"</string>
<string name="action_enable">"Enable"</string> <string name="action_enable">"Enable"</string>
<string name="action_end_poll">"End poll"</string> <string name="action_end_poll">"End poll"</string>
@ -91,6 +93,7 @@
<string name="action_react">"React"</string> <string name="action_react">"React"</string>
<string name="action_reject">"Reject"</string> <string name="action_reject">"Reject"</string>
<string name="action_remove">"Remove"</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">"Reply"</string>
<string name="action_reply_in_thread">"Reply in thread"</string> <string name="action_reply_in_thread">"Reply in thread"</string>
<string name="action_report_bug">"Report bug"</string> <string name="action_report_bug">"Report bug"</string>
@ -123,6 +126,7 @@
<string name="action_yes">"Yes"</string> <string name="action_yes">"Yes"</string>
<string name="common_about">"About"</string> <string name="common_about">"About"</string>
<string name="common_acceptable_use_policy">"Acceptable use policy"</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_advanced_settings">"Advanced settings"</string>
<string name="common_analytics">"Analytics"</string> <string name="common_analytics">"Analytics"</string>
<string name="common_appearance">"Appearance"</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_do_not_show_this_again">"Do not show this again"</string>
<string name="common_edited_suffix">"(edited)"</string> <string name="common_edited_suffix">"(edited)"</string>
<string name="common_editing">"Editing"</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_emote">"* %1$s %2$s"</string>
<string name="common_encryption">"Encryption"</string> <string name="common_encryption">"Encryption"</string>
<string name="common_encryption_enabled">"Encryption enabled"</string> <string name="common_encryption_enabled">"Encryption enabled"</string>

View file

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

View file

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

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:a44e40c0d586b41a0e47aa7d40a564eb820c2692051e795c87c460df92e81ca6 oid sha256:4ba70221a149ce0f8abaa4e4f870011bea82630edf99ecacea560a737f56346f
size 17739 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 version https://git-lfs.github.com/spec/v1
oid sha256:78ce6620309ead376b0be9a5e33da0ac935f0d6cc243d45ecfc3c69115388110 oid sha256:727085653ecf863579d7fc952bf1d09aab8a41548e087435c1dd7b2b82350910
size 21248 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 version https://git-lfs.github.com/spec/v1
oid sha256:c66fb54a0b0f1c5fc30f2ff0be7a6f5e7d9bdc426f058825495a5c5194cffea6 oid sha256:3cfd7c3d4fa0a2a289a15438092cb272d57be91ea221d73865a04b5ddf58b5ed
size 20763 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 version https://git-lfs.github.com/spec/v1
oid sha256:3ce4299e4c585abf3635b37c6003ac50a8ed25ffa0e9258dfbc07d61fb04569f oid sha256:7d1ed9a282d3363fa4e28d924a750d0a46f7564533f687ab102d559e2dd1606b
size 61074 size 59518

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:035d793ee33682785b98974c6357b99bfb879f8a86cba7117bb2b9c4c4bc13d5 oid sha256:9660c7ea6ca5101c6b5fa9e774d4063d4011b9f8fdb64bf8e32cbc89c231531c
size 49533 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 version https://git-lfs.github.com/spec/v1
oid sha256:4ad0d2f2e326fd05997ef115e31971727067543c543883029411b735ce8909ff oid sha256:3ce4299e4c585abf3635b37c6003ac50a8ed25ffa0e9258dfbc07d61fb04569f
size 40004 size 61074

View file

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

View file

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

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:1da55e4b116cbf0ebd74afee2a47d34b99d87192cdf9a5f8698118fcd4bbe239 oid sha256:0f9df03bc6cba3fbf8ae454768064c9141b05e319c40c568a0a3edf3a53fed4e
size 45466 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 version https://git-lfs.github.com/spec/v1
oid sha256:48c4460c9b57f0a438c9d0ae268eaa3823bddf3f1054e5c6b5caa64c6d075c6c oid sha256:7e9412f3e84b63be532188e13a976d1954404466136633f3f8cd41851617e753
size 37392 size 60668

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:8226bbd316c5c4ac01f5660b262623be32ec0a274c696c7a547c8baf59bf069b oid sha256:1da55e4b116cbf0ebd74afee2a47d34b99d87192cdf9a5f8698118fcd4bbe239
size 37171 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