Skip to content

Commit d5b36d5

Browse files
Merge pull request #1958 from Infomaniak/editor
Replace current editor with rich html editor
2 parents fcbf02e + f64764a commit d5b36d5

File tree

16 files changed

+432
-218
lines changed

16 files changed

+432
-218
lines changed

app/build.gradle

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -89,6 +89,8 @@ dependencies {
8989
implementation project(path: ':Core:Stores')
9090
implementation project(path: ':HtmlCleaner')
9191

92+
implementation libs.rich.html.editor
93+
9294
implementation libs.realm.kotlin.base
9395

9496
standardImplementation libs.firebase.messaging.ktx

app/src/main/java/com/infomaniak/mail/data/cache/mailboxContent/DraftController.kt

Lines changed: 0 additions & 66 deletions
Original file line numberDiff line numberDiff line change
@@ -17,31 +17,21 @@
1717
*/
1818
package com.infomaniak.mail.data.cache.mailboxContent
1919

20-
import com.infomaniak.lib.core.utils.contains
21-
import com.infomaniak.mail.data.api.ApiRepository
2220
import com.infomaniak.mail.data.cache.RealmDatabase
23-
import com.infomaniak.mail.data.cache.mailboxInfo.MailboxController
24-
import com.infomaniak.mail.data.models.Attachment.UploadStatus
2521
import com.infomaniak.mail.data.models.draft.Draft
26-
import com.infomaniak.mail.data.models.draft.Draft.DraftMode
2722
import com.infomaniak.mail.data.models.message.Message
28-
import com.infomaniak.mail.utils.AccountUtils
29-
import com.infomaniak.mail.utils.SentryDebug
3023
import io.realm.kotlin.MutableRealm
3124
import io.realm.kotlin.Realm
3225
import io.realm.kotlin.TypedRealm
3326
import io.realm.kotlin.UpdatePolicy
3427
import io.realm.kotlin.ext.query
35-
import io.realm.kotlin.ext.toRealmList
3628
import io.realm.kotlin.query.RealmQuery
3729
import io.realm.kotlin.query.RealmResults
3830
import io.realm.kotlin.query.RealmSingleQuery
3931
import javax.inject.Inject
4032

4133
class DraftController @Inject constructor(
4234
private val mailboxContentRealm: RealmDatabase.MailboxContent,
43-
private val mailboxController: MailboxController,
44-
private val replyForwardFooterManager: ReplyForwardFooterManager,
4535
) {
4636

4737
//region Get data
@@ -74,72 +64,16 @@ class DraftController @Inject constructor(
7464
//endregion
7565

7666
//region Open Draft
77-
fun setPreviousMessage(draft: Draft, draftMode: DraftMode, previousMessage: Message) {
78-
draft.inReplyTo = previousMessage.messageId
79-
80-
val previousReferences = if (previousMessage.references == null) "" else "${previousMessage.references} "
81-
draft.references = "${previousReferences}${previousMessage.messageId}"
82-
83-
draft.subject = formatSubject(draftMode, previousMessage.subject ?: "")
84-
85-
when (draftMode) {
86-
DraftMode.REPLY, DraftMode.REPLY_ALL -> {
87-
draft.inReplyToUid = previousMessage.uid
88-
89-
val (toList, ccList) = previousMessage.getRecipientsForReplyTo(replyAll = draftMode == DraftMode.REPLY_ALL)
90-
draft.to = toList.toRealmList()
91-
draft.cc = ccList.toRealmList()
92-
93-
draft.uiQuote = replyForwardFooterManager.createReplyFooter(previousMessage)
94-
}
95-
DraftMode.FORWARD -> {
96-
draft.forwardedUid = previousMessage.uid
97-
98-
val mailboxUuid = mailboxController.getMailbox(AccountUtils.currentUserId, AccountUtils.currentMailboxId)!!.uuid
99-
ApiRepository.attachmentsToForward(mailboxUuid, previousMessage).data?.attachments?.forEach { attachment ->
100-
draft.attachments += attachment.apply {
101-
resource = previousMessage.attachments.find { it.name == name }?.resource
102-
setUploadStatus(UploadStatus.FINISHED)
103-
}
104-
SentryDebug.addAttachmentsBreadcrumb(draft, step = "set previousMessage when reply/replyAll/Forward")
105-
}
106-
107-
draft.uiQuote = replyForwardFooterManager.createForwardFooter(previousMessage, draft.attachments)
108-
}
109-
DraftMode.NEW_MAIL -> Unit
110-
}
111-
}
112-
11367
fun fetchHeavyDataIfNeeded(message: Message, realm: Realm): Pair<Message, Boolean> {
11468
if (message.isFullyDownloaded()) return message to false
11569

11670
val (deleted, failed) = ThreadController.fetchMessagesHeavyData(listOf(message), realm)
11771
val hasFailedFetching = deleted.isNotEmpty() || failed.isNotEmpty()
11872
return MessageController.getMessage(message.uid, realm)!! to hasFailedFetching
11973
}
120-
121-
private fun formatSubject(draftMode: DraftMode, subject: String): String {
122-
123-
fun String.isReply(): Boolean = this in Regex(REGEX_REPLY, RegexOption.IGNORE_CASE)
124-
fun String.isForward(): Boolean = this in Regex(REGEX_FORWARD, RegexOption.IGNORE_CASE)
125-
126-
val prefix = when (draftMode) {
127-
DraftMode.REPLY, DraftMode.REPLY_ALL -> if (subject.isReply()) "" else PREFIX_REPLY
128-
DraftMode.FORWARD -> if (subject.isForward()) "" else PREFIX_FORWARD
129-
DraftMode.NEW_MAIL -> {
130-
throw IllegalStateException("`${DraftMode::class.simpleName}` cannot be `${DraftMode.NEW_MAIL.name}` here.")
131-
}
132-
}
133-
134-
return prefix + subject
135-
}
13674
//endregion
13775

13876
companion object {
139-
private const val PREFIX_REPLY = "Re: "
140-
private const val PREFIX_FORWARD = "Fw: "
141-
private const val REGEX_REPLY = "(re|ref|aw|rif|r):"
142-
private const val REGEX_FORWARD = "(fw|fwd|rv|wg|tr|i):"
14377

14478
//region Queries
14579
private fun getDraftsQuery(query: String? = null, realm: TypedRealm): RealmQuery<Draft> = with(realm) {

app/src/main/java/com/infomaniak/mail/data/models/draft/Draft.kt

Lines changed: 0 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,6 @@ import io.realm.kotlin.ext.realmListOf
2727
import io.realm.kotlin.serializers.RealmListKSerializer
2828
import io.realm.kotlin.types.RealmList
2929
import io.realm.kotlin.types.RealmObject
30-
import io.realm.kotlin.types.annotations.Ignore
3130
import io.realm.kotlin.types.annotations.PrimaryKey
3231
import kotlinx.serialization.SerialName
3332
import kotlinx.serialization.Serializable
@@ -89,18 +88,6 @@ class Draft : RealmObject {
8988
var messageUid: String? = null
9089
//endregion
9190

92-
//region UI data (Transient & Ignore)
93-
@Transient
94-
@Ignore
95-
var uiBody: String = ""
96-
@Transient
97-
@Ignore
98-
var uiSignature: String? = null
99-
@Transient
100-
@Ignore
101-
var uiQuote: String? = null
102-
//endregion
103-
10491
var action
10592
get() = enumValueOfOrNull<DraftAction>(_action)
10693
set(value) {
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
/*
2+
* Infomaniak Mail - Android
3+
* Copyright (C) 2024 Infomaniak Network SA
4+
*
5+
* This program is free software: you can redistribute it and/or modify
6+
* it under the terms of the GNU General Public License as published by
7+
* the Free Software Foundation, either version 3 of the License, or
8+
* (at your option) any later version.
9+
*
10+
* This program is distributed in the hope that it will be useful,
11+
* but WITHOUT ANY WARRANTY; without even the implied warranty of
12+
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
13+
* GNU General Public License for more details.
14+
*
15+
* You should have received a copy of the GNU General Public License
16+
* along with this program. If not, see <http://www.gnu.org/licenses/>.
17+
*/
18+
package com.infomaniak.mail.ui.newMessage
19+
20+
/**
21+
* @param content The string representation of the body in either html or plain text format.
22+
* @param type The type of representation of [content]. Each type will lead to different processing of the content.
23+
*/
24+
data class BodyContentPayload(val content: String, val type: BodyContentType) {
25+
26+
companion object {
27+
fun emptyBody() = BodyContentPayload(content = "", type = BodyContentType.TEXT_PLAIN_WITHOUT_HTML)
28+
}
29+
}
30+
31+
enum class BodyContentType {
32+
HTML_SANITIZED,
33+
HTML_UNSANITIZED,
34+
TEXT_PLAIN_WITH_HTML,
35+
TEXT_PLAIN_WITHOUT_HTML,
36+
}
Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
/*
2+
* Infomaniak Mail - Android
3+
* Copyright (C) 2024 Infomaniak Network SA
4+
*
5+
* This program is free software: you can redistribute it and/or modify
6+
* it under the terms of the GNU General Public License as published by
7+
* the Free Software Foundation, either version 3 of the License, or
8+
* (at your option) any later version.
9+
*
10+
* This program is distributed in the hope that it will be useful,
11+
* but WITHOUT ANY WARRANTY; without even the implied warranty of
12+
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
13+
* GNU General Public License for more details.
14+
*
15+
* You should have received a copy of the GNU General Public License
16+
* along with this program. If not, see <http://www.gnu.org/licenses/>.
17+
*/
18+
package com.infomaniak.mail.ui.newMessage
19+
20+
import android.text.Html
21+
import com.infomaniak.html.cleaner.HtmlSanitizer
22+
import com.infomaniak.lib.richhtmleditor.RichHtmlEditorWebView
23+
import com.infomaniak.mail.utils.JsoupParserUtil.jsoupParseWithLog
24+
import dagger.hilt.android.scopes.FragmentScoped
25+
import org.jsoup.nodes.Document
26+
import javax.inject.Inject
27+
28+
@FragmentScoped
29+
class EditorContentManager @Inject constructor() {
30+
31+
fun setContent(editor: RichHtmlEditorWebView, bodyContentPayload: BodyContentPayload) = with(editor) {
32+
when (bodyContentPayload.type) {
33+
BodyContentType.HTML_SANITIZED -> setSanitizedHtml(bodyContentPayload.content)
34+
BodyContentType.HTML_UNSANITIZED -> setUnsanitizedHtml(bodyContentPayload.content)
35+
BodyContentType.TEXT_PLAIN_WITH_HTML -> setPlainTextAndInterpretHtml(bodyContentPayload.content)
36+
BodyContentType.TEXT_PLAIN_WITHOUT_HTML -> setPlainTextAndEscapeHtml(bodyContentPayload.content)
37+
}
38+
}
39+
40+
private fun RichHtmlEditorWebView.setSanitizedHtml(html: String) = setHtml(html)
41+
42+
private fun RichHtmlEditorWebView.setUnsanitizedHtml(html: String) = setSanitizedHtml(html.sanitize())
43+
44+
private fun RichHtmlEditorWebView.setPlainTextAndInterpretHtml(text: String) {
45+
setSanitizedHtml(text.replaceNewLines().sanitize())
46+
}
47+
48+
private fun RichHtmlEditorWebView.setPlainTextAndEscapeHtml(text: String) {
49+
setSanitizedHtml(text.escapeHtmlCharacters().replaceNewLines())
50+
}
51+
52+
private fun String.escapeHtmlCharacters(): String = Html.escapeHtml(this)
53+
54+
private fun String.replaceNewLines(): String = replace(NEW_LINES_REGEX, "<br>")
55+
56+
private fun String.sanitize(): String = HtmlSanitizer.getInstance()
57+
.sanitize(jsoupParseWithLog(this))
58+
.apply { outputSettings().prettyPrint(false) }
59+
.getHtmlWithoutDocumentWrapping()
60+
61+
// Jsoup wraps parsed html inside an <html> and <body> tag. This gives us a wrapped form of the html content. While the editor
62+
// can handle this wrapped HTML without issues, it will also output the HTML in this wrapped form if given one as input.
63+
// If the HTML received from the API is unwrapped, the sanitization process will wrap it, leading to failed comparisons due to
64+
// this wrapping, during draft snapshot comparisons, even when the actual content hasn't changed.
65+
// This method checks if the HTML is wrapped with an <html> tag containing exactly one empty <head> and one <body> tag.
66+
// If this wrapping is detected, the method unwraps the HTML and returns only the content within the <body> tag.
67+
private fun Document.getHtmlWithoutDocumentWrapping(): String {
68+
val html = root().firstElementChild() ?: return html()
69+
val nodeSize = html.childNodeSize()
70+
val elements = html.children()
71+
72+
val canRemoveDocumentWrapping = nodeSize == 2
73+
&& elements.count() == 2
74+
&& elements[0].tagName().uppercase() == "HEAD"
75+
&& elements[0].childNodeSize() == 0
76+
&& elements[1].tagName().uppercase() == "BODY"
77+
78+
return if (canRemoveDocumentWrapping) body().html() else html()
79+
}
80+
81+
companion object {
82+
private val NEW_LINES_REGEX = "(\\r\\n|\\n)".toRegex()
83+
}
84+
}

app/src/main/java/com/infomaniak/mail/ui/newMessage/NewMessageActivity.kt

Lines changed: 21 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -86,7 +86,7 @@ class NewMessageActivity : BaseActivity() {
8686

8787
private fun setupSnackbar() {
8888
fun getAnchor(): View? = when (navController.currentDestination?.id) {
89-
R.id.newMessageFragment -> findViewById(R.id.editor)
89+
R.id.newMessageFragment -> findViewById(R.id.editorToolbar)
9090
R.id.aiPropositionFragment -> findViewById(R.id.aiPropositionBottomBar)
9191
else -> null
9292
}
@@ -124,26 +124,34 @@ class NewMessageActivity : BaseActivity() {
124124
}
125125

126126
private fun saveDraft() {
127-
128-
val fragment = getCurrentFragment(R.id.newMessageHostFragment)
129-
130-
val (subjectValue, uiBodyValue) = if (fragment is NewMessageFragment) {
131-
fragment.getSubjectAndBodyValues()
132-
} else with(newMessageViewModel) {
133-
lastOnStopSubjectValue to lastOnStopBodyValue
134-
}
135-
136-
newMessageViewModel.executeDraftActionWhenStopping(
127+
val draftSaveConfiguration = DraftSaveConfiguration(
137128
action = if (newMessageViewModel.shouldSendInsteadOfSave) DraftAction.SEND else DraftAction.SAVE,
138129
isFinishing = isFinishing,
139130
isTaskRoot = isTaskRoot,
140-
subjectValue = subjectValue,
141-
uiBodyValue = uiBodyValue,
142131
startWorkerCallback = ::startWorker,
143132
)
133+
134+
newMessageViewModel.waitForBodyAndSubjectToExecuteDraftAction(draftSaveConfiguration)
144135
}
145136

146137
private fun startWorker() {
147138
draftsActionsWorkerScheduler.scheduleWork(newMessageViewModel.draftLocalUuid())
148139
}
140+
141+
data class DraftSaveConfiguration(
142+
val action: DraftAction,
143+
val isFinishing: Boolean,
144+
val isTaskRoot: Boolean,
145+
val startWorkerCallback: () -> Unit,
146+
) {
147+
var subjectValue: String = ""
148+
private set
149+
var uiBodyValue: String = ""
150+
private set
151+
152+
fun addSubjectAndBody(subject: String, body: String) {
153+
subjectValue = subject
154+
uiBodyValue = body
155+
}
156+
}
149157
}

app/src/main/java/com/infomaniak/mail/ui/newMessage/NewMessageAiManager.kt

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,7 @@ import com.infomaniak.lib.core.R as RCore
5353
@FragmentScoped
5454
class NewMessageAiManager @Inject constructor(
5555
@ActivityContext private val activityContext: Context,
56+
private val editorContentManager: EditorContentManager,
5657
private val localSettings: LocalSettings,
5758
) : NewMessageManager() {
5859

@@ -92,7 +93,7 @@ class NewMessageAiManager @Inject constructor(
9293
fun observeAiOutput() = with(binding) {
9394
aiViewModel.aiOutputToInsert.observe(viewLifecycleOwner) { (subject, content) ->
9495
subject?.let(subjectTextField::setText)
95-
bodyTextField.setText(content)
96+
editorContentManager.setContent(editorWebView, BodyContentPayload(content, BodyContentType.TEXT_PLAIN_WITH_HTML))
9697
}
9798
}
9899

@@ -224,7 +225,7 @@ class NewMessageAiManager @Inject constructor(
224225
fragment.safeNavigate(
225226
NewMessageFragmentDirections.actionNewMessageFragmentToAiPropositionFragment(
226227
isSubjectBlank = fragment.isSubjectBlank(),
227-
isBodyBlank = binding.bodyTextField.text?.isBlank() == true,
228+
isBodyBlank = binding.editorWebView.isEmptyFlow.value ?: true,
228229
),
229230
)
230231
}

0 commit comments

Comments
 (0)