Edit / Add / Remove caption

This commit is contained in:
Benoit Marty 2024-11-19 11:55:46 +01:00
parent f353ecdd45
commit d3408c8f25
12 changed files with 246 additions and 69 deletions

View file

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

View file

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

View file

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

View file

@ -254,7 +254,7 @@ class MessageComposerPresenter @Inject constructor(
when (event) {
MessageComposerEvents.ToggleFullScreenState -> isFullScreen.value = !isFullScreen.value
MessageComposerEvents.CloseSpecialMode -> {
if (messageComposerContext.composerMode is MessageComposerMode.Edit) {
if (messageComposerContext.composerMode.isEditing) {
localCoroutineScope.launch {
resetComposer(markdownTextEditorState, richTextEditorState, fromEdit = true)
}
@ -431,7 +431,15 @@ class MessageComposerPresenter @Inject constructor(
}
}
}
is MessageComposerMode.EditCaption -> {
timelineController.invokeOnCurrentTimeline {
editCaption(
capturedMode.eventOrTransactionId,
caption = message.markdown,
formattedCaption = message.html
)
}
}
is MessageComposerMode.Reply -> {
timelineController.invokeOnCurrentTimeline {
replyMessage(capturedMode.eventId, message.markdown, message.html, message.intentionalMentions)
@ -570,6 +578,10 @@ class MessageComposerPresenter @Inject constructor(
mode.eventOrTransactionId.eventId?.let { eventId -> ComposerDraftType.Edit(eventId) }
}
is MessageComposerMode.Reply -> ComposerDraftType.Reply(mode.eventId)
is MessageComposerMode.EditCaption -> {
// TODO Need a new type to save caption in the SDK
null
}
}
return if (draftType == null || message.markdown.isBlank()) {
null
@ -644,7 +656,14 @@ class MessageComposerPresenter @Inject constructor(
val currentComposerMode = messageComposerContext.composerMode
when (newComposerMode) {
is MessageComposerMode.Edit -> {
if (currentComposerMode !is MessageComposerMode.Edit) {
if (currentComposerMode.isEditing.not()) {
val draft = createDraftFromState(markdownTextEditorState, richTextEditorState)
updateDraft(draft, isVolatile = true).join()
}
setText(newComposerMode.content, markdownTextEditorState, richTextEditorState)
}
is MessageComposerMode.EditCaption -> {
if (currentComposerMode.isEditing.not()) {
val draft = createDraftFromState(markdownTextEditorState, richTextEditorState)
updateDraft(draft, isVolatile = true).join()
}
@ -652,7 +671,7 @@ class MessageComposerPresenter @Inject constructor(
}
else -> {
// When coming from edit, just clear the composer as it'd be weird to reset a volatile draft in this scenario.
if (currentComposerMode is MessageComposerMode.Edit) {
if (currentComposerMode.isEditing) {
setText("", markdownTextEditorState, richTextEditorState)
}
}

View file

@ -59,10 +59,17 @@ interface Timeline : AutoCloseable {
suspend fun editMessage(
eventOrTransactionId: EventOrTransactionId,
body: String, htmlBody: String?,
body: String,
htmlBody: String?,
intentionalMentions: List<IntentionalMention>,
): Result<Unit>
suspend fun editCaption(
eventOrTransactionId: EventOrTransactionId,
caption: String?,
formattedCaption: String?,
): Result<Unit>
suspend fun replyMessage(
eventId: EventId,
body: String,

View file

@ -295,8 +295,7 @@ class RustTimeline(
body: String,
htmlBody: String?,
intentionalMentions: List<IntentionalMention>,
): Result<Unit> =
withContext(dispatcher) {
): Result<Unit> = withContext(dispatcher) {
runCatching<Unit> {
val editedContent = EditedContent.RoomMessage(
content = MessageEventContent.from(
@ -312,6 +311,25 @@ class RustTimeline(
}
}
override suspend fun editCaption(
eventOrTransactionId: EventOrTransactionId,
caption: String?,
formattedCaption: String?,
): Result<Unit> = withContext(dispatcher) {
runCatching<Unit> {
val editedContent = EditedContent.MediaCaption(
caption = caption,
formattedCaption = formattedCaption?.let {
FormattedBody(body = it, format = MessageFormat.Html)
},
)
inner.edit(
newContent = editedContent,
eventOrTransactionId = eventOrTransactionId.toRustEventOrTransactionId(),
)
}
}
override suspend fun replyMessage(
eventId: EventId,
body: String,

View file

@ -92,6 +92,24 @@ class FakeTimeline(
intentionalMentions
)
var editCaptionLambda: (
eventOrTransactionId: EventOrTransactionId,
caption: String?,
formattedCaption: String?,
) -> Result<Unit> = { _, _, _ ->
lambdaError()
}
override suspend fun editCaption(
eventOrTransactionId: EventOrTransactionId,
caption: String?,
formattedCaption: String?,
): Result<Unit> = editCaptionLambda(
eventOrTransactionId,
caption,
formattedCaption,
)
var replyMessageLambda: (
eventId: EventId,
body: String,

View file

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

View file

@ -124,11 +124,16 @@ fun TextComposer(
.fillMaxSize()
.height(IntrinsicSize.Min)
val composerOptionsButton: @Composable () -> Unit = remember {
val composerOptionsButton: @Composable () -> Unit = remember(composerMode) {
@Composable {
if (composerMode is MessageComposerMode.Attachment) {
when (composerMode) {
is MessageComposerMode.Attachment -> {
Spacer(modifier = Modifier.width(9.dp))
} else {
}
is MessageComposerMode.EditCaption -> {
Spacer(modifier = Modifier.width(16.dp))
}
else -> {
ComposerOptionsButton(
modifier = Modifier
.size(48.dp),
@ -137,6 +142,7 @@ fun TextComposer(
}
}
}
}
val placeholder = if (composerMode.inThread) {
stringResource(id = CommonStrings.action_reply_in_thread)
@ -573,6 +579,40 @@ internal fun TextComposerEditPreview() = ElementPreview {
}
}
@PreviewsDayNight
@Composable
internal fun TextComposerEditCaptionPreview() = ElementPreview {
PreviewColumn(
items = aTextEditorStateRichList()
) { _, textEditorState ->
ATextComposer(
state = textEditorState,
voiceMessageState = VoiceMessageState.Idle,
composerMode = aMessageComposerModeEditCaption(
content = "A caption",
),
enableVoiceMessages = false,
)
}
}
@PreviewsDayNight
@Composable
internal fun TextComposerAddCaptionPreview() = ElementPreview {
PreviewColumn(
items = aTextEditorStateRichList()
) { _, textEditorState ->
ATextComposer(
state = textEditorState,
voiceMessageState = VoiceMessageState.Idle,
composerMode = aMessageComposerModeEditCaption(
content = "",
),
enableVoiceMessages = false,
)
}
}
@PreviewsDayNight
@Composable
internal fun MarkdownTextComposerEditPreview() = ElementPreview {
@ -717,6 +757,14 @@ fun aMessageComposerModeEdit(
content = content
)
fun aMessageComposerModeEditCaption(
eventOrTransactionId: EventOrTransactionId = EventId("$1234").toEventOrTransactionId(),
content: String = "Some caption",
) = MessageComposerMode.EditCaption(
eventOrTransactionId = eventOrTransactionId,
content = content
)
fun aMessageComposerModeReply(
replyToDetails: InReplyToDetails,
hideImage: Boolean = false,

View file

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

View file

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

View file

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