diff --git a/app/src/main/java/com/infomaniak/mail/MatomoMail.kt b/app/src/main/java/com/infomaniak/mail/MatomoMail.kt
index b252e136692..7084a1bccc1 100644
--- a/app/src/main/java/com/infomaniak/mail/MatomoMail.kt
+++ b/app/src/main/java/com/infomaniak/mail/MatomoMail.kt
@@ -148,6 +148,7 @@ object MatomoMail : Matomo {
DeleteDraft("deleteDraft"),
DeleteExecuted("deleteExecuted"),
DeleteFromHistory("deleteFromHistory"),
+ ShowQuote("showQuote"),
DeleteQuote("deleteQuote"),
DeleteRecipient("deleteRecipient"),
DeleteSearch("deleteSearch"),
diff --git a/app/src/main/java/com/infomaniak/mail/data/cache/mailboxContent/ReplyForwardFooterManager.kt b/app/src/main/java/com/infomaniak/mail/data/cache/mailboxContent/ReplyForwardFooterManager.kt
index 95807dc5c79..263410ac228 100644
--- a/app/src/main/java/com/infomaniak/mail/data/cache/mailboxContent/ReplyForwardFooterManager.kt
+++ b/app/src/main/java/com/infomaniak/mail/data/cache/mailboxContent/ReplyForwardFooterManager.kt
@@ -125,17 +125,27 @@ class ReplyForwardFooterManager @Inject constructor(private val appContext: Cont
addAndEscapeTextLine("")
addAlreadyEscapedBody(previousFullBody)
+ // We insert an unstyled empty line immediately after the quoted block. This is necessary because deleting styled
+ // content often leaves behind empty containers with residual formatting (borders, backgrounds). This plain line acts
+ // as a "clean exit", when the user deletes the quotes up to this point, the editor drops the quotes' inline styles
+ // completely, allowing the entire block to be removed in one go.
+ addAndEscapeTextLine("")
}.outerHtml()
}
private fun assembleReplyHtmlFooter(messageReplyHeader: String, previousFullBody: String): String {
- val replyRoot = """
"""
+ val replyRoot = """"""
return parseAndWrapElementInNewDocument(replyRoot).apply {
addAndEscapeTextLine("")
addAndEscapeTextLine(messageReplyHeader, endWithBr = false)
addReplyBlockQuote {
addAlreadyEscapedBody(previousFullBody)
}
+ // We insert an unstyled empty line immediately after the quoted block. This is necessary because deleting styled
+ // content often leaves behind empty containers with residual formatting (borders, backgrounds). This plain line acts
+ // as a "clean exit", when the user deletes the quotes up to this point, the editor drops the quotes' inline styles
+ // completely, allowing the entire block to be removed in one go.
+ addAndEscapeTextLine("")
}.outerHtml()
}
diff --git a/app/src/main/java/com/infomaniak/mail/data/models/javascriptBridge/EditorJavascriptBridge.kt b/app/src/main/java/com/infomaniak/mail/data/models/javascriptBridge/EditorJavascriptBridge.kt
new file mode 100644
index 00000000000..e570e4c67bf
--- /dev/null
+++ b/app/src/main/java/com/infomaniak/mail/data/models/javascriptBridge/EditorJavascriptBridge.kt
@@ -0,0 +1,42 @@
+/*
+ * Infomaniak Mail - Android
+ * Copyright (C) 2026 Infomaniak Network SA
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+package com.infomaniak.mail.data.models.javascriptBridge
+
+import android.webkit.JavascriptInterface
+import com.infomaniak.core.sentry.SentryLog
+import org.json.JSONArray
+
+class EditorJavascriptBridge(
+ private val onInlineImagesDeleted: (List) -> Unit,
+) {
+ @JavascriptInterface
+ fun onInlineImagesDeleted(cidJson: String) {
+ val jsonArray = runCatching {
+ JSONArray(cidJson)
+ }.onFailure {
+ SentryLog.e(TAG, "Failed to parse CIDs", it)
+ }.getOrNull() ?: return
+
+ val cids = (0 until jsonArray.length()).map { jsonArray.getString(it) }
+ onInlineImagesDeleted(cids)
+ }
+
+ companion object {
+ private const val TAG = "EditorJavascriptBridge"
+ }
+}
diff --git a/app/src/main/java/com/infomaniak/mail/data/models/javascriptBridge/MessageDisplayJavascriptBridge.kt b/app/src/main/java/com/infomaniak/mail/data/models/javascriptBridge/MessageDisplayJavascriptBridge.kt
new file mode 100644
index 00000000000..897b681a585
--- /dev/null
+++ b/app/src/main/java/com/infomaniak/mail/data/models/javascriptBridge/MessageDisplayJavascriptBridge.kt
@@ -0,0 +1,59 @@
+/*
+ * Infomaniak Mail - Android
+ * Copyright (C) 2026 Infomaniak Network SA
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+package com.infomaniak.mail.data.models.javascriptBridge
+
+import android.webkit.JavascriptInterface
+import com.infomaniak.mail.utils.SentryDebug
+
+class MessageDisplayJavascriptBridge(
+ private val onWebViewFinishedLoading: () -> Unit,
+) {
+
+ @JavascriptInterface
+ fun reportOverScroll(clientWidth: Int, scrollWidth: Int, messageUid: String) {
+ SentryDebug.sendOverScrolledMessage(clientWidth, scrollWidth, messageUid)
+ }
+
+ @JavascriptInterface
+ fun reportError(
+ errorName: String,
+ errorMessage: String,
+ errorStack: String,
+ scriptFirstLine: String,
+ messageUid: String,
+ ) {
+ val correctErrorStack = fixStackTraceLineNumber(errorStack, scriptFirstLine)
+ SentryDebug.sendJavaScriptError(errorName, errorMessage, correctErrorStack, messageUid)
+ }
+
+ @JavascriptInterface
+ fun webviewFinishedLoading() {
+ onWebViewFinishedLoading()
+ }
+
+ private fun fixStackTraceLineNumber(errorStack: String, scriptFirstLine: String): String {
+ var correctErrorStack = errorStack
+ val matches = "about:blank:([0-9]+):".toRegex().findAll(correctErrorStack)
+ matches.forEach { match ->
+ val lineNumber = match.groupValues[1]
+ val newLineNumber = lineNumber.toInt() - scriptFirstLine.toInt() + 1
+ correctErrorStack = correctErrorStack.replace(match.groupValues[0], "about:blank:$newLineNumber:")
+ }
+ return correctErrorStack
+ }
+}
diff --git a/app/src/main/java/com/infomaniak/mail/ui/main/thread/ThreadAdapter.kt b/app/src/main/java/com/infomaniak/mail/ui/main/thread/ThreadAdapter.kt
index ae7976ae8e6..982f6117405 100644
--- a/app/src/main/java/com/infomaniak/mail/ui/main/thread/ThreadAdapter.kt
+++ b/app/src/main/java/com/infomaniak/mail/ui/main/thread/ThreadAdapter.kt
@@ -65,6 +65,7 @@ import com.infomaniak.mail.databinding.ItemSuperCollapsedBlockBinding
import com.infomaniak.mail.ui.main.thread.ThreadAdapter.ThreadAdapterViewHolder
import com.infomaniak.mail.ui.main.thread.models.MessageUi
import com.infomaniak.mail.ui.main.thread.models.MessageUi.UnsubscribeState
+import com.infomaniak.mail.ui.main.thread.webViewClient.MessageDisplayWebViewClient
import com.infomaniak.mail.utils.AccountUtils
import com.infomaniak.mail.utils.HtmlFormatter
import com.infomaniak.mail.utils.MessageBodyUtils
@@ -86,7 +87,7 @@ import com.infomaniak.mail.utils.extensions.enableAlgorithmicDarkening
import com.infomaniak.mail.utils.extensions.formatSubject
import com.infomaniak.mail.utils.extensions.getAttributeColor
import com.infomaniak.mail.utils.extensions.indexOfFirstOrNull
-import com.infomaniak.mail.utils.extensions.initWebViewClientAndBridge
+import com.infomaniak.mail.utils.extensions.initDisplayWebViewClientAndBridge
import com.infomaniak.mail.utils.extensions.toDate
import com.infomaniak.mail.utils.extensions.toggleChevron
import io.sentry.Sentry
@@ -797,8 +798,8 @@ class ThreadAdapter(
quoteButtonFrameLayout.isVisible = hasQuote
initWebViewClientIfNeeded(
- message,
- threadAdapterCallbacks?.navigateToNewMessageActivity,
+ message = message,
+ navigateToNewMessageActivity = threadAdapterCallbacks?.navigateToNewMessageActivity,
onPageFinished = { onExpandedMessageLoaded(message.uid) },
onWebViewFinishedLoading = { threadAdapterCallbacks?.onBodyWebViewFinishedLoading?.invoke() },
)
@@ -1122,8 +1123,8 @@ class ThreadAdapter(
onAttachmentOptionsClicked = { onAttachmentOptionsClicked?.invoke(it) },
)
- private var _bodyWebViewClient: MessageWebViewClient? = null
- private var _fullMessageWebViewClient: MessageWebViewClient? = null
+ private var _bodyWebViewClient: MessageDisplayWebViewClient? = null
+ private var _fullMessageWebViewClient: MessageDisplayWebViewClient? = null
val bodyWebViewClient get() = _bodyWebViewClient!!
val fullMessageWebViewClient get() = _fullMessageWebViewClient!!
@@ -1149,7 +1150,7 @@ class ThreadAdapter(
}
if (_bodyWebViewClient == null) {
- _bodyWebViewClient = binding.bodyWebView.initWebViewClientAndBridge(
+ _bodyWebViewClient = binding.bodyWebView.initDisplayWebViewClientAndBridge(
attachments = message.attachments,
messageUid = message.uid,
shouldLoadDistantResources = shouldLoadDistantResources,
@@ -1158,7 +1159,7 @@ class ThreadAdapter(
onPageFinished = onPageFinished,
onWebViewFinishedLoading = onWebViewFinishedLoading,
)
- _fullMessageWebViewClient = binding.fullMessageWebView.initWebViewClientAndBridge(
+ _fullMessageWebViewClient = binding.fullMessageWebView.initDisplayWebViewClientAndBridge(
attachments = message.attachments,
messageUid = message.uid,
shouldLoadDistantResources = shouldLoadDistantResources,
diff --git a/app/src/main/java/com/infomaniak/mail/ui/main/thread/webViewClient/EditorWebViewClient.kt b/app/src/main/java/com/infomaniak/mail/ui/main/thread/webViewClient/EditorWebViewClient.kt
new file mode 100644
index 00000000000..320272323f2
--- /dev/null
+++ b/app/src/main/java/com/infomaniak/mail/ui/main/thread/webViewClient/EditorWebViewClient.kt
@@ -0,0 +1,37 @@
+/*
+ * Infomaniak Mail - Android
+ * Copyright (C) 2026 Infomaniak Network SA
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+package com.infomaniak.mail.ui.main.thread.webViewClient
+
+import android.content.Context
+import android.webkit.WebView
+import com.infomaniak.mail.data.models.Attachment
+
+class EditorWebViewClient(
+ context: Context,
+ cidDictionary: Map,
+ shouldLoadDistantResources: Boolean,
+ private val onPageFinished: () -> Unit,
+) : MessageWebViewClient(
+ context,
+ cidDictionary,
+ shouldLoadDistantResources,
+) {
+ override fun onPageFinished(webView: WebView, url: String?) {
+ onPageFinished()
+ }
+}
diff --git a/app/src/main/java/com/infomaniak/mail/ui/main/thread/webViewClient/MessageDisplayWebViewClient.kt b/app/src/main/java/com/infomaniak/mail/ui/main/thread/webViewClient/MessageDisplayWebViewClient.kt
new file mode 100644
index 00000000000..07554f1d5d7
--- /dev/null
+++ b/app/src/main/java/com/infomaniak/mail/ui/main/thread/webViewClient/MessageDisplayWebViewClient.kt
@@ -0,0 +1,94 @@
+/*
+ * Infomaniak Mail - Android
+ * Copyright (C) 2026 Infomaniak Network SA
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+package com.infomaniak.mail.ui.main.thread.webViewClient
+
+import android.content.Context
+import android.content.Intent
+import android.net.Uri
+import android.webkit.WebResourceRequest
+import android.webkit.WebView
+import com.infomaniak.core.ui.showToast
+import com.infomaniak.core.ui.view.toDp
+import com.infomaniak.lib.richhtmleditor.looselyEscapeAsStringLiteralForJs
+import com.infomaniak.mail.R
+import com.infomaniak.mail.data.models.Attachment
+import com.infomaniak.mail.utils.Utils.runCatchingRealm
+import com.infomaniak.mail.utils.WebViewVersionUtils
+import io.sentry.Sentry
+import io.sentry.SentryLevel
+
+class MessageDisplayWebViewClient(
+ private val context: Context,
+ cidDictionary: Map,
+ private val messageUid: String,
+ shouldLoadDistantResources: Boolean,
+ onBlockedResourcesDetected: () -> Unit,
+ private val navigateToNewMessageActivity: ((Uri) -> Unit)?,
+ private val onPageFinished: (() -> Unit)?,
+) : MessageWebViewClient(
+ context,
+ cidDictionary,
+ shouldLoadDistantResources,
+ onBlockedResourcesDetected,
+) {
+ override fun shouldOverrideUrlLoading(view: WebView, request: WebResourceRequest): Boolean {
+ request.url?.let { uri ->
+
+ if (uri.scheme == "mailto") {
+ navigateToNewMessageActivity?.invoke(uri)
+ return true
+ }
+
+ runCatching {
+ val intent = Intent(Intent.ACTION_VIEW, uri)
+ intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
+ context.startActivity(intent)
+ }.onFailure {
+ context.showToast(R.string.startActivityCantHandleAction)
+ }
+ }
+ return true
+ }
+
+ override fun onPageFinished(webView: WebView, url: String?) {
+ runCatchingRealm {
+ val widthInDp = webView.width.toDp(webView)
+ if (widthInDp <= 0) {
+ val versionData = WebViewVersionUtils.getWebViewVersionData(context)
+
+ Sentry.captureMessage("Zero width webview detected onPageFinished which prevents message width's normalization") { scope ->
+ scope.level = SentryLevel.WARNING
+ scope.setExtra("width", webView.width.toString())
+ scope.setExtra("measuredWidth", webView.measuredWidth.toString())
+ scope.setExtra("height", webView.height.toString())
+ scope.setExtra("measuredHeight", webView.measuredHeight.toString())
+ scope.setTag(
+ "webview version",
+ "${versionData?.webViewPackageName}: ${versionData?.versionName} - ${versionData?.majorVersion}"
+ )
+ scope.setTag("visibility", webView.visibility.toString())
+ scope.setTag("messageUid", messageUid)
+ scope.setTag("shouldLoadDistantResources", shouldLoadDistantResources.toString())
+ }
+ }
+ val escapedMessageUid = looselyEscapeAsStringLiteralForJs(messageUid)
+ webView.loadUrl("javascript:removeAllProperties(); normalizeMessageWidth($widthInDp, $escapedMessageUid)")
+ onPageFinished?.invoke()
+ }
+ }
+}
diff --git a/app/src/main/java/com/infomaniak/mail/ui/main/thread/MessageWebViewClient.kt b/app/src/main/java/com/infomaniak/mail/ui/main/thread/webViewClient/MessageWebViewClient.kt
similarity index 56%
rename from app/src/main/java/com/infomaniak/mail/ui/main/thread/MessageWebViewClient.kt
rename to app/src/main/java/com/infomaniak/mail/ui/main/thread/webViewClient/MessageWebViewClient.kt
index 7976f15aa6e..2e8c55f878e 100644
--- a/app/src/main/java/com/infomaniak/mail/ui/main/thread/MessageWebViewClient.kt
+++ b/app/src/main/java/com/infomaniak/mail/ui/main/thread/webViewClient/MessageWebViewClient.kt
@@ -15,38 +15,28 @@
* You should have received a copy of the GNU General Public License
* along with this program. If not, see .
*/
-package com.infomaniak.mail.ui.main.thread
+package com.infomaniak.mail.ui.main.thread.webViewClient
import android.content.Context
-import android.content.Intent
-import android.net.Uri
import android.webkit.WebResourceRequest
import android.webkit.WebResourceResponse
import android.webkit.WebView
import android.webkit.WebViewClient
-import com.infomaniak.core.ui.showToast
-import com.infomaniak.core.ui.view.toDp
-import com.infomaniak.mail.R
import com.infomaniak.mail.data.api.ApiRepository
import com.infomaniak.mail.data.models.Attachment
import com.infomaniak.mail.utils.LocalStorageUtils
import com.infomaniak.mail.utils.Utils
import com.infomaniak.mail.utils.Utils.runCatchingRealm
-import com.infomaniak.mail.utils.WebViewVersionUtils.getWebViewVersionData
-import io.sentry.Sentry
-import io.sentry.SentryLevel
import kotlinx.coroutines.runBlocking
import java.io.ByteArrayInputStream
-class MessageWebViewClient(
+abstract class MessageWebViewClient(
private val context: Context,
- private val cidDictionary: MutableMap,
- private val messageUid: String,
- private var shouldLoadDistantResources: Boolean,
+ private val cidDictionary: Map,
+ private var _shouldLoadDistantResources: Boolean,
private val onBlockedResourcesDetected: (() -> Unit)? = null,
- private val navigateToNewMessageActivity: ((Uri) -> Unit)?,
- private val onPageFinished: (() -> Unit)? = null,
) : WebViewClient() {
+ val shouldLoadDistantResources get() = _shouldLoadDistantResources
private val emptyResource by lazy { WebResourceResponse("text/plain", "utf-8", ByteArrayInputStream(ByteArray(0))) }
@@ -74,7 +64,7 @@ class MessageWebViewClient(
} ?: emptyResource
}
- val shouldLoadResource = shouldLoadDistantResources
+ val shouldLoadResource = _shouldLoadDistantResources
|| url?.scheme.equals(DATA_SCHEME, ignoreCase = true)
|| trustedUrls.any { it.find(url.toString()) != null }
@@ -86,54 +76,8 @@ class MessageWebViewClient(
}
}.getOrDefault(super.shouldInterceptRequest(view, request))
- override fun shouldOverrideUrlLoading(view: WebView, request: WebResourceRequest): Boolean {
- request.url?.let { uri ->
-
- if (uri.scheme == "mailto") {
- navigateToNewMessageActivity?.invoke(uri)
- return true
- }
-
- runCatching {
- val intent = Intent(Intent.ACTION_VIEW, uri)
- intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
- context.startActivity(intent)
- }.onFailure {
- context.showToast(R.string.startActivityCantHandleAction)
- }
- }
- return true
- }
-
- override fun onPageFinished(webView: WebView, url: String?) {
- runCatchingRealm {
- val widthInDp = webView.width.toDp(webView)
- if (widthInDp <= 0) {
- val versionData = getWebViewVersionData(context)
-
- Sentry.captureMessage("Zero width webview detected onPageFinished which prevents message width's normalization") { scope ->
- scope.level = SentryLevel.WARNING
- scope.setExtra("width", webView.width.toString())
- scope.setExtra("measuredWidth", webView.measuredWidth.toString())
- scope.setExtra("height", webView.height.toString())
- scope.setExtra("measuredHeight", webView.measuredHeight.toString())
- scope.setTag(
- "webview version",
- "${versionData?.webViewPackageName}: ${versionData?.versionName} - ${versionData?.majorVersion}"
- )
- scope.setTag("visibility", webView.visibility.toString())
- scope.setTag("messageUid", messageUid)
- scope.setTag("shouldLoadDistantResources", shouldLoadDistantResources.toString())
- }
- }
-
- webView.loadUrl("javascript:removeAllProperties(); normalizeMessageWidth($widthInDp, '$messageUid')")
- onPageFinished?.invoke()
- }
- }
-
fun unblockDistantResources() {
- shouldLoadDistantResources = true
+ _shouldLoadDistantResources = true
}
companion object {
diff --git a/app/src/main/java/com/infomaniak/mail/ui/newMessage/AiPromptFragment.kt b/app/src/main/java/com/infomaniak/mail/ui/newMessage/AiPromptFragment.kt
index c5fb816ba9a..20d93d62543 100644
--- a/app/src/main/java/com/infomaniak/mail/ui/newMessage/AiPromptFragment.kt
+++ b/app/src/main/java/com/infomaniak/mail/ui/newMessage/AiPromptFragment.kt
@@ -92,7 +92,9 @@ class AiPromptFragment : Fragment() {
closeButton.setOnClickListener { newMessageFragment.closeAiPrompt() }
generateButton.setOnClickListener {
- newMessageFragment.navigateToPropositionFragment()
+ viewLifecycleOwner.lifecycleScope.launch {
+ newMessageFragment.navigateToPropositionFragment()
+ }
}
// When the app is recreated or the prompt is opened when coming back from AiPropositionFragment,
diff --git a/app/src/main/java/com/infomaniak/mail/ui/newMessage/BodyContentPayload.kt b/app/src/main/java/com/infomaniak/mail/ui/newMessage/BodyContentPayload.kt
index 88f29030fda..bea914dbea5 100644
--- a/app/src/main/java/com/infomaniak/mail/ui/newMessage/BodyContentPayload.kt
+++ b/app/src/main/java/com/infomaniak/mail/ui/newMessage/BodyContentPayload.kt
@@ -1,6 +1,6 @@
/*
* Infomaniak Mail - Android
- * Copyright (C) 2024 Infomaniak Network SA
+ * Copyright (C) 2024-2026 Infomaniak Network SA
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
@@ -24,7 +24,8 @@ package com.infomaniak.mail.ui.newMessage
data class BodyContentPayload(val content: String, val type: BodyContentType) {
companion object {
- fun emptyBody() = BodyContentPayload(content = "", type = BodyContentType.TEXT_PLAIN_WITHOUT_HTML)
+ // Add some empty lines in the body so the body focus on these lines and not in the signature
+ fun emptyBody() = BodyContentPayload(content = "
", type = BodyContentType.HTML_SANITIZED)
}
}
diff --git a/app/src/main/java/com/infomaniak/mail/ui/newMessage/EditorContentManager.kt b/app/src/main/java/com/infomaniak/mail/ui/newMessage/EditorContentManager.kt
index e8d2ff09a93..ece1a2968f9 100644
--- a/app/src/main/java/com/infomaniak/mail/ui/newMessage/EditorContentManager.kt
+++ b/app/src/main/java/com/infomaniak/mail/ui/newMessage/EditorContentManager.kt
@@ -1,6 +1,6 @@
/*
* Infomaniak Mail - Android
- * Copyright (C) 2024 Infomaniak Network SA
+ * Copyright (C) 2024-2026 Infomaniak Network SA
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
@@ -28,57 +28,59 @@ import javax.inject.Inject
@FragmentScoped
class EditorContentManager @Inject constructor() {
- fun setContent(editor: RichHtmlEditorWebView, bodyContentPayload: BodyContentPayload) = with(editor) {
- when (bodyContentPayload.type) {
- BodyContentType.HTML_SANITIZED -> setSanitizedHtml(bodyContentPayload.content)
- BodyContentType.HTML_UNSANITIZED -> setUnsanitizedHtml(bodyContentPayload.content)
- BodyContentType.TEXT_PLAIN_WITH_HTML -> setPlainTextAndInterpretHtml(bodyContentPayload.content)
- BodyContentType.TEXT_PLAIN_WITHOUT_HTML -> setPlainTextAndEscapeHtml(bodyContentPayload.content)
- }
+ fun setContent(editor: RichHtmlEditorWebView, bodyContentPayload: BodyContentPayload) {
+ editor.setHtml(bodyContentPayload.toSanitizedHtml())
}
- private fun RichHtmlEditorWebView.setSanitizedHtml(html: String) = setHtml(html)
+ companion object {
+ private val NEW_LINES_REGEX = "(\\r\\n|\\n)".toRegex()
- private fun RichHtmlEditorWebView.setUnsanitizedHtml(html: String) = setSanitizedHtml(html.sanitize())
+ fun BodyContentPayload.toSanitizedHtml(): String {
+ return when (type) {
+ BodyContentType.HTML_SANITIZED -> content
+ BodyContentType.HTML_UNSANITIZED -> getUnsanitizedHtml(content)
+ BodyContentType.TEXT_PLAIN_WITH_HTML -> getPlainTextAndInterpretHtml(content)
+ BodyContentType.TEXT_PLAIN_WITHOUT_HTML -> getPlainTextAndEscapeHtml(content)
+ }
+ }
- private fun RichHtmlEditorWebView.setPlainTextAndInterpretHtml(text: String) {
- setSanitizedHtml(text.replaceNewLines().sanitize())
- }
+ private fun getUnsanitizedHtml(html: String) = html.sanitize()
- private fun RichHtmlEditorWebView.setPlainTextAndEscapeHtml(text: String) {
- setSanitizedHtml(text.escapeHtmlCharacters().replaceNewLines())
- }
+ private fun getPlainTextAndInterpretHtml(text: String): String {
+ return text.replaceNewLines().sanitize()
+ }
- private fun String.escapeHtmlCharacters(): String = Html.escapeHtml(this)
+ private fun getPlainTextAndEscapeHtml(text: String): String {
+ return text.escapeHtmlCharacters().replaceNewLines()
+ }
- private fun String.replaceNewLines(): String = replace(NEW_LINES_REGEX, "
")
+ private fun String.escapeHtmlCharacters(): String = Html.escapeHtml(this)
- private fun String.sanitize(): String = HtmlSanitizer.getInstance()
- .sanitize(jsoupParseWithLog(this))
- .apply { outputSettings().prettyPrint(false) }
- .getHtmlWithoutDocumentWrapping()
+ private fun String.replaceNewLines(): String = replace(NEW_LINES_REGEX, "
")
- // Jsoup wraps parsed html inside an and tag. This gives us a wrapped form of the html content. While the editor
- // can handle this wrapped HTML without issues, it will also output the HTML in this wrapped form if given one as input.
- // If the HTML received from the API is unwrapped, the sanitization process will wrap it, leading to failed comparisons due to
- // this wrapping, during draft snapshot comparisons, even when the actual content hasn't changed.
- // This method checks if the HTML is wrapped with an tag containing exactly one empty and one tag.
- // If this wrapping is detected, the method unwraps the HTML and returns only the content within the tag.
- private fun Document.getHtmlWithoutDocumentWrapping(): String {
- val html = root().firstElementChild() ?: return html()
- val nodeSize = html.childNodeSize()
- val elements = html.children()
+ private fun String.sanitize(): String = HtmlSanitizer.getInstance()
+ .sanitize(jsoupParseWithLog(this))
+ .apply { outputSettings().prettyPrint(false) }
+ .getHtmlWithoutDocumentWrapping()
- val canRemoveDocumentWrapping = nodeSize == 2
- && elements.count() == 2
- && elements[0].tagName().uppercase() == "HEAD"
- && elements[0].childNodeSize() == 0
- && elements[1].tagName().uppercase() == "BODY"
+ // Jsoup wraps parsed html inside an and tag. This gives us a wrapped form of the html content. While the editor
+ // can handle this wrapped HTML without issues, it will also output the HTML in this wrapped form if given one as input.
+ // If the HTML received from the API is unwrapped, the sanitization process will wrap it, leading to failed comparisons due to
+ // this wrapping, during draft snapshot comparisons, even when the actual content hasn't changed.
+ // This method checks if the HTML is wrapped with an tag containing exactly one empty and one tag.
+ // If this wrapping is detected, the method unwraps the HTML and returns only the content within the tag.
+ private fun Document.getHtmlWithoutDocumentWrapping(): String {
+ val html = root().firstElementChild() ?: return html()
+ val nodeSize = html.childNodeSize()
+ val elements = html.children()
- return if (canRemoveDocumentWrapping) body().html() else html()
- }
+ val canRemoveDocumentWrapping = nodeSize == 2
+ && elements.count() == 2
+ && elements[0].tagName().uppercase() == "HEAD"
+ && elements[0].childNodeSize() == 0
+ && elements[1].tagName().uppercase() == "BODY"
- companion object {
- private val NEW_LINES_REGEX = "(\\r\\n|\\n)".toRegex()
+ return if (canRemoveDocumentWrapping) body().html() else html()
+ }
}
}
diff --git a/app/src/main/java/com/infomaniak/mail/ui/newMessage/NewMessageAiManager.kt b/app/src/main/java/com/infomaniak/mail/ui/newMessage/NewMessageAiManager.kt
index d07c8e434b1..04041075afd 100644
--- a/app/src/main/java/com/infomaniak/mail/ui/newMessage/NewMessageAiManager.kt
+++ b/app/src/main/java/com/infomaniak/mail/ui/newMessage/NewMessageAiManager.kt
@@ -32,6 +32,7 @@ import androidx.lifecycle.lifecycleScope
import com.infomaniak.core.ksuite.data.KSuite
import com.infomaniak.core.legacy.utils.hideKeyboard
import com.infomaniak.core.legacy.utils.safeNavigate
+import com.infomaniak.lib.richhtmleditor.executor.JsExecutableMethod
import com.infomaniak.mail.MatomoMail.MatomoName
import com.infomaniak.mail.MatomoMail.trackAiWriterEvent
import com.infomaniak.mail.R
@@ -39,6 +40,12 @@ import com.infomaniak.mail.data.LocalSettings
import com.infomaniak.mail.data.models.FeatureFlag
import com.infomaniak.mail.data.models.ai.AiPromptOpeningStatus
import com.infomaniak.mail.databinding.FragmentNewMessageBinding
+import com.infomaniak.mail.ui.newMessage.EditorContentManager.Companion.toSanitizedHtml
+import com.infomaniak.mail.utils.HtmlFormatter.Companion.getCheckIsEditorBodyEmptyScript
+import com.infomaniak.mail.utils.MessageBodyUtils.INFOMANIAK_FORWARD_QUOTE_HTML_CLASS_NAME
+import com.infomaniak.mail.utils.MessageBodyUtils.INFOMANIAK_REPLY_QUOTE_HTML_CLASS_NAME
+import com.infomaniak.mail.utils.MessageBodyUtils.INFOMANIAK_SIGNATURE_HTML_CLASS_NAME
+import com.infomaniak.mail.utils.WebViewUtils.Companion.evaluateJs
import com.infomaniak.mail.utils.openKSuiteProBottomSheet
import com.infomaniak.mail.utils.openMailPremiumBottomSheet
import com.infomaniak.mail.utils.openMyKSuiteUpgradeBottomSheet
@@ -55,7 +62,6 @@ import com.infomaniak.core.legacy.R as RCore
@FragmentScoped
class NewMessageAiManager @Inject constructor(
@ActivityContext private val activityContext: Context,
- private val editorContentManager: EditorContentManager,
private val localSettings: LocalSettings,
) : NewMessageManager() {
@@ -67,6 +73,7 @@ class NewMessageAiManager @Inject constructor(
private val animationDuration by lazy { resources.getInteger(R.integer.aiPromptAnimationDuration).toLong() }
private val scrimOpacity by lazy { ResourcesCompat.getFloat(context.resources, R.dimen.scrimOpacity) }
private val black by lazy { context.getColor(RCore.color.black) }
+ private val checkIsEditorBodyEmptyScript by lazy { activityContext.getCheckIsEditorBodyEmptyScript() }
private var aiPromptFragment: AiPromptFragment? = null
@@ -94,8 +101,18 @@ class NewMessageAiManager @Inject constructor(
fun observeAiOutput() = with(binding) {
aiViewModel.aiOutputToInsert.observe(viewLifecycleOwner) { (subject, content) ->
+ newMessageViewModel.setPlaceholderVisibility(false)
subject?.let(subjectTextField::setText)
- editorContentManager.setContent(editorWebView, BodyContentPayload(content, BodyContentType.TEXT_PLAIN_WITH_HTML))
+ setAiContent(content)
+ }
+ }
+
+ private fun setAiContent(content: String) {
+ val bodyContent = BodyContentPayload(content, BodyContentType.TEXT_PLAIN_WITH_HTML)
+ val sanitizedContent = bodyContent.toSanitizedHtml()
+
+ viewLifecycleOwner.lifecycleScope.launch {
+ binding.editorWebView.executeJsMethodWhenEditorIsSetup(JsExecutableMethod("setAiOutputContent", sanitizedContent))
}
}
@@ -207,15 +224,25 @@ class NewMessageAiManager @Inject constructor(
}
}
- fun navigateToPropositionFragment() {
-
+ suspend fun navigateToPropositionFragment() {
+ calculateAiPropositionData()
closeAiPrompt(becauseOfGeneration = true)
resetAiProposition()
+ }
+
+ private suspend fun calculateAiPropositionData() {
+ val isSubjectBlank = fragment.isSubjectBlank()
+ val formattedScript = checkIsEditorBodyEmptyScript.format(
+ INFOMANIAK_SIGNATURE_HTML_CLASS_NAME,
+ INFOMANIAK_FORWARD_QUOTE_HTML_CLASS_NAME,
+ INFOMANIAK_REPLY_QUOTE_HTML_CLASS_NAME,
+ )
+ val isBodyBlank = binding.editorWebView.evaluateJs(formattedScript) == "true"
fragment.safeNavigate(
NewMessageFragmentDirections.actionNewMessageFragmentToAiPropositionFragment(
- isSubjectBlank = fragment.isSubjectBlank(),
- isBodyBlank = binding.editorWebView.isEmptyFlow.value ?: true,
+ isSubjectBlank = isSubjectBlank,
+ isBodyBlank = isBodyBlank,
),
)
}
diff --git a/app/src/main/java/com/infomaniak/mail/ui/newMessage/NewMessageFragment.kt b/app/src/main/java/com/infomaniak/mail/ui/newMessage/NewMessageFragment.kt
index 86559e0a00a..2614d721abd 100644
--- a/app/src/main/java/com/infomaniak/mail/ui/newMessage/NewMessageFragment.kt
+++ b/app/src/main/java/com/infomaniak/mail/ui/newMessage/NewMessageFragment.kt
@@ -21,9 +21,7 @@ package com.infomaniak.mail.ui.newMessage
import android.annotation.SuppressLint
import android.app.Activity
-import android.content.ClipDescription
import android.content.Intent
-import android.content.res.Configuration
import android.os.Bundle
import android.text.InputFilter
import android.text.Spanned
@@ -31,13 +29,11 @@ import android.transition.TransitionManager
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
-import android.webkit.WebView
import android.widget.ListPopupWindow
import android.widget.PopupWindow
import androidx.activity.addCallback
import androidx.appcompat.app.AppCompatActivity
import androidx.appcompat.content.res.AppCompatResources
-import androidx.constraintlayout.widget.Group
import androidx.core.view.forEach
import androidx.core.view.isGone
import androidx.core.view.isVisible
@@ -62,13 +58,14 @@ import com.infomaniak.lib.richhtmleditor.StatusCommand.ITALIC
import com.infomaniak.lib.richhtmleditor.StatusCommand.STRIKE_THROUGH
import com.infomaniak.lib.richhtmleditor.StatusCommand.UNDERLINE
import com.infomaniak.lib.richhtmleditor.StatusCommand.UNORDERED_LIST
+import com.infomaniak.lib.richhtmleditor.executor.JsExecutableMethod
+import com.infomaniak.lib.richhtmleditor.looselyEscapeAsStringLiteralForJs
import com.infomaniak.mail.MatomoMail.MatomoName
import com.infomaniak.mail.MatomoMail.trackAttachmentActionsEvent
import com.infomaniak.mail.MatomoMail.trackNewMessageEvent
import com.infomaniak.mail.MatomoMail.trackScheduleSendEvent
import com.infomaniak.mail.R
import com.infomaniak.mail.data.LocalSettings
-import com.infomaniak.mail.data.LocalSettings.ExternalContent
import com.infomaniak.mail.data.models.Attachment
import com.infomaniak.mail.data.models.AttachmentDisposition
import com.infomaniak.mail.data.models.FeatureFlag
@@ -93,14 +90,21 @@ import com.infomaniak.mail.ui.newMessage.NewMessageViewModel.UiFrom
import com.infomaniak.mail.ui.newMessage.encryption.EncryptionMessageManager
import com.infomaniak.mail.ui.newMessage.encryption.EncryptionViewModel
import com.infomaniak.mail.utils.AccountUtils
-import com.infomaniak.mail.utils.HtmlUtils.processCids
-import com.infomaniak.mail.utils.JsoupParserUtil.jsoupParseWithLog
+import com.infomaniak.mail.utils.HtmlFormatter.Companion.getCustomDarkMode
+import com.infomaniak.mail.utils.HtmlFormatter.Companion.getCustomEditorStyle
+import com.infomaniak.mail.utils.HtmlFormatter.Companion.getCustomStyle
+import com.infomaniak.mail.utils.HtmlFormatter.Companion.getDeletedInlineImagesObserverScript
+import com.infomaniak.mail.utils.HtmlFormatter.Companion.getEditorJsBridgeScript
+import com.infomaniak.mail.utils.HtmlFormatter.Companion.getFixStyleScript
+import com.infomaniak.mail.utils.HtmlFormatter.Companion.getIncludeQuotesScript
+import com.infomaniak.mail.utils.HtmlFormatter.Companion.getReplaceSignatureScript
+import com.infomaniak.mail.utils.HtmlFormatter.Companion.getSetAiContentScript
+import com.infomaniak.mail.utils.MessageBodyUtils.EDITOR_LOCAL_SIGNATURE_ID
+import com.infomaniak.mail.utils.MessageBodyUtils.INFOMANIAK_FORWARD_QUOTE_HTML_CLASS_NAME
+import com.infomaniak.mail.utils.MessageBodyUtils.INFOMANIAK_REPLY_QUOTE_HTML_CLASS_NAME
+import com.infomaniak.mail.utils.MessageBodyUtils.INFOMANIAK_SIGNATURE_HTML_CLASS_NAME
import com.infomaniak.mail.utils.SentryDebug
import com.infomaniak.mail.utils.SignatureUtils
-import com.infomaniak.mail.utils.UiUtils.PRIMARY_COLOR_CODE
-import com.infomaniak.mail.utils.Utils
-import com.infomaniak.mail.utils.WebViewUtils
-import com.infomaniak.mail.utils.WebViewUtils.Companion.destroyAndClearHistory
import com.infomaniak.mail.utils.WebViewUtils.Companion.setupNewMessageWebViewSettings
import com.infomaniak.mail.utils.extensions.AttachmentExt
import com.infomaniak.mail.utils.extensions.AttachmentExt.openAttachment
@@ -110,10 +114,9 @@ import com.infomaniak.mail.utils.extensions.applyWindowInsetsListener
import com.infomaniak.mail.utils.extensions.bindAlertToViewLifecycle
import com.infomaniak.mail.utils.extensions.changeToolbarColorOnScroll
import com.infomaniak.mail.utils.extensions.enableAlgorithmicDarkening
-import com.infomaniak.mail.utils.extensions.getAttributeColor
import com.infomaniak.mail.utils.extensions.ime
-import com.infomaniak.mail.utils.extensions.initWebViewClientAndBridge
-import com.infomaniak.mail.utils.extensions.loadCss
+import com.infomaniak.mail.utils.extensions.initEditorWebviewBridge
+import com.infomaniak.mail.utils.extensions.initEditorWebviewClient
import com.infomaniak.mail.utils.extensions.navigateToDownloadProgressDialog
import com.infomaniak.mail.utils.extensions.systemBars
import com.infomaniak.mail.utils.extensions.valueOrEmpty
@@ -125,14 +128,11 @@ import io.sentry.Sentry
import io.sentry.SentryLevel
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.combine
-import kotlinx.coroutines.flow.filterNotNull
-import kotlinx.coroutines.flow.launchIn
-import kotlinx.coroutines.flow.onEach
+import kotlinx.coroutines.flow.filterNot
import kotlinx.coroutines.launch
import splitties.experimental.ExperimentalSplittiesApi
import java.util.Date
import javax.inject.Inject
-import androidx.appcompat.R as RAndroid
@AndroidEntryPoint
class NewMessageFragment : Fragment() {
@@ -144,6 +144,13 @@ class NewMessageFragment : Fragment() {
// extras aren't yet initialized, so we don't use the `navArgs` here.
requireActivity().intent?.extras?.let(NewMessageActivityArgs::fromBundle) ?: NewMessageActivityArgs()
}
+ private val replaceSignatureScript by lazy { requireContext().getReplaceSignatureScript() }
+ private val includeQuotesScript by lazy { requireContext().getIncludeQuotesScript() }
+ private val deletedInlineImagesObserverScript by lazy { requireContext().getDeletedInlineImagesObserverScript() }
+ private val editorJsBridgeScript by lazy { requireContext().getEditorJsBridgeScript() }
+ private val fixStyle by lazy { requireContext().getFixStyleScript() }
+ private val setAiContentScript by lazy { requireContext().getSetAiContentScript() }
+
private val newMessageFragmentArgs: NewMessageFragmentArgs by navArgs()
private val newMessageViewModel: NewMessageViewModel by activityViewModels()
private val aiViewModel: AiViewModel by activityViewModels()
@@ -155,14 +162,10 @@ class NewMessageFragment : Fragment() {
private var addressListPopupWindow: ListPopupWindow? = null
- private var quoteWebView: WebView? = null
- private var signatureWebView: WebView? = null
-
private val signatureAdapter = SignatureAdapter(::onSignatureClicked)
private val attachmentAdapter inline get() = binding.attachmentsRecyclerView.adapter as AttachmentAdapter
private val newMessageActivity by lazy { requireActivity() as NewMessageActivity }
- private val webViewUtils by lazy { WebViewUtils(requireContext()) }
@Inject
lateinit var editorContentManager: EditorContentManager
@@ -219,7 +222,6 @@ class NewMessageFragment : Fragment() {
bindAlertToViewLifecycle(descriptionDialog)
- setWebViewReference()
initMailbox()
initUi()
initializeDraft()
@@ -232,9 +234,10 @@ class NewMessageFragment : Fragment() {
observeAttachments()
observeImportAttachmentsResult()
observeBodyLoader()
- observeUiSignature()
- observeUiQuote()
observeShimmering()
+ observePlaceholderVisibility()
+ observeQuotesButtonVisibility()
+ observeQuotesInclusion()
setupBackActionHandler()
@@ -361,21 +364,6 @@ class NewMessageFragment : Fragment() {
)
}
- private fun setWebViewReference() {
- quoteWebView = binding.quoteWebView
- signatureWebView = binding.signatureWebView
- }
-
- override fun onConfigurationChanged(newConfig: Configuration) {
- newMessageViewModel.uiSignatureLiveData.value?.let { _ ->
- binding.signatureWebView.reload()
- }
- newMessageViewModel.uiQuoteLiveData.value?.let { _ ->
- binding.quoteWebView.reload()
- }
- super.onConfigurationChanged(newConfig)
- }
-
override fun onDestroyView() {
// This block of code is needed in order to keep and reload the content of the editor across configuration changes.
binding.editorWebView.exportHtml { html ->
@@ -383,10 +371,6 @@ class NewMessageFragment : Fragment() {
}
addressListPopupWindow = null
- quoteWebView?.destroyAndClearHistory()
- quoteWebView = null
- signatureWebView?.destroyAndClearHistory()
- signatureWebView = null
TransitionManager.endTransitions(binding.root)
super.onDestroyView()
_binding = null
@@ -418,9 +402,6 @@ class NewMessageFragment : Fragment() {
toolbar.setNavigationOnClickListener { activity?.onBackPressedDispatcher?.onBackPressed() }
changeToolbarColorOnScroll(appBarLayout, compositionNestedScrollView)
- signatureWebView.enableAlgorithmicDarkening(true)
- quoteWebView.enableAlgorithmicDarkening(true)
-
attachmentsRecyclerView.adapter = AttachmentAdapter(
shouldDisplayCloseButton = true,
onDelete = ::onDeleteAttachment,
@@ -452,6 +433,7 @@ class NewMessageFragment : Fragment() {
})
initEditorUi()
+ setupShowQuotesButton()
viewLifecycleOwner.lifecycleScope.launch {
val mailbox = newMessageViewModel.currentMailbox()
@@ -466,38 +448,48 @@ class NewMessageFragment : Fragment() {
}
private fun initEditorUi() = with(binding) {
+ editorWebView.settings.setupNewMessageWebViewSettings()
+ editorWebView.initEditorWebviewBridge(onInlineImagesDeleted = newMessageViewModel::deleteInlineAttachments)
editorWebView.subscribeToStates(setOf(BOLD, ITALIC, UNDERLINE, STRIKE_THROUGH, UNORDERED_LIST, CREATE_LINK))
setEditorStyle()
- handleEditorPlaceholderVisibility()
-
+ setEditorScript()
editorAiAnimation.setAnimation(R.raw.euria)
-
setToolbarEnabledStatus(false)
- disableButtonsWhenFocusIsLost()
+ removeAllProperties()
+ handleFocusChanges()
}
private fun setEditorStyle() = with(binding.editorWebView) {
enableAlgorithmicDarkening(isEnabled = true)
- if (context.isNightModeEnabled()) addCss(context.loadCss(R.raw.custom_dark_mode))
-
- val customColors = listOf(PRIMARY_COLOR_CODE to context.getAttributeColor(RAndroid.attr.colorPrimary))
- addCss(context.loadCss(R.raw.style, customColors))
- addCss(context.loadCss(R.raw.editor_style, customColors))
+ if (context.isNightModeEnabled()) addCss(context.getCustomDarkMode())
+ addCss(context.getCustomStyle())
+ addCss(context.getCustomEditorStyle())
}
- private fun handleEditorPlaceholderVisibility() {
- val isPlaceholderVisible = combine(
- binding.editorWebView.isEmptyFlow.filterNotNull(),
- newMessageViewModel.isShimmering,
- ) { isEditorEmpty, isShimmering -> isEditorEmpty && !isShimmering }
+ private fun setEditorScript() = with(binding.editorWebView) {
+ addScript(fixStyle)
+ addScript(editorJsBridgeScript)
+ addScript(deletedInlineImagesObserverScript)
- isPlaceholderVisible
- .onEach { isVisible -> binding.newMessagePlaceholder.isVisible = isVisible }
- .launchIn(lifecycleScope)
+ val formattedAiContentScript = setAiContentScript.format(
+ INFOMANIAK_SIGNATURE_HTML_CLASS_NAME,
+ INFOMANIAK_REPLY_QUOTE_HTML_CLASS_NAME,
+ INFOMANIAK_FORWARD_QUOTE_HTML_CLASS_NAME
+ )
+ addScript(formattedAiContentScript)
+ }
+
+ private fun removeAllProperties() = viewLifecycleOwner.lifecycleScope.launch {
+ binding.editorWebView.executeJsMethodWhenEditorIsSetup(JsExecutableMethod("removeAllProperties"))
}
- private fun disableButtonsWhenFocusIsLost() {
- newMessageViewModel.isEditorWebViewFocusedLiveData.observe(viewLifecycleOwner, ::setToolbarEnabledStatus)
+ private fun handleFocusChanges() = with(newMessageViewModel) {
+ isEditorWebViewFocusedLiveData.observe(viewLifecycleOwner) { isFocused ->
+ setToolbarEnabledStatus(isFocused)
+ if (isFocused && isPlaceHolderVisible.value) {
+ setPlaceholderVisibility(isVisible = false)
+ }
+ }
}
private fun setToolbarEnabledStatus(isEnabled: Boolean) {
@@ -529,74 +521,46 @@ class NewMessageFragment : Fragment() {
}
}
- private fun configureUiWithDraftData(draft: Draft) = with(binding) {
-
- // Signature
- signatureWebView.apply {
- settings.setupNewMessageWebViewSettings()
- initWebViewClientAndBridge(
- attachments = emptyList(),
- messageUid = "SIGNATURE-${draft.messageUid}",
- shouldLoadDistantResources = true,
- navigateToNewMessageActivity = null,
- )
- }
- removeSignature.setOnClickListener {
- trackNewMessageEvent(MatomoName.DeleteSignature)
- newMessageViewModel.uiSignatureLiveData.value = null
- }
-
- // Quote
- quoteWebView.apply {
- settings.setupNewMessageWebViewSettings()
- val alwaysShowExternalContent = localSettings.externalContent == ExternalContent.ALWAYS
- initWebViewClientAndBridge(
- attachments = draft.attachments,
- messageUid = "QUOTE-${draft.messageUid}",
- shouldLoadDistantResources = alwaysShowExternalContent || newMessageViewModel.shouldLoadDistantResources(),
- navigateToNewMessageActivity = null,
- )
- }
- removeQuote.setOnClickListener {
- trackNewMessageEvent(MatomoName.DeleteQuote)
- removeInlineAttachmentsUsedInQuote()
- newMessageViewModel.uiQuoteLiveData.value = null
- }
+ private fun configureUiWithDraftData(draft: Draft) = with(binding.editorWebView) {
+ val alwaysShowExternalContent = localSettings.externalContent == LocalSettings.ExternalContent.ALWAYS
+ webViewClient = initEditorWebviewClient(
+ attachments = draft.attachments,
+ shouldLoadDistantResources = alwaysShowExternalContent || newMessageViewModel.shouldLoadDistantResources(),
+ onPageFinished = { notifyPageHasLoaded() },
+ )
}
- private fun removeInlineAttachmentsUsedInQuote() = with(newMessageViewModel) {
- uiQuoteLiveData.value?.let { html ->
- attachmentsLiveData.value?.filterOutHtmlCids(html)?.let { attachmentsLiveData.value = it }
+ private fun observeQuotesButtonVisibility() = viewLifecycleOwner.lifecycleScope.launch {
+ combine(
+ newMessageViewModel.isQuotesButtonVisible,
+ newMessageViewModel.isShimmering.filterNot { it }
+ ) { isQuoteVisible, _ ->
+ isQuoteVisible
+ }.collect { isQuoteButtonVisible ->
+ binding.showQuotesButton.isVisible = isQuoteButtonVisible
}
}
- private fun List.filterOutHtmlCids(html: String): List {
- return buildList {
- addAll(this@filterOutHtmlCids)
-
- jsoupParseWithLog(html).processCids(
- attachments = this@filterOutHtmlCids,
- associateDataToCid = { it },
- onCidImageFound = { attachment, _ ->
- remove(attachment)
- }
- )
+ private fun observePlaceholderVisibility() = viewLifecycleOwner.lifecycleScope.launch {
+ newMessageViewModel.isPlaceHolderVisible.collect { isPlaceholderVisible ->
+ binding.newMessagePlaceholder.isVisible = isPlaceholderVisible
}
}
- private fun WebView.loadSignatureContent(html: String, webViewGroup: Group) {
- val processedHtml = webViewUtils.processSignatureHtmlForDisplay(html, context.isNightModeEnabled())
- loadProcessedContent(processedHtml, webViewGroup)
- }
-
- private fun WebView.loadContent(html: String, webViewGroup: Group) {
- val processedHtml = webViewUtils.processHtmlForDisplay(html = html, isDisplayedInDarkMode = context.isNightModeEnabled())
- loadProcessedContent(processedHtml, webViewGroup)
+ private fun observeQuotesInclusion() = viewLifecycleOwner.lifecycleScope.launch {
+ for (quote in newMessageViewModel.quotesToIncludeChannel) {
+ val escapedQuote = looselyEscapeAsStringLiteralForJs(quote)
+ val includeQuoteScript = includeQuotesScript.format(escapedQuote)
+ binding.editorWebView.evaluateJavascript(includeQuoteScript, null)
+ }
}
- private fun WebView.loadProcessedContent(processedHtml: String, webViewGroup: Group) {
- webViewGroup.isVisible = processedHtml.isNotBlank()
- loadDataWithBaseURL("", processedHtml, ClipDescription.MIMETYPE_TEXT_HTML, Utils.UTF_8, "")
+ private fun setupShowQuotesButton() {
+ binding.showQuotesButton.setOnClickListener {
+ trackNewMessageEvent(MatomoName.ShowQuote)
+ newMessageViewModel.setQuotesButtonVisibility(isVisible = false)
+ newMessageViewModel.includeQuotes()
+ }
}
private fun setupFromField(signatures: List) = with(binding) {
@@ -636,6 +600,24 @@ class NewMessageFragment : Fragment() {
addressListPopupWindow?.dismiss()
}
+ private fun updateBodySignature(signature: Signature) {
+ val selectedSignature = if (signature.isDummy) {
+ "''" // This will represent an empty string in js.
+ } else {
+ val signatureWithClass = signatureUtils.encapsulateSignatureContentWithInfomaniakClass(signature.content)
+ looselyEscapeAsStringLiteralForJs(signatureWithClass)
+ }
+
+ val quotesSelector = ".${INFOMANIAK_REPLY_QUOTE_HTML_CLASS_NAME},.${INFOMANIAK_FORWARD_QUOTE_HTML_CLASS_NAME}"
+ val replaceSignatureScript = replaceSignatureScript.format(
+ EDITOR_LOCAL_SIGNATURE_ID,
+ selectedSignature,
+ quotesSelector
+ )
+
+ binding.editorWebView.evaluateJavascript(replaceSignatureScript, null)
+ }
+
private fun updateSelectedSignatureInFromField(signature: Signature) {
val defaultFormat = if (signature.senderName.isBlank()) {
@@ -765,26 +747,6 @@ class NewMessageFragment : Fragment() {
}
}
- private fun observeUiSignature() = with(binding) {
- newMessageViewModel.uiSignatureLiveData.observe(viewLifecycleOwner) { signature ->
- if (signature == null) {
- signatureGroup.isGone = true
- } else {
- signatureWebView.loadSignatureContent(signature, signatureGroup)
- }
- }
- }
-
- private fun observeUiQuote() = with(binding) {
- newMessageViewModel.uiQuoteLiveData.observe(viewLifecycleOwner) { quote ->
- if (quote == null) {
- quoteGroup.isGone = true
- } else {
- quoteWebView.loadContent(quote, quoteGroup)
- }
- }
- }
-
private fun observeShimmering() = lifecycleScope.launch {
newMessageViewModel.isShimmering.collect(::setShimmerVisibility)
}
@@ -914,7 +876,7 @@ class NewMessageFragment : Fragment() {
if (isTaskRoot) finishAndRemoveTask() else finish()
}
- fun navigateToPropositionFragment() = aiManager.navigateToPropositionFragment()
+ suspend fun navigateToPropositionFragment() = aiManager.navigateToPropositionFragment()
fun closeAiPrompt() = aiManager.closeAiPrompt()
diff --git a/app/src/main/java/com/infomaniak/mail/ui/newMessage/NewMessageViewModel.kt b/app/src/main/java/com/infomaniak/mail/ui/newMessage/NewMessageViewModel.kt
index 93a7fccd522..a89d11d1a5a 100644
--- a/app/src/main/java/com/infomaniak/mail/ui/newMessage/NewMessageViewModel.kt
+++ b/app/src/main/java/com/infomaniak/mail/ui/newMessage/NewMessageViewModel.kt
@@ -77,6 +77,7 @@ import com.infomaniak.mail.data.models.signature.Signature
import com.infomaniak.mail.di.IoDispatcher
import com.infomaniak.mail.di.MainDispatcher
import com.infomaniak.mail.ui.main.SnackbarManager
+import com.infomaniak.mail.ui.newMessage.EditorContentManager.Companion.toSanitizedHtml
import com.infomaniak.mail.ui.newMessage.NewMessageActivity.DraftSaveConfiguration
import com.infomaniak.mail.ui.newMessage.NewMessageEditorManager.EditorAction
import com.infomaniak.mail.ui.newMessage.NewMessageRecipientFieldsManager.FieldType
@@ -86,6 +87,10 @@ import com.infomaniak.mail.utils.DraftInitManager
import com.infomaniak.mail.utils.JsoupParserUtil.jsoupParseWithLog
import com.infomaniak.mail.utils.LocalStorageUtils
import com.infomaniak.mail.utils.MessageBodyUtils
+import com.infomaniak.mail.utils.MessageBodyUtils.EDITOR_LOCAL_SIGNATURE_ID
+import com.infomaniak.mail.utils.MessageBodyUtils.INFOMANIAK_SIGNATURE_HTML_CLASS_NAME
+import com.infomaniak.mail.utils.MessageBodyUtils.isHtmlBlank
+import com.infomaniak.mail.utils.MessageBodyUtils.splitSignatureAndQuoteFromBody
import com.infomaniak.mail.utils.SentryDebug
import com.infomaniak.mail.utils.SharedUtils
import com.infomaniak.mail.utils.SignatureUtils
@@ -122,6 +127,7 @@ import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.mapLatest
@@ -161,8 +167,9 @@ class NewMessageViewModel @Inject constructor(
//region Initial data
private var initialBody: BodyContentPayload = BodyContentPayload.emptyBody()
- private var initialSignature: String? = null
- private var initialQuote: String? = null
+ private var initialSignature: BodyContentPayload? = null
+ // It is always sanitized to safely be inserted when the user clicks on "show quotes" and it's only set in initDraftAndViewModel
+ private var initialSanitizedQuote: String? = null
//endregion
//region UI data
@@ -171,8 +178,6 @@ class NewMessageViewModel @Inject constructor(
val ccLiveData = MutableLiveData()
val bccLiveData = MutableLiveData()
val attachmentsLiveData = MutableLiveData>()
- val uiSignatureLiveData = MutableLiveData()
- val uiQuoteLiveData = MutableLiveData()
inline val allRecipients get() = toLiveData.valueOrEmpty() + ccLiveData.valueOrEmpty() + bccLiveData.valueOrEmpty()
//endregion
@@ -222,24 +227,32 @@ class NewMessageViewModel @Inject constructor(
private val _isShimmering = MutableStateFlow(true)
val isShimmering: StateFlow = _isShimmering
+ private val _isQuotesButtonVisible = MutableStateFlow(false)
+ val isQuotesButtonVisible: StateFlow = _isQuotesButtonVisible.asStateFlow()
+
+ private var areQuotesIncluded = false
+ private val _quotesToIncludeChannel: Channel = Channel(capacity = CONFLATED)
+ val quotesToIncludeChannel: ReceiveChannel = _quotesToIncludeChannel
+
+ private val _isPlaceHolderVisible = MutableStateFlow(false)
+ val isPlaceHolderVisible: StateFlow = _isPlaceHolderVisible.asStateFlow()
+
//region Check mailbox existence
private val exitSignal: CompletableJob = Job()
private val mailboxRefFlow = MutableSharedFlow(
replay = 1,
- onBufferOverflow = BufferOverflow.DROP_OLDEST
+ onBufferOverflow = BufferOverflow.DROP_OLDEST,
)
- private val _currentMailboxFlow: Flow = mailboxRefFlow
- .mapLatest {
- val mailbox = mailboxController.getMailbox(it.userId, it.mailboxId)
- if (mailbox == null) {
- exitSignal.complete()
- awaitCancellation()
- }
- mailbox
+ private val _currentMailboxFlow: Flow = mailboxRefFlow.mapLatest {
+ val mailbox = mailboxController.getMailbox(it.userId, it.mailboxId)
+ if (mailbox == null) {
+ exitSignal.complete()
+ awaitCancellation()
}
- .shareIn(viewModelScope, SharingStarted.Lazily, replay = 1)
+ mailbox
+ }.shareIn(viewModelScope, SharingStarted.Lazily, replay = 1)
val currentUserIdFlow = _currentMailboxFlow
.map { it.userId }
@@ -321,30 +334,69 @@ class NewMessageViewModel @Inject constructor(
}.getOrNull()
draft?.let {
-
it.flagRecipientsAsAutomaticallyEntered()
dismissNotification()
markAsRead(currentMailbox(), realm)
realm.write { DraftController.upsertDraftBlocking(it, realm = this) }
- it.saveSnapshot(initialBody.content)
it.initLiveData(signatures)
- _isShimmering.emit(false)
+ processBodyAndInitializeEditorContent(it)
+
+ _isShimmering.emit(false)
initResult.postValue(InitResult(it, signatures))
}
emit(draft)
}
+ private fun processBodyAndInitializeEditorContent(draft: Draft) {
+ val sanitizedBody = initialBody.toSanitizedHtml()
+ initEditorElementsVisibility(sanitizedBody)
+
+ val sanitizedSignature = initialSignature?.toSanitizedHtml()
+ val sanitizedBodyWithoutQuotes = sanitizedBody.mergeWithSignature(sanitizedSignature)
+ val sanitizedBodyContentWithoutQuotes = BodyContentPayload(sanitizedBodyWithoutQuotes, BodyContentType.HTML_SANITIZED)
+ /**
+ * We load the body into the editor without its quotes to improve performances. We load them only if the user expands the
+ * quotes section. Anyhow, the quotes will always be added before executing the draft action inside [addMissingQuotes].
+ */
+ editorBodyInitializer.postValue(sanitizedBodyContentWithoutQuotes)
+
+ val sanitizedBodyWithQuotes = sanitizedBodyWithoutQuotes.mergeWithQuotes(initialSanitizedQuote)
+
+ // We save the initial snapshot with its quotes, because quotes are added during snapshot comparison, ensuring it works
+ // whether the user included them or not.
+ draft.saveSnapshot(sanitizedBodyWithQuotes)
+ }
+
+ private fun initEditorElementsVisibility(body: String) {
+ setPlaceholderVisibility(isVisible = body.isHtmlBlank())
+ if (initialSanitizedQuote != null) setQuotesButtonVisibility(isVisible = true)
+ }
+
private suspend fun getExistingDraft(localUuid: String?): Draft? {
+
+ fun String.sanitize(): String {
+ return BodyContentPayload(this, BodyContentType.HTML_UNSANITIZED).toSanitizedHtml()
+ }
+
return getLocalOrRemoteDraft(localUuid)?.also { draft ->
saveNavArgsToSavedState(draft.localUuid)
if (draft.identityId.isNullOrBlank()) {
draft.identityId = currentMailbox().getDefaultSignatureWithFallback().id.toString()
}
- splitSignatureAndQuoteFromBody(draft)
+
+ val (body, signature, quote) = splitSignatureAndQuoteFromBody(draft)
+ initialBody = body
+ if (signature != null) {
+ signature.getElementsByClass(INFOMANIAK_SIGNATURE_HTML_CLASS_NAME).attr("id", EDITOR_LOCAL_SIGNATURE_ID)
+ initialSignature = BodyContentPayload(signature.outerHtml(), BodyContentType.HTML_UNSANITIZED)
+ }
+ initialSanitizedQuote = quote?.outerHtml()?.sanitize()
+
+ return draft
}
}
@@ -366,31 +418,42 @@ class NewMessageViewModel @Inject constructor(
if (hasFailedFetching) return@let
- setReplyForwardDraftValues(draft = this, fullMessage)
+ initializeReplyForwardDraftValues(draft = this, fullMessage)
+ initialSanitizedQuote = draftInitManager.createQuote(draftMode, fullMessage, attachments)?.let {
+ BodyContentPayload(it, BodyContentType.HTML_UNSANITIZED).toSanitizedHtml()
+ }
}
}
}
+ val signature: Signature
with(draftInitManager) {
- val signature = chooseSignature(currentMailbox().email, signatures, draftMode, previousMessage)
+ signature = chooseSignature(currentMailbox().email, signatures, draftMode, previousMessage)
setSignatureIdentity(signature)
- if (signature.content.isNotEmpty()) {
- initialSignature = signatureUtils.encapsulateSignatureContentWithInfomaniakClass(signature.content)
- }
}
+ val encapsulatedSignature = signatureUtils.encapsulateSignatureContentWithInfomaniakClass(signature.content)
+ initialSignature = BodyContentPayload(encapsulatedSignature, BodyContentType.HTML_UNSANITIZED)
populateWithExternalMailDataIfNeeded(draft = this, intent)
}
- private suspend fun setReplyForwardDraftValues(draft: Draft, fullMessage: Message) {
+ private fun String.mergeWithSignature(signature: String?): String {
+ return if (signature.isNullOrEmpty()) this else this + signature
+ }
+
+ private fun String.mergeWithQuotes(quote: String?): String {
+ return if (quote.isNullOrEmpty()) this else this + quote
+ }
+
+ /**
+ * Initializes some [draft] values related to reply, reply all and forward, when creating a new draft from scratch.
+ */
+ private suspend fun initializeReplyForwardDraftValues(draft: Draft, fullMessage: Message) {
with(draftInitManager) {
draft.setPreviousMessage(draftMode = draftMode, previousMessage = fullMessage)
}
- val quote = draftInitManager.createQuote(draftMode, fullMessage, draft.attachments)
- if (quote != null) initialQuote = quote
-
if (fullMessage.body == null) {
SentryLog.e(TAG, "The message we're trying to reply to has an unexpected null body") { scope ->
scope.setExtra("Message resource", fullMessage.resource)
@@ -424,56 +487,6 @@ class NewMessageViewModel @Inject constructor(
savedStateHandle[NewMessageActivityArgs::draftResource.name] = draftResource
}
- private fun splitSignatureAndQuoteFromBody(draft: Draft) {
- val remoteBody = draft.body
- if (remoteBody.isEmpty()) return
-
- val (body, signature, quote) = when (draft.mimeType) {
- Utils.TEXT_PLAIN -> BodyData(
- body = BodyContentPayload(remoteBody, BodyContentType.TEXT_PLAIN_WITHOUT_HTML),
- signature = null,
- quote = null
- )
- Utils.TEXT_HTML -> splitSignatureAndQuoteFromHtml(remoteBody)
- else -> error("Cannot load an email which is not of type text/plain or text/html")
- }
-
- initialBody = body
- initialSignature = signature
- initialQuote = quote
- }
-
- private fun splitSignatureAndQuoteFromHtml(draftBody: String): BodyData {
-
- fun Document.split(divClassName: String, defaultValue: String): Pair {
- return getElementsByClass(divClassName).firstOrNull()?.let {
- it.remove()
- val first = body().html()
- val second = if (it.html().isBlank()) null else it.outerHtml()
- first to second
- } ?: (defaultValue to null)
- }
-
- fun String.lastIndexOfOrMax(string: String): Int {
- val index = lastIndexOf(string)
- return if (index == -1) Int.MAX_VALUE else index
- }
-
- val doc = jsoupParseWithLog(draftBody).also { it.outputSettings().prettyPrint(false) }
-
- val (bodyWithQuote, signature) = doc.split(MessageBodyUtils.INFOMANIAK_SIGNATURE_HTML_CLASS_NAME, draftBody)
-
- val replyPosition = draftBody.lastIndexOfOrMax(MessageBodyUtils.INFOMANIAK_REPLY_QUOTE_HTML_CLASS_NAME)
- val forwardPosition = draftBody.lastIndexOfOrMax(MessageBodyUtils.INFOMANIAK_FORWARD_QUOTE_HTML_CLASS_NAME)
- val (body, quote) = if (replyPosition < forwardPosition) {
- doc.split(MessageBodyUtils.INFOMANIAK_REPLY_QUOTE_HTML_CLASS_NAME, bodyWithQuote)
- } else {
- doc.split(MessageBodyUtils.INFOMANIAK_FORWARD_QUOTE_HTML_CLASS_NAME, bodyWithQuote)
- }
-
- return BodyData(BodyContentPayload(body, BodyContentType.HTML_UNSANITIZED), signature, quote)
- }
-
private suspend fun populateWithExternalMailDataIfNeeded(draft: Draft, intent: Intent) {
when (intent.action) {
Intent.ACTION_SEND -> handleSingleSendIntent(draft, intent)
@@ -523,13 +536,21 @@ class NewMessageViewModel @Inject constructor(
cc = cc.toSet(),
bcc = bcc.toSet(),
subject = subject,
- uiBody = uiBodyValue,
+ uiBody = uiBodyValue.normalizeCarriageReturns(),
isEncrypted = isEncrypted,
encryptionPassword = encryptionKey ?: "",
attachmentsLocalUuids = attachments.mapTo(mutableSetOf()) { it.localUuid },
)
}
+ /**
+ * The editor will always export code with `\n` even when we provided `\r\n` as input. To not fail snapshot comparison, we
+ * need to make sure we save the snapshots with `\n` only.
+ */
+ private fun String.normalizeCarriageReturns(): String {
+ return replace("\r\n", "\n")
+ }
+
private suspend fun Draft.initLiveData(signatures: List) {
val draftSignature = signatures.singleOrNull { it.id == identityId?.toInt() }
@@ -548,11 +569,6 @@ class NewMessageViewModel @Inject constructor(
attachmentsLiveData.postValue(attachments)
- editorBodyInitializer.postValue(initialBody)
-
- uiSignatureLiveData.postValue(initialSignature)
- uiQuoteLiveData.postValue(initialQuote)
-
if (cc.isNotEmpty() || bcc.isNotEmpty()) {
otherRecipientsFieldsAreEmpty.postValue(false)
initializeFieldsAsOpen.postValue(true)
@@ -561,6 +577,19 @@ class NewMessageViewModel @Inject constructor(
isEncryptionActivated.postValue(isEncrypted)
}
+ fun setQuotesButtonVisibility(isVisible: Boolean) {
+ _isQuotesButtonVisible.value = isVisible
+ }
+
+ fun includeQuotes() {
+ initialSanitizedQuote?.let { _quotesToIncludeChannel.trySend(it) }
+ areQuotesIncluded = true
+ }
+
+ fun setPlaceholderVisibility(isVisible: Boolean) {
+ _isPlaceHolderVisible.value = isVisible
+ }
+
private suspend fun getLocalOrRemoteDraft(localUuid: String?): Draft? {
@Suppress("UNUSED_PARAMETER")
@@ -839,14 +868,6 @@ class NewMessageViewModel @Inject constructor(
if (cc.isEmpty() && bcc.isEmpty()) otherRecipientsFieldsAreEmpty.value = true
}
- fun updateBodySignature(signature: Signature) {
- uiSignatureLiveData.value = if (signature.isDummy) {
- null
- } else {
- signatureUtils.encapsulateSignatureContentWithInfomaniakClass(signature.content)
- }
- }
-
fun uploadAttachmentsToServer(uiAttachments: List) = viewModelScope.launch(ioDispatcher) {
val localUuid = draftLocalUuid ?: return@launch
val realm = mailboxContentRealm()
@@ -905,18 +926,27 @@ class NewMessageViewModel @Inject constructor(
}
}
+ private fun String.addMissingQuotes(): String {
+ return if (areQuotesIncluded || initialSanitizedQuote == null) {
+ this
+ } else {
+ this + initialSanitizedQuote
+ }
+ }
+
private suspend fun executeDraftAction(draftSaveConfiguration: DraftSaveConfiguration) = with(draftSaveConfiguration) {
val localUuid = draftLocalUuid ?: return@with
val subject = subjectValue.ifBlank { null }?.take(SUBJECT_MAX_LENGTH)
- if (action == DraftAction.SAVE && isSnapshotTheSame(subject, uiBodyValue)) {
+ val bodyWithQuotes = uiBodyValue.addMissingQuotes()
+ if (action == DraftAction.SAVE && isSnapshotTheSame(subject, bodyWithQuotes)) {
if (isFinishing && isNewMessage) removeDraftFromRealm(localUuid)
return@with
}
val hasFailed = mailboxContentRealm().write {
DraftController.getDraftBlocking(localUuid, realm = this)
- ?.updateDraftBeforeSavingRemotely(action, isFinishing, subject, uiBodyValue, realm = this@write)
+ ?.updateDraftBeforeSavingRemotely(action, isFinishing, subject, bodyWithQuotes, realm = this@write)
?: return@write true
return@write false
}
@@ -964,8 +994,6 @@ class NewMessageViewModel @Inject constructor(
subject = subjectValue
- body = uiBodyValue + (uiSignatureLiveData.value ?: "") + (uiQuoteLiveData.value ?: "")
-
/**
* If we are opening for the 1st time an existing Draft created somewhere else
* (ex: webmail), we need to create the link between the Draft and its Message.
@@ -983,6 +1011,31 @@ class NewMessageViewModel @Inject constructor(
copyFromRealm().saveSnapshot(uiBodyValue)
isNewMessage = false
}
+
+ body = removeUnwantedHtml(uiBodyValue)
+ }
+
+ /**
+ * We filter out some HTML elements that we don't want to send with the email.
+ */
+ private fun removeUnwantedHtml(html: String): String {
+ val doc = jsoupParseWithLog(html)
+ // This assures that jsoup doesn't add line breaks that will impact the snapshot comparison
+ doc.outputSettings().prettyPrint(false)
+
+ // Remove id used for replacing signature.
+ doc.getElementById(EDITOR_LOCAL_SIGNATURE_ID)?.removeAttr("id")
+
+ // If the user deleted the quotes' text, remove the quotes' div so user doesn't write in it
+ // (the text could get hidden later with the show quotes button).
+ doc.removeEmptyElements(MessageBodyUtils.INFOMANIAK_REPLY_QUOTE_HTML_CLASS_NAME)
+ doc.removeEmptyElements(MessageBodyUtils.INFOMANIAK_FORWARD_QUOTE_HTML_CLASS_NAME)
+ return doc.html()
+ }
+
+ private fun Document.removeEmptyElements(className: String) {
+ val elements = getElementsByClass(className)
+ elements.forEach { if (it.text().isEmpty()) it.remove() }
}
private fun Draft.updateDraftAttachmentsWithLiveData(uiAttachments: List, step: String) {
@@ -1087,6 +1140,13 @@ class NewMessageViewModel @Inject constructor(
)
}
+ fun deleteInlineAttachments(cids: List) {
+ val cidsToDelete = cids.map { it.removePrefix("cid:") }
+ val attachmentsData = attachmentsLiveData.value ?: return
+ val newAttachments = attachmentsData.filter { attachment -> !cidsToDelete.contains(attachment.contentId) }
+ attachmentsLiveData.postValue(newAttachments)
+ }
+
enum class ImportationResult {
SUCCESS,
ATTACHMENTS_TOO_BIG,
@@ -1123,8 +1183,6 @@ class NewMessageViewModel @Inject constructor(
private data class SubjectAndBodyData(val subject: String, val body: String, val expirationId: Int)
- private data class BodyData(val body: BodyContentPayload, val signature: String?, val quote: String?)
-
companion object {
private val TAG = NewMessageViewModel::class.java.simpleName
diff --git a/app/src/main/java/com/infomaniak/mail/utils/HtmlFormatter.kt b/app/src/main/java/com/infomaniak/mail/utils/HtmlFormatter.kt
index 85e7c24cf22..0960462b487 100644
--- a/app/src/main/java/com/infomaniak/mail/utils/HtmlFormatter.kt
+++ b/app/src/main/java/com/infomaniak/mail/utils/HtmlFormatter.kt
@@ -1,6 +1,6 @@
/*
* Infomaniak Mail - Android
- * Copyright (C) 2023-2025 Infomaniak Network SA
+ * Copyright (C) 2023-2026 Infomaniak Network SA
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
@@ -214,12 +214,17 @@ class HtmlFormatter(private val html: String) {
fun Context.getImproveRenderingStyle(): String = loadCss(R.raw.improve_rendering)
+ fun Context.getMessageDisplayStyle(): String = loadCss(R.raw.message_display_style)
+
fun Context.getCustomStyle(): String = loadCss(
R.raw.style,
listOf(PRIMARY_COLOR_CODE to getAttributeColor(RAndroid.attr.colorPrimary)),
)
- fun Context.getSignatureMarginStyle(): String = loadCss(R.raw.signature_margins)
+ fun Context.getCustomEditorStyle(): String = loadCss(
+ R.raw.editor_style,
+ listOf(PRIMARY_COLOR_CODE to getAttributeColor(RAndroid.attr.colorPrimary))
+ )
fun Context.getPrintMailStyle(): String = loadCss(R.raw.print_email)
@@ -228,12 +233,30 @@ class HtmlFormatter(private val html: String) {
listOf("MESSAGE_SELECTOR" to "#$KMAIL_MESSAGE_ID")
)
+ fun Context.getIncludeQuotesScript(): String = loadScript(R.raw.include_quotes_script)
+
+ fun Context.getDeletedInlineImagesObserverScript(): String = loadScript(R.raw.deleted_inline_images_observer)
+
+ fun Context.getReplaceSignatureScript(): String = loadScript(R.raw.replace_signature_script)
+
fun Context.getFixStyleScript(): String {
return loadScript(R.raw.fix_email_style)
}
- fun Context.getJsBridgeScript(): String {
- return loadScript(R.raw.javascript_bridge)
+ fun Context.getMessageDisplayJavascriptBridge(): String {
+ return loadScript(R.raw.message_display_javascript_bridge)
+ }
+
+ fun Context.getEditorJsBridgeScript(): String {
+ return loadScript(R.raw.editor_javascript_bridge)
+ }
+
+ fun Context.getCheckIsEditorBodyEmptyScript(): String {
+ return loadScript(R.raw.check_is_editor_body_empty)
+ }
+
+ fun Context.getSetAiContentScript(): String {
+ return loadScript(R.raw.set_ai_content_script)
}
}
}
diff --git a/app/src/main/java/com/infomaniak/mail/utils/HtmlUtils.kt b/app/src/main/java/com/infomaniak/mail/utils/HtmlUtils.kt
index ca6bd471f37..49e74c64cf3 100644
--- a/app/src/main/java/com/infomaniak/mail/utils/HtmlUtils.kt
+++ b/app/src/main/java/com/infomaniak/mail/utils/HtmlUtils.kt
@@ -1,6 +1,6 @@
/*
* Infomaniak Mail - Android
- * Copyright (C) 2024 Infomaniak Network SA
+ * Copyright (C) 2024-2026 Infomaniak Network SA
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
@@ -18,7 +18,7 @@
package com.infomaniak.mail.utils
import com.infomaniak.mail.data.models.Attachment
-import com.infomaniak.mail.ui.main.thread.MessageWebViewClient
+import com.infomaniak.mail.ui.main.thread.webViewClient.MessageWebViewClient
import org.jsoup.nodes.Document
import org.jsoup.nodes.Element
diff --git a/app/src/main/java/com/infomaniak/mail/utils/MessageBodyUtils.kt b/app/src/main/java/com/infomaniak/mail/utils/MessageBodyUtils.kt
index 85b9f1b2692..08d2ff390ce 100644
--- a/app/src/main/java/com/infomaniak/mail/utils/MessageBodyUtils.kt
+++ b/app/src/main/java/com/infomaniak/mail/utils/MessageBodyUtils.kt
@@ -1,6 +1,6 @@
/*
* Infomaniak Mail - Android
- * Copyright (C) 2023-2024 Infomaniak Network SA
+ * Copyright (C) 2023-2026 Infomaniak Network SA
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
@@ -18,23 +18,30 @@
package com.infomaniak.mail.utils
import android.content.Context
+import com.infomaniak.mail.data.models.draft.Draft
import com.infomaniak.mail.data.models.message.Body
import com.infomaniak.mail.data.models.message.Message
import com.infomaniak.mail.data.models.message.SubBody
+import com.infomaniak.mail.ui.newMessage.BodyContentPayload
+import com.infomaniak.mail.ui.newMessage.BodyContentType
import com.infomaniak.mail.utils.JsoupParserUtil.jsoupParseWithLog
import com.infomaniak.mail.utils.JsoupParserUtil.measureAndLogMemoryUsage
import com.infomaniak.mail.utils.PrintHeaderUtils.createPrintHeader
+import com.infomaniak.mail.utils.extensions.htmlToText
import io.sentry.Sentry
import io.sentry.SentryLevel
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.ensureActive
import org.jsoup.nodes.Document
+import org.jsoup.nodes.Element
object MessageBodyUtils {
const val INFOMANIAK_SIGNATURE_HTML_CLASS_NAME = "editorUserSignature"
const val INFOMANIAK_REPLY_QUOTE_HTML_CLASS_NAME = "ik_mail_quote"
const val INFOMANIAK_FORWARD_QUOTE_HTML_CLASS_NAME = "forwardContentMessage"
+ // This ID is used for finding the signature to replace it. REMOVE before sending the email.
+ const val EDITOR_LOCAL_SIGNATURE_ID = "ikEditorLocalSignature"
private const val QUOTE_DETECTION_TIMEOUT = 1_500L
@@ -63,6 +70,7 @@ object MessageBodyUtils {
"[name=\"quote\"]", // GMX
)
+ //region Split
suspend fun splitContentAndQuote(body: Body): SplitBody {
val bodyContent = body.value
@@ -113,13 +121,69 @@ object MessageBodyUtils {
return htmlDocument.outerHtml() to quotes
}
- fun addPrintHeader(context: Context, message: Message, htmlDocument: Document) {
- htmlDocument.body().apply {
- attr("style", "margin: 40px")
- insertChildren(0, createPrintHeader(context, message))
+ fun String.isHtmlBlank(): Boolean {
+ return htmlToText().isBlank()
+ }
+
+ private fun splitSignatureAndQuoteFromHtml(body: String): BodyData {
+ val doc = jsoupParseWithLog(body)
+ return splitSignatureAndQuoteFromHtml(doc)
+ }
+
+ /**
+ * This only takes into account infomaniak's quotes and signatures.
+ */
+ fun splitSignatureAndQuoteFromHtml(document: Document): BodyData {
+
+ fun Document.split(divClassName: String, defaultValue: Element): Pair {
+ // We need to use select and not getElementByClass because getElementByClass adds a \n after every
.
+ // The function remove() also adds \n after each
of it.
+ // So we need to use select, save the second part of the split first, and then do remove, to save the correct value
+ // for the snapshot to compare.
+ return select(".$divClassName").firstOrNull()?.let {
+ val second = if (it.html().isBlank()) null else it
+ it.remove()
+ document to second
+ } ?: (defaultValue to null)
+ }
+
+ document.outputSettings().prettyPrint(false)
+
+ val (bodyWithQuote, signature) = document.split(INFOMANIAK_SIGNATURE_HTML_CLASS_NAME, document)
+
+ val replyPosition = document.getElementPositionOrMax(".$INFOMANIAK_REPLY_QUOTE_HTML_CLASS_NAME")
+ val forwardPosition = document.getElementPositionOrMax(".$INFOMANIAK_FORWARD_QUOTE_HTML_CLASS_NAME")
+ val (body, quote) = if (replyPosition < forwardPosition) {
+ document.split(INFOMANIAK_REPLY_QUOTE_HTML_CLASS_NAME, bodyWithQuote)
+ } else {
+ document.split(INFOMANIAK_FORWARD_QUOTE_HTML_CLASS_NAME, bodyWithQuote)
+ }
+
+ return BodyData(BodyContentPayload(body.html(), BodyContentType.HTML_UNSANITIZED), signature, quote)
+ }
+
+ fun splitSignatureAndQuoteFromBody(draft: Draft): BodyData {
+ val remoteBody = draft.body
+ if (remoteBody.isEmpty()) return BodyData(BodyContentPayload.emptyBody(), null, null)
+
+ val (body, signature, quote) = when (draft.mimeType) {
+ Utils.TEXT_PLAIN -> BodyData(
+ body = BodyContentPayload(remoteBody, BodyContentType.TEXT_PLAIN_WITHOUT_HTML),
+ signature = null,
+ quote = null
+ )
+ Utils.TEXT_HTML -> splitSignatureAndQuoteFromHtml(remoteBody)
+ else -> error("Cannot load an email which is not of type text/plain or text/html")
}
+
+ return BodyData(body, signature, quote)
}
+ private fun Document.getElementPositionOrMax(className: String): Int {
+ return this.selectFirst(className)?.sourceRange()?.start()?.pos() ?: Int.MAX_VALUE
+ }
+ //endregion
+
fun mergeSplitBodyAndSubBodies(body: String, subBodies: List): String {
return body + formatSubBodiesContent(subBodies)
}
@@ -137,6 +201,13 @@ object MessageBodyUtils {
return subBodiesContent
}
+ fun addPrintHeader(context: Context, message: Message, htmlDocument: Document) {
+ htmlDocument.body().apply {
+ attr("style", "margin: 40px")
+ insertChildren(0, createPrintHeader(context, message))
+ }
+ }
+
//region Utils
/**
* Some Email clients rename CSS classes to prefix them.
@@ -156,4 +227,6 @@ object MessageBodyUtils {
override fun hashCode(): Int = 31 * content.hashCode() + quote.hashCode()
}
+
+ data class BodyData(val body: BodyContentPayload, val signature: Element?, val quote: Element?)
}
diff --git a/app/src/main/java/com/infomaniak/mail/utils/SignatureUtils.kt b/app/src/main/java/com/infomaniak/mail/utils/SignatureUtils.kt
index 3528eee9a84..e0bcd89f6fd 100644
--- a/app/src/main/java/com/infomaniak/mail/utils/SignatureUtils.kt
+++ b/app/src/main/java/com/infomaniak/mail/utils/SignatureUtils.kt
@@ -17,24 +17,14 @@
*/
package com.infomaniak.mail.utils
-import android.content.Context
-import com.infomaniak.mail.R
-import com.infomaniak.mail.utils.extensions.loadCss
+import com.infomaniak.mail.utils.MessageBodyUtils.EDITOR_LOCAL_SIGNATURE_ID
+import com.infomaniak.mail.utils.MessageBodyUtils.INFOMANIAK_SIGNATURE_HTML_CLASS_NAME
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
-class SignatureUtils @Inject constructor(appContext: Context) {
-
- private val signatureMargins by lazy { appContext.loadCss(R.raw.signature_margins) }
-
+class SignatureUtils @Inject constructor() {
fun encapsulateSignatureContentWithInfomaniakClass(signatureContent: String): String {
- val verticalMarginsCss = signatureMargins
- val verticalMarginAttributes = extractAttributesFromMarginCss(verticalMarginsCss)
- return """$signatureContent
"""
- }
-
- private fun extractAttributesFromMarginCss(verticalMarginsCss: String): String {
- return Regex("""\{(.*)\}""").find(verticalMarginsCss)!!.groupValues[1]
+ return """$signatureContent
"""
}
}
diff --git a/app/src/main/java/com/infomaniak/mail/utils/WebViewUtils.kt b/app/src/main/java/com/infomaniak/mail/utils/WebViewUtils.kt
index de1cb26180e..5dede462ae7 100644
--- a/app/src/main/java/com/infomaniak/mail/utils/WebViewUtils.kt
+++ b/app/src/main/java/com/infomaniak/mail/utils/WebViewUtils.kt
@@ -21,21 +21,24 @@ import android.annotation.SuppressLint
import android.content.Context
import android.view.MotionEvent
import android.view.ViewParent
-import android.webkit.JavascriptInterface
import android.webkit.WebSettings
import android.webkit.WebSettings.LOAD_CACHE_ELSE_NETWORK
import android.webkit.WebView
import com.infomaniak.mail.R
+import com.infomaniak.mail.data.models.javascriptBridge.EditorJavascriptBridge
+import com.infomaniak.mail.data.models.javascriptBridge.MessageDisplayJavascriptBridge
import com.infomaniak.mail.utils.HtmlFormatter.Companion.getCustomDarkMode
import com.infomaniak.mail.utils.HtmlFormatter.Companion.getCustomStyle
import com.infomaniak.mail.utils.HtmlFormatter.Companion.getFixStyleScript
import com.infomaniak.mail.utils.HtmlFormatter.Companion.getImproveRenderingStyle
-import com.infomaniak.mail.utils.HtmlFormatter.Companion.getJsBridgeScript
+import com.infomaniak.mail.utils.HtmlFormatter.Companion.getMessageDisplayJavascriptBridge
+import com.infomaniak.mail.utils.HtmlFormatter.Companion.getMessageDisplayStyle
import com.infomaniak.mail.utils.HtmlFormatter.Companion.getPrintMailStyle
import com.infomaniak.mail.utils.HtmlFormatter.Companion.getResizeScript
-import com.infomaniak.mail.utils.HtmlFormatter.Companion.getSignatureMarginStyle
import com.infomaniak.mail.utils.extensions.enableAlgorithmicDarkening
import com.infomaniak.mail.utils.extensions.loadCss
+import kotlinx.coroutines.suspendCancellableCoroutine
+import kotlin.coroutines.resume
import kotlin.math.abs
class WebViewUtils(context: Context) {
@@ -43,12 +46,12 @@ class WebViewUtils(context: Context) {
private val customDarkMode by lazy { context.getCustomDarkMode() }
private val improveRenderingStyle by lazy { context.getImproveRenderingStyle() }
private val customStyle by lazy { context.getCustomStyle() }
- private val signatureVerticalMargin by lazy { context.getSignatureMarginStyle() }
+ private val messageDisplayStyle by lazy { context.getMessageDisplayStyle() }
private val printMailStyle by lazy { context.getPrintMailStyle() }
private val resizeScript by lazy { context.getResizeScript() }
private val fixStyleScript by lazy { context.getFixStyleScript() }
- private val jsBridgeScript by lazy { context.getJsBridgeScript() }
+ private val messageDisplayJsBridgeScript by lazy { context.getMessageDisplayJavascriptBridge() }
fun processHtmlForPrint(
html: String,
@@ -68,85 +71,44 @@ class WebViewUtils(context: Context) {
return@with inject()
}
- fun processSignatureHtmlForDisplay(
- html: String,
- isDisplayedInDarkMode: Boolean,
- ): String = with(HtmlFormatter(html)) {
- addCommonDisplayContent(isDisplayedInDarkMode)
- registerCss(signatureVerticalMargin)
- return@with inject()
- }
-
private fun HtmlFormatter.addCommonDisplayContent(isDisplayedInDarkMode: Boolean) {
if (isDisplayedInDarkMode) registerCss(customDarkMode, DARK_BACKGROUND_STYLE_ID)
registerCss(improveRenderingStyle)
registerCss(customStyle)
+ registerCss(messageDisplayStyle)
registerMetaViewPort()
registerScript(resizeScript)
registerScript(fixStyleScript)
- registerScript(jsBridgeScript)
+ registerScript(messageDisplayJsBridgeScript)
registerBodyEncapsulation()
registerBreakLongWords()
}
- class JavascriptBridge(private val onWebViewFinishedLoading: (() -> Unit)? = null) {
-
- @JavascriptInterface
- fun reportOverScroll(clientWidth: Int, scrollWidth: Int, messageUid: String) {
- SentryDebug.sendOverScrolledMessage(clientWidth, scrollWidth, messageUid)
- }
-
- @JavascriptInterface
- fun reportError(
- errorName: String,
- errorMessage: String,
- errorStack: String,
- scriptFirstLine: String,
- messageUid: String,
- ) {
- val correctErrorStack = fixStackTraceLineNumber(errorStack, scriptFirstLine)
- SentryDebug.sendJavaScriptError(errorName, errorMessage, correctErrorStack, messageUid)
- }
-
- @JavascriptInterface
- fun webviewFinishedLoading() {
- onWebViewFinishedLoading?.invoke()
- }
-
- private fun fixStackTraceLineNumber(errorStack: String, scriptFirstLine: String): String {
- var correctErrorStack = errorStack
- val matches = "about:blank:([0-9]+):".toRegex().findAll(correctErrorStack)
- matches.forEach { match ->
- val lineNumber = match.groupValues[1]
- val newLineNumber = lineNumber.toInt() - scriptFirstLine.toInt() + 1
- correctErrorStack = correctErrorStack.replace(match.groupValues[0], "about:blank:$newLineNumber:")
- }
- return correctErrorStack
- }
- }
-
companion object {
private const val DARK_BACKGROUND_STYLE_ID = "dark_background_style"
- lateinit var jsBridge: JavascriptBridge // TODO: Avoid excessive memory consumption with injection
+ lateinit var messageDisplayJsBridge: MessageDisplayJavascriptBridge // TODO: Avoid excessive memory consumption with injection
+ lateinit var editorJsBridge: EditorJavascriptBridge
- fun initJavascriptBridge(onWebViewFinishedLoading: (() -> Unit)? = null) {
- jsBridge = JavascriptBridge(onWebViewFinishedLoading = onWebViewFinishedLoading)
+ fun initMessageDisplayJavascriptBridge(onWebViewFinishedLoading: () -> Unit) {
+ messageDisplayJsBridge = MessageDisplayJavascriptBridge(onWebViewFinishedLoading = onWebViewFinishedLoading)
+ }
+
+ fun initEditorJsBridge(onInlineImagesDeleted: (List) -> Unit) {
+ editorJsBridge = EditorJavascriptBridge(onInlineImagesDeleted = onInlineImagesDeleted)
}
private fun WebSettings.setupCommonWebViewSettings() {
@SuppressLint("SetJavaScriptEnabled")
javaScriptEnabled = true
-
- loadWithOverviewMode = true
- useWideViewPort = true
-
cacheMode = LOAD_CACHE_ELSE_NETWORK
}
fun WebSettings.setupThreadWebViewSettings() {
setupCommonWebViewSettings()
+ useWideViewPort = true
+ loadWithOverviewMode = true
setSupportZoom(true)
builtInZoomControls = true
displayZoomControls = false
@@ -178,9 +140,10 @@ class WebViewUtils(context: Context) {
evaluateJavascript(removeBackgroundStyleScript, null)
}
- fun WebView.destroyAndClearHistory() {
- clearHistory()
- destroy()
+ suspend fun WebView.evaluateJs(script: String): String = suspendCancellableCoroutine { continuation ->
+ evaluateJavascript(script) {
+ continuation.resume(it)
+ }
}
/**
diff --git a/app/src/main/java/com/infomaniak/mail/utils/extensions/Extensions.kt b/app/src/main/java/com/infomaniak/mail/utils/extensions/Extensions.kt
index b70f87b6126..48d57478f39 100644
--- a/app/src/main/java/com/infomaniak/mail/utils/extensions/Extensions.kt
+++ b/app/src/main/java/com/infomaniak/mail/utils/extensions/Extensions.kt
@@ -101,12 +101,13 @@ import com.infomaniak.mail.ui.main.folder.DateSeparatorItemDecoration
import com.infomaniak.mail.ui.main.folder.HeaderItemDecoration
import com.infomaniak.mail.ui.main.folder.ThreadListAdapter
import com.infomaniak.mail.ui.main.folder.ThreadListItem
-import com.infomaniak.mail.ui.main.thread.MessageWebViewClient
import com.infomaniak.mail.ui.main.thread.RoundedBackgroundSpan
import com.infomaniak.mail.ui.main.thread.SubjectFormatter.Companion.getTagsPaint
import com.infomaniak.mail.ui.main.thread.SubjectFormatter.EllipsizeConfiguration
import com.infomaniak.mail.ui.main.thread.SubjectFormatter.TagColor
import com.infomaniak.mail.ui.main.thread.ThreadFragment.HeaderState
+import com.infomaniak.mail.ui.main.thread.webViewClient.EditorWebViewClient
+import com.infomaniak.mail.ui.main.thread.webViewClient.MessageDisplayWebViewClient
import com.infomaniak.mail.ui.newMessage.NewMessageViewModel.UiRecipients
import com.infomaniak.mail.utils.AccountUtils
import com.infomaniak.mail.utils.ApiErrorException
@@ -257,29 +258,25 @@ fun LottieAnimationView.changePathColor(illuColors: IlluColors) {
SimpleColorFilter(illuColors.color)
}
}
+//endregion
-fun WebView.initWebViewClientAndBridge(
+//region WebViewClient
+fun WebView.initDisplayWebViewClientAndBridge(
attachments: List,
messageUid: String,
shouldLoadDistantResources: Boolean,
- onBlockedResourcesDetected: (() -> Unit)? = null,
+ onBlockedResourcesDetected: () -> Unit,
navigateToNewMessageActivity: ((Uri) -> Unit)?,
onPageFinished: (() -> Unit)? = null,
- onWebViewFinishedLoading: (() -> Unit)? = null,
-): MessageWebViewClient {
+ onWebViewFinishedLoading: () -> Unit,
+): MessageDisplayWebViewClient {
- WebViewUtils.initJavascriptBridge(onWebViewFinishedLoading)
- addJavascriptInterface(WebViewUtils.jsBridge, "kmail")
+ WebViewUtils.initMessageDisplayJavascriptBridge(onWebViewFinishedLoading)
+ addJavascriptInterface(WebViewUtils.messageDisplayJsBridge, "kmail")
- val cidDictionary = mutableMapOf().apply {
- attachments.forEach {
- it.contentId?.let { cid ->
- if (cid.isNotBlank()) this[cid] = it
- }
- }
- }
+ val cidDictionary = attachments.toCidDictionary()
- return MessageWebViewClient(
+ return MessageDisplayWebViewClient(
context,
cidDictionary,
messageUid,
@@ -291,6 +288,38 @@ fun WebView.initWebViewClientAndBridge(
webViewClient = it
}
}
+
+fun WebView.initEditorWebviewBridge(onInlineImagesDeleted: (List) -> Unit) {
+ WebViewUtils.initEditorJsBridge(onInlineImagesDeleted)
+ addJavascriptInterface(WebViewUtils.editorJsBridge, "kmail")
+}
+
+fun WebView.initEditorWebviewClient(
+ attachments: List,
+ shouldLoadDistantResources: Boolean,
+ onPageFinished: () -> Unit,
+): EditorWebViewClient {
+ val cidDictionary = attachments.toCidDictionary()
+
+ return EditorWebViewClient(
+ context,
+ cidDictionary,
+ shouldLoadDistantResources,
+ onPageFinished,
+ ).also {
+ webViewClient = it
+ }
+}
+
+private fun List.toCidDictionary(): Map {
+ return buildMap {
+ forEach {
+ it.contentId?.let { cid ->
+ if (cid.isNotBlank()) this[cid] = it
+ }
+ }
+ }
+}
//endregion
//region API
diff --git a/app/src/main/res/layout/fragment_new_message.xml b/app/src/main/res/layout/fragment_new_message.xml
index ea4ba1bde7c..849db3403e1 100644
--- a/app/src/main/res/layout/fragment_new_message.xml
+++ b/app/src/main/res/layout/fragment_new_message.xml
@@ -312,13 +312,23 @@
android:text="@string/newMessagePlaceholderTitle"
android:textColor="@color/tertiaryTextColor"
android:visibility="gone"
- app:layout_constraintBottom_toBottomOf="@id/editorWebView"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintHorizontal_bias="0"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="@id/editorWebView"
tools:visibility="visible" />
+
+
-
-
-
-
-
-
-
-
-
-
-
-
-