Draft : refactor a bit ComposerMode and formatting management so we don't mess up with draft restoration.

This commit is contained in:
ganfra 2024-06-25 11:35:22 +02:00
parent 9f45005c05
commit dfae2e50c9
7 changed files with 87 additions and 72 deletions

View file

@ -379,8 +379,8 @@ class SendLocationPresenterTest {
fakeMessageComposerContext.apply { fakeMessageComposerContext.apply {
composerMode = MessageComposerMode.Edit( composerMode = MessageComposerMode.Edit(
eventId = null, eventId = null,
content = "", transactionId = null,
transactionId = null content = ""
) )
} }
@ -427,8 +427,8 @@ class SendLocationPresenterTest {
fakeMessageComposerContext.apply { fakeMessageComposerContext.apply {
composerMode = MessageComposerMode.Edit( composerMode = MessageComposerMode.Edit(
eventId = null, eventId = null,
content = "", transactionId = null,
transactionId = null content = ""
) )
} }

View file

@ -312,6 +312,7 @@ class MessagesPresenter @AssistedInject constructor(
else -> { else -> {
val composerMode = MessageComposerMode.Edit( val composerMode = MessageComposerMode.Edit(
targetEvent.eventId, targetEvent.eventId,
targetEvent.transactionId,
(targetEvent.content as? TimelineItemTextBasedContent)?.let { (targetEvent.content as? TimelineItemTextBasedContent)?.let {
if (enableTextFormatting) { if (enableTextFormatting) {
it.htmlBody ?: it.body it.htmlBody ?: it.body
@ -319,7 +320,6 @@ class MessagesPresenter @AssistedInject constructor(
it.body it.body
} }
}.orEmpty(), }.orEmpty(),
targetEvent.transactionId,
) )
composerState.eventSink( composerState.eventSink(
MessageComposerEvents.SetMode(composerMode) MessageComposerEvents.SetMode(composerMode)

View file

@ -68,6 +68,7 @@ import io.element.android.libraries.permissions.api.PermissionsPresenter
import io.element.android.libraries.preferences.api.store.SessionPreferencesStore import io.element.android.libraries.preferences.api.store.SessionPreferencesStore
import io.element.android.libraries.textcomposer.mentions.ResolvedMentionSuggestion import io.element.android.libraries.textcomposer.mentions.ResolvedMentionSuggestion
import io.element.android.libraries.textcomposer.mentions.rememberMentionSpanProvider import io.element.android.libraries.textcomposer.mentions.rememberMentionSpanProvider
import io.element.android.libraries.textcomposer.model.MarkdownTextEditorState
import io.element.android.libraries.textcomposer.model.Message import io.element.android.libraries.textcomposer.model.Message
import io.element.android.libraries.textcomposer.model.MessageComposerMode import io.element.android.libraries.textcomposer.model.MessageComposerMode
import io.element.android.libraries.textcomposer.model.Suggestion import io.element.android.libraries.textcomposer.model.Suggestion
@ -75,6 +76,7 @@ import io.element.android.libraries.textcomposer.model.TextEditorState
import io.element.android.libraries.textcomposer.model.rememberMarkdownTextEditorState import io.element.android.libraries.textcomposer.model.rememberMarkdownTextEditorState
import io.element.android.services.analytics.api.AnalyticsService import io.element.android.services.analytics.api.AnalyticsService
import io.element.android.services.analyticsproviders.api.trackers.captureInteraction import io.element.android.services.analyticsproviders.api.trackers.captureInteraction
import io.element.android.wysiwyg.compose.RichTextEditorState
import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.toPersistentList import kotlinx.collections.immutable.toPersistentList
import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CancellationException
@ -133,8 +135,6 @@ class MessageComposerPresenter @Inject constructor(
override fun present(): MessageComposerState { override fun present(): MessageComposerState {
val localCoroutineScope = rememberCoroutineScope() val localCoroutineScope = rememberCoroutineScope()
// Initially disabled so we don't set focus and text twice
var applyFormattingModeChanges by remember { mutableStateOf(false) }
val richTextEditorState = richTextEditorStateFactory.remember() val richTextEditorState = richTextEditorStateFactory.remember()
if (isTesting) { if (isTesting) {
richTextEditorState.isReadyToProcessActions = true richTextEditorState.isReadyToProcessActions = true
@ -182,18 +182,6 @@ class MessageComposerPresenter @Inject constructor(
val sendTypingNotifications by sessionPreferencesStore.isSendTypingNotificationsEnabled().collectAsState(initial = true) val sendTypingNotifications by sessionPreferencesStore.isSendTypingNotificationsEnabled().collectAsState(initial = true)
LaunchedEffect(messageComposerContext.composerMode) {
when (val modeValue = messageComposerContext.composerMode) {
is MessageComposerMode.Edit ->
if (showTextFormatting) {
richTextEditorState.setHtml(modeValue.content)
} else {
markdownTextEditorState.text.update(modeValue.content, true)
}
else -> Unit
}
}
LaunchedEffect(attachmentsState.value) { LaunchedEffect(attachmentsState.value) {
when (val attachmentStateValue = attachmentsState.value) { when (val attachmentStateValue = attachmentsState.value) {
is AttachmentsState.Sending.Processing -> { is AttachmentsState.Sending.Processing -> {
@ -267,25 +255,7 @@ class MessageComposerPresenter @Inject constructor(
) )
LaunchedEffect(Unit) { LaunchedEffect(Unit) {
loadDraft(textEditorState) loadDraft(markdownTextEditorState, richTextEditorState)
}
LaunchedEffect(showTextFormatting) {
if (!applyFormattingModeChanges) {
applyFormattingModeChanges = true
return@LaunchedEffect
}
if (showTextFormatting) {
val markdown = markdownTextEditorState.getMessageMarkdown(permalinkBuilder)
richTextEditorState.setMarkdown(markdown)
richTextEditorState.requestFocus()
} else {
val markdown = richTextEditorState.messageMarkdown
markdownTextEditorState.text.update(markdown, true)
// Give some time for the focus of the previous editor to be cleared
delay(100)
markdownTextEditorState.requestFocusAction()
}
} }
val mentionSpanProvider = if (isTesting) { val mentionSpanProvider = if (isTesting) {
@ -338,19 +308,7 @@ class MessageComposerPresenter @Inject constructor(
attachmentState = attachmentsState, attachmentState = attachmentsState,
) )
is MessageComposerEvents.SetMode -> { is MessageComposerEvents.SetMode -> {
messageComposerContext.composerMode = event.composerMode localCoroutineScope.setMode(event.composerMode, markdownTextEditorState, richTextEditorState)
when (event.composerMode) {
is MessageComposerMode.Reply -> event.composerMode.eventId
is MessageComposerMode.Edit -> event.composerMode.eventId
is MessageComposerMode.Normal -> null
is MessageComposerMode.Quote -> null
}.let { relatedEventId ->
appCoroutineScope.launch {
timelineController.invokeOnCurrentTimeline {
enterSpecialMode(relatedEventId)
}
}
}
} }
MessageComposerEvents.AddAttachment -> localCoroutineScope.launch { MessageComposerEvents.AddAttachment -> localCoroutineScope.launch {
showAttachmentSourcePicker = true showAttachmentSourcePicker = true
@ -398,10 +356,7 @@ class MessageComposerPresenter @Inject constructor(
} }
is MessageComposerEvents.ToggleTextFormatting -> { is MessageComposerEvents.ToggleTextFormatting -> {
showAttachmentSourcePicker = false showAttachmentSourcePicker = false
showTextFormatting = event.enabled localCoroutineScope.toggleTextFormatting(event.enabled, markdownTextEditorState, richTextEditorState)
if (showTextFormatting) {
analyticsService.captureInteraction(Interaction.Name.MobileRoomComposerFormattingEnabled)
}
} }
is MessageComposerEvents.Error -> { is MessageComposerEvents.Error -> {
analyticsService.trackError(event.error) analyticsService.trackError(event.error)
@ -497,7 +452,6 @@ class MessageComposerPresenter @Inject constructor(
} }
} }
is MessageComposerMode.Quote -> TODO()
is MessageComposerMode.Reply -> { is MessageComposerMode.Reply -> {
timelineController.invokeOnCurrentTimeline { timelineController.invokeOnCurrentTimeline {
replyMessage(capturedMode.eventId, message.markdown, message.html, mentions) replyMessage(capturedMode.eventId, message.markdown, message.html, mentions)
@ -596,25 +550,33 @@ class MessageComposerPresenter @Inject constructor(
} }
private fun CoroutineScope.loadDraft( private fun CoroutineScope.loadDraft(
textEditorState: TextEditorState, markdownTextEditorState: MarkdownTextEditorState,
richTextEditorState: RichTextEditorState,
) = launch { ) = launch {
val draft = draftService.loadDraft(room.roomId) ?: return@launch val draft = draftService.loadDraft(room.roomId) ?: return@launch
val htmlText = draft.htmlText val htmlText = draft.htmlText
val markdownText = draft.plainText val markdownText = draft.plainText
textEditorState.setMarkdown(markdownText)
if (htmlText != null) { if (htmlText != null) {
textEditorState.setHtml(htmlText)
showTextFormatting = true showTextFormatting = true
richTextEditorState.setHtml(htmlText)
richTextEditorState.requestFocus()
} else {
showTextFormatting = false
markdownTextEditorState.text.update(markdownText, true)
markdownTextEditorState.requestFocusAction()
} }
when (val draftType = draft.draftType) { when (val draftType = draft.draftType) {
ComposerDraftType.NewMessage -> messageComposerContext.composerMode = MessageComposerMode.Normal ComposerDraftType.NewMessage -> messageComposerContext.composerMode = MessageComposerMode.Normal
is ComposerDraftType.Edit -> messageComposerContext.composerMode = MessageComposerMode.Edit(draftType.eventId, markdownText, null) is ComposerDraftType.Edit -> messageComposerContext.composerMode = MessageComposerMode.Edit(
eventId = draftType.eventId,
transactionId = null,
content = htmlText ?: markdownText
)
is ComposerDraftType.Reply -> { is ComposerDraftType.Reply -> {
messageComposerContext.composerMode = MessageComposerMode.Reply(InReplyToDetails.Loading(draftType.eventId)) messageComposerContext.composerMode = MessageComposerMode.Reply(InReplyToDetails.Loading(draftType.eventId))
timelineController.invokeOnCurrentTimeline { timelineController.invokeOnCurrentTimeline {
val replyToDetails = loadReplyDetails(draftType.eventId).map(permalinkParser) val replyToDetails = loadReplyDetails(draftType.eventId).map(permalinkParser)
messageComposerContext.composerMode = MessageComposerMode.Reply(replyToDetails) run { messageComposerContext.composerMode = MessageComposerMode.Reply(replyToDetails) }
Unit
} }
} }
} }
@ -631,7 +593,6 @@ class MessageComposerPresenter @Inject constructor(
mode.eventId?.let { eventId -> ComposerDraftType.Edit(eventId) } mode.eventId?.let { eventId -> ComposerDraftType.Edit(eventId) }
} }
is MessageComposerMode.Reply -> ComposerDraftType.Reply(mode.eventId) is MessageComposerMode.Reply -> ComposerDraftType.Reply(mode.eventId)
is MessageComposerMode.Quote -> null
} }
if (draftType == null || markdown.isBlank()) { if (draftType == null || markdown.isBlank()) {
return@launch return@launch
@ -644,4 +605,58 @@ class MessageComposerPresenter @Inject constructor(
draftService.saveDraft(room.roomId, composerDraft) draftService.saveDraft(room.roomId, composerDraft)
} }
} }
private fun CoroutineScope.toggleTextFormatting(
enabled: Boolean,
markdownTextEditorState: MarkdownTextEditorState,
richTextEditorState: RichTextEditorState,
) = launch {
showTextFormatting = enabled
if (showTextFormatting) {
val markdown = markdownTextEditorState.getMessageMarkdown(permalinkBuilder)
richTextEditorState.setMarkdown(markdown)
richTextEditorState.requestFocus()
analyticsService.captureInteraction(Interaction.Name.MobileRoomComposerFormattingEnabled)
} else {
val markdown = richTextEditorState.messageMarkdown
markdownTextEditorState.text.update(markdown, true)
// Give some time for the focus of the previous editor to be cleared
delay(100)
markdownTextEditorState.requestFocusAction()
}
}
private fun CoroutineScope.setMode(
composerMode: MessageComposerMode,
markdownTextEditorState: MarkdownTextEditorState,
richTextEditorState: RichTextEditorState
) = launch {
messageComposerContext.composerMode = composerMode
when (composerMode) {
is MessageComposerMode.Reply -> {
timelineController.invokeOnCurrentTimeline {
enterSpecialMode(composerMode.eventId)
}
}
is MessageComposerMode.Edit -> {
setText(composerMode.content, markdownTextEditorState, richTextEditorState)
timelineController.invokeOnCurrentTimeline {
enterSpecialMode(composerMode.eventId)
}
}
else -> Unit
}
}
private suspend fun setText(content: String, markdownTextEditorState: MarkdownTextEditorState, richTextEditorState: RichTextEditorState) {
if (showTextFormatting) {
richTextEditorState.setHtml(content)
} else {
markdownTextEditorState.text.update(content, true)
}
}
} }

View file

@ -60,7 +60,6 @@ import io.element.android.libraries.matrix.test.A_USER_ID
import io.element.android.libraries.matrix.test.A_USER_ID_2 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.A_USER_NAME
import io.element.android.libraries.matrix.test.FakeMatrixClient import io.element.android.libraries.matrix.test.FakeMatrixClient
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
@ -1083,7 +1082,7 @@ fun anEditMode(
eventId: EventId? = AN_EVENT_ID, eventId: EventId? = AN_EVENT_ID,
message: String = A_MESSAGE, message: String = A_MESSAGE,
transactionId: TransactionId? = null, transactionId: TransactionId? = null,
) = MessageComposerMode.Edit(eventId, message, transactionId) ) = MessageComposerMode.Edit(eventId, transactionId, message)
fun aReplyMode() = MessageComposerMode.Reply(replyToDetails = InReplyToDetails.Loading(AN_EVENT_ID)) fun aReplyMode() = MessageComposerMode.Reply(replyToDetails = InReplyToDetails.Loading(AN_EVENT_ID))
fun aQuoteMode() = MessageComposerMode.Quote(AN_EVENT_ID, A_MESSAGE) fun aQuoteMode() = MessageComposerMode.Quote(AN_EVENT_ID, A_MESSAGE)

View file

@ -601,7 +601,7 @@ internal fun TextComposerEditPreview() = ElementPreview {
ATextComposer( ATextComposer(
TextEditorState.Rich(aRichTextEditorState(initialText = "A message", initialFocus = true)), TextEditorState.Rich(aRichTextEditorState(initialText = "A message", initialFocus = true)),
voiceMessageState = VoiceMessageState.Idle, voiceMessageState = VoiceMessageState.Idle,
composerMode = MessageComposerMode.Edit(EventId("$1234"), "Some text", TransactionId("1234")), composerMode = MessageComposerMode.Edit(EventId("$1234"), TransactionId("1234"), "Some text"),
enableVoiceMessages = true, enableVoiceMessages = true,
currentUserId = UserId("@alice:localhost") currentUserId = UserId("@alice:localhost")
) )
@ -615,7 +615,7 @@ internal fun MarkdownTextComposerEditPreview() = ElementPreview {
ATextComposer( ATextComposer(
TextEditorState.Markdown(aMarkdownTextEditorState(initialText = "A message", initialFocus = true)), TextEditorState.Markdown(aMarkdownTextEditorState(initialText = "A message", initialFocus = true)),
voiceMessageState = VoiceMessageState.Idle, voiceMessageState = VoiceMessageState.Idle,
composerMode = MessageComposerMode.Edit(EventId("$1234"), "Some text", TransactionId("1234")), composerMode = MessageComposerMode.Edit(EventId("$1234"), TransactionId("1234"), "Some text"),
enableVoiceMessages = true, enableVoiceMessages = true,
currentUserId = UserId("@alice:localhost") currentUserId = UserId("@alice:localhost")
) )

View file

@ -86,7 +86,7 @@ internal fun SendButton(
@Composable @Composable
internal fun SendButtonPreview() = ElementPreview { internal fun SendButtonPreview() = ElementPreview {
val normalMode = MessageComposerMode.Normal val normalMode = MessageComposerMode.Normal
val editMode = MessageComposerMode.Edit(null, "", null) val editMode = MessageComposerMode.Edit(null, null, "")
Row { Row {
SendButton(canSendMessage = true, onClick = {}, composerMode = normalMode) SendButton(canSendMessage = true, onClick = {}, composerMode = normalMode)
SendButton(canSendMessage = false, onClick = {}, composerMode = normalMode) SendButton(canSendMessage = false, onClick = {}, composerMode = normalMode)

View file

@ -29,9 +29,11 @@ sealed interface MessageComposerMode {
sealed interface Special : MessageComposerMode sealed interface Special : MessageComposerMode
data class Edit(val eventId: EventId?, val content: String, val transactionId: TransactionId?) : Special data class Edit(
val eventId: EventId?,
class Quote(val eventId: EventId, val content: String) : Special val transactionId: TransactionId?,
val content: String
) : Special
class Reply( class Reply(
val replyToDetails: InReplyToDetails val replyToDetails: InReplyToDetails
@ -43,7 +45,6 @@ sealed interface MessageComposerMode {
get() = when (this) { get() = when (this) {
is Normal -> null is Normal -> null
is Edit -> eventId is Edit -> eventId
is Quote -> eventId
is Reply -> eventId is Reply -> eventId
} }