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 88f29030fd..77336cf125 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
@@ -24,7 +24,11 @@ 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)
+ fun emptyBody() =
+ BodyContentPayload(
+ content = "
",
+ type = BodyContentType.HTML_SANITIZED
+ )
}
}
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 27b7a38bb4..de262eb39b 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
@@ -68,7 +64,6 @@ 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 +88,16 @@ 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.escapeForJS
+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.getReplaceSignatureScript
+import com.infomaniak.mail.utils.HtmlFormatter.Companion.getSignatureMarginStyle
+import com.infomaniak.mail.utils.HtmlFormatter.Companion.getToggleQuotesButtonVisibilityScript
+import com.infomaniak.mail.utils.MessageBodyUtils
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 +107,8 @@ 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.navigateToDownloadProgressDialog
import com.infomaniak.mail.utils.extensions.systemBars
import com.infomaniak.mail.utils.extensions.valueOrEmpty
@@ -124,15 +119,10 @@ import dagger.hilt.android.AndroidEntryPoint
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.launch
import splitties.experimental.ExperimentalSplittiesApi
import java.util.Date
import javax.inject.Inject
-import androidx.appcompat.R as RAndroid
@AndroidEntryPoint
class NewMessageFragment : Fragment() {
@@ -155,14 +145,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 +205,6 @@ class NewMessageFragment : Fragment() {
bindAlertToViewLifecycle(descriptionDialog)
- setWebViewReference()
initMailbox()
initUi()
initializeDraft()
@@ -232,8 +217,6 @@ class NewMessageFragment : Fragment() {
observeAttachments()
observeImportAttachmentsResult()
observeBodyLoader()
- observeUiSignature()
- observeUiQuote()
observeShimmering()
setupBackActionHandler()
@@ -361,21 +344,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 +351,6 @@ class NewMessageFragment : Fragment() {
}
addressListPopupWindow = null
- quoteWebView?.destroyAndClearHistory()
- quoteWebView = null
- signatureWebView?.destroyAndClearHistory()
- signatureWebView = null
TransitionManager.endTransitions(binding.root)
super.onDestroyView()
_binding = null
@@ -418,9 +382,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,
@@ -465,36 +426,30 @@ class NewMessageFragment : Fragment() {
private fun initEditorUi() = with(binding) {
editorWebView.subscribeToStates(setOf(BOLD, ITALIC, UNDERLINE, STRIKE_THROUGH, UNORDERED_LIST, CREATE_LINK))
setEditorStyle()
- handleEditorPlaceholderVisibility()
-
editorAiAnimation.setAnimation(R.raw.euria)
-
setToolbarEnabledStatus(false)
- disableButtonsWhenFocusIsLost()
+ 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())
+ addCss(context.getSignatureMarginStyle())
}
- private fun handleEditorPlaceholderVisibility() {
- val isPlaceholderVisible = combine(
- binding.editorWebView.isEmptyFlow.filterNotNull(),
- newMessageViewModel.isShimmering,
- ) { isEditorEmpty, isShimmering -> isEditorEmpty && !isShimmering }
-
- isPlaceholderVisible
- .onEach { isVisible -> binding.newMessagePlaceholder.isVisible = isVisible }
- .launchIn(lifecycleScope)
+ private fun removePlaceholder() {
+ binding.newMessagePlaceholder.visibility = View.GONE
}
- private fun disableButtonsWhenFocusIsLost() {
- newMessageViewModel.isEditorWebViewFocusedLiveData.observe(viewLifecycleOwner, ::setToolbarEnabledStatus)
+ private fun handleFocusChanges() {
+ newMessageViewModel.isEditorWebViewFocusedLiveData.observe(viewLifecycleOwner) { isFocused ->
+ setToolbarEnabledStatus(isFocused)
+ if (isFocused && binding.newMessagePlaceholder.isVisible) {
+ removePlaceholder()
+ }
+ }
}
private fun setToolbarEnabledStatus(isEnabled: Boolean) {
@@ -505,6 +460,8 @@ class NewMessageFragment : Fragment() {
if (initResult.value == null) {
initDraftAndViewModel(intent = requireActivity().intent).observe(viewLifecycleOwner) { draft ->
if (draft != null) {
+ val isBodyEmpty = newMessageViewModel.bodyHasPlaceholder(draft.body)
+ binding.newMessagePlaceholder.isVisible = isBodyEmpty
showKeyboardInCorrectView(isToFieldEmpty = draft.to.isEmpty())
binding.subjectTextField.setText(draft.subject)
} else {
@@ -526,76 +483,26 @@ 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 removeInlineAttachmentsUsedInQuote() = with(newMessageViewModel) {
- uiQuoteLiveData.value?.let { html ->
- attachmentsLiveData.value?.filterOutHtmlCids(html)?.let { attachmentsLiveData.value = it }
- }
+ private fun configureUiWithDraftData(draft: Draft) = with(binding.editorWebView) {
+ settings.setupNewMessageWebViewSettings()
+ webViewClient = initWebViewClientAndBridge(
+ attachments = draft.attachments,
+ messageUid = "MESSAGE-" + draft.messageUid,
+ shouldLoadDistantResources = true,
+ navigateToNewMessageActivity = null
+ )
}
- 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 setupToggleQuotesButton() {
+ binding.quotesToggleButton.isVisible = newMessageViewModel.areQuotesVisible.value
+ binding.quotesToggleButton.setOnClickListener {
+ binding.editorWebView.evaluateJavascript(requireContext().getToggleQuotesButtonVisibilityScript()) {
+ binding.quotesToggleButton.isGone = true
+ newMessageViewModel.changeQuotesVisibility(areVisible = true)
+ }
}
}
- 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 WebView.loadProcessedContent(processedHtml: String, webViewGroup: Group) {
- webViewGroup.isVisible = processedHtml.isNotBlank()
- loadDataWithBaseURL("", processedHtml, ClipDescription.MIMETYPE_TEXT_HTML, Utils.UTF_8, "")
- }
-
private fun setupFromField(signatures: List) = with(binding) {
signatureAdapter.setList(signatures)
@@ -629,8 +536,24 @@ class NewMessageFragment : Fragment() {
private fun onSignatureClicked(signature: Signature) {
trackNewMessageEvent(MatomoName.SwitchIdentity)
- newMessageViewModel.fromLiveData.value = UiFrom(signature)
- addressListPopupWindow?.dismiss()
+
+ // Get the New Signature HTML
+ val newSignatureHtml = signature.content
+ val wrappedNewSignature =
+ if (signature.isDummy) "" else signatureUtils.encapsulateSignatureContentWithInfomaniakClass(newSignatureHtml)
+
+ val escapedSignature = wrappedNewSignature.escapeForJS()
+
+ val replaceSignatureScript = requireContext().getReplaceSignatureScript().format(
+ MessageBodyUtils.INFOMANIAK_SIGNATURE_HTML_CLASS_NAME,
+ escapedSignature,
+ MessageBodyUtils.INFOMANIAK_REPLY_QUOTE_HTML_CLASS_NAME
+ )
+
+ binding.editorWebView.evaluateJavascript(replaceSignatureScript) {
+ newMessageViewModel.fromLiveData.value = UiFrom(signature)
+ addressListPopupWindow?.dismiss()
+ }
}
private fun updateSelectedSignatureInFromField(signature: Signature) {
@@ -660,9 +583,8 @@ class NewMessageFragment : Fragment() {
}
private fun observeFromData() = with(newMessageViewModel) {
- fromLiveData.observe(viewLifecycleOwner) { (signature, shouldUpdateBodySignature) ->
+ fromLiveData.observe(viewLifecycleOwner) { (signature) ->
updateSelectedSignatureInFromField(signature)
- if (shouldUpdateBodySignature) updateBodySignature(signature)
signatureAdapter.updateSelectedSignature(signature.id)
}
}
@@ -759,26 +681,7 @@ class NewMessageFragment : Fragment() {
private fun observeBodyLoader() {
newMessageViewModel.editorBodyInitializer.observe(viewLifecycleOwner) { body ->
editorContentManager.setContent(binding.editorWebView, body)
- }
- }
-
- 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)
- }
+ setupToggleQuotesButton()
}
}
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 cb695c3f12..e1101cc45e 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
@@ -83,6 +83,7 @@ import com.infomaniak.mail.ui.newMessage.NewMessageRecipientFieldsManager.FieldT
import com.infomaniak.mail.utils.AccountUtils
import com.infomaniak.mail.utils.ContactUtils.arrangeMergedContacts
import com.infomaniak.mail.utils.DraftInitManager
+import com.infomaniak.mail.utils.JsoupParserUtil
import com.infomaniak.mail.utils.JsoupParserUtil.jsoupParseWithLog
import com.infomaniak.mail.utils.LocalStorageUtils
import com.infomaniak.mail.utils.MessageBodyUtils
@@ -130,7 +131,6 @@ import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.invoke
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
-import org.jsoup.nodes.Document
import splitties.experimental.ExperimentalSplittiesApi
import java.util.Date
import javax.inject.Inject
@@ -171,8 +171,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,6 +220,9 @@ class NewMessageViewModel @Inject constructor(
private val _isShimmering = MutableStateFlow(true)
val isShimmering: StateFlow = _isShimmering
+ private val _areQuotesVisible = MutableStateFlow(false)
+ val areQuotesVisible: StateFlow = _areQuotesVisible
+
//region Check mailbox existence
private val exitSignal: CompletableJob = Job()
@@ -328,10 +329,8 @@ class NewMessageViewModel @Inject constructor(
markAsRead(currentMailbox(), realm)
realm.write { DraftController.upsertDraftBlocking(it, realm = this) }
- it.saveSnapshot(initialBody.content)
it.initLiveData(signatures)
_isShimmering.emit(false)
-
initResult.postValue(InitResult(it, signatures))
}
@@ -344,11 +343,14 @@ class NewMessageViewModel @Inject constructor(
if (draft.identityId.isNullOrBlank()) {
draft.identityId = currentMailbox().getDefaultSignatureWithFallback().id.toString()
}
- splitSignatureAndQuoteFromBody(draft)
+ val contentType =
+ if (draft.mimeType == Utils.TEXT_PLAIN) BodyContentType.TEXT_PLAIN_WITH_HTML else BodyContentType.HTML_SANITIZED
+ initialBody = BodyContentPayload(draft.body, contentType)
+ return draft
}
}
- private suspend fun getNewDraft(signatures: List, intent: Intent, realm: Realm): Draft? = Draft().apply {
+ private suspend fun getNewDraft(signatures: List, intent: Intent, realm: Realm): Draft = Draft().apply {
var previousMessage: Message? = null
@@ -424,56 +426,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 fun populateWithExternalMailDataIfNeeded(draft: Draft, intent: Intent) {
when (intent.action) {
Intent.ACTION_SEND -> handleSingleSendIntent(draft, intent)
@@ -548,10 +500,28 @@ class NewMessageViewModel @Inject constructor(
attachmentsLiveData.postValue(attachments)
- editorBodyInitializer.postValue(initialBody)
+ var finalBodyContent = initialBody.content
+ val signatureHtml = draftSignature?.takeIf { !it.isDummy }?.content
+ val wrappedSignature = signatureHtml?.let { signatureUtils.encapsulateSignatureContentWithInfomaniakClass(it) }
- uiSignatureLiveData.postValue(initialSignature)
- uiQuoteLiveData.postValue(initialQuote)
+ if (isNewMessage && wrappedSignature != null) {
+ finalBodyContent += wrappedSignature
+ }
+
+ if (isNewMessage && !initialQuote.isNullOrEmpty()) {
+ _areQuotesVisible.emit(true)
+ finalBodyContent += MessageBodyUtils.encapsulateQuotesWithInfomaniakClass(initialQuote.toString())
+ }
+
+ val normalizedBody = normalizeHtml(finalBodyContent)
+ saveSnapshot(normalizedBody)
+
+ editorBodyInitializer.postValue(
+ BodyContentPayload(
+ content = normalizedBody,
+ type = BodyContentType.HTML_SANITIZED
+ )
+ )
if (cc.isNotEmpty() || bcc.isNotEmpty()) {
otherRecipientsFieldsAreEmpty.postValue(false)
@@ -561,6 +531,21 @@ class NewMessageViewModel @Inject constructor(
isEncryptionActivated.postValue(isEncrypted)
}
+ fun bodyHasPlaceholder(bodyHtml: String): Boolean {
+ val body = JsoupParserUtil.jsoupParseBodyFragmentWithLog(bodyHtml).body()
+ return !body.hasText()
+ }
+
+ fun changeQuotesVisibility(areVisible: Boolean) {
+ _areQuotesVisible.value = areVisible
+ }
+
+ private fun normalizeHtml(html: String): String {
+ val doc = jsoupParseWithLog(html)
+ doc.outputSettings().prettyPrint(false)
+ return doc.body().html()
+ }
+
private suspend fun getLocalOrRemoteDraft(localUuid: String?): Draft? {
@Suppress("UNUSED_PARAMETER")
@@ -839,14 +824,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()
@@ -963,8 +940,7 @@ class NewMessageViewModel @Inject constructor(
)
subject = subjectValue
-
- body = uiBodyValue + (uiSignatureLiveData.value ?: "") + (uiQuoteLiveData.value ?: "")
+ body = sanitizeBody(uiBodyValue)
/**
* If we are opening for the 1st time an existing Draft created somewhere else
@@ -980,11 +956,28 @@ class NewMessageViewModel @Inject constructor(
// Only if `!isFinishing`, because if we are finishing, well… We're out of here so we don't care about all of that.
if (!isFinishing) {
- copyFromRealm().saveSnapshot(uiBodyValue)
+ val normalizedBody = normalizeHtml(body)
+ copyFromRealm().saveSnapshot(normalizedBody)
isNewMessage = false
}
}
+ private fun sanitizeBody(html: String): String {
+ val doc = jsoupParseWithLog(html)
+ // If the user deleted the quotes text, remove the quotes div so the button to show quotes doesn't show.
+ val quotes = JsoupParserUtil.jsoupParseBodyFragmentWithLog(html)
+ .getElementsByClass(MessageBodyUtils.INFOMANIAK_REPLY_QUOTE_HTML_CLASS_NAME)
+ val hasQuotes = quotes.isNotEmpty()
+ if (hasQuotes && !quotes.hasText()) {
+ doc.getElementsByClass(MessageBodyUtils.INFOMANIAK_REPLY_QUOTE_HTML_CLASS_NAME).forEach { it.remove() }
+ }
+
+ // Don't save the draft or send the mail with quotes style display none.
+ if (hasQuotes) quotes.attr("style", "display: block")
+
+ return doc.html()
+ }
+
private fun Draft.updateDraftAttachmentsWithLiveData(uiAttachments: List, step: String) {
/**
@@ -1030,7 +1023,7 @@ class NewMessageViewModel @Inject constructor(
draftSnapshot.cc == ccLiveData.valueOrEmpty().toSet() &&
draftSnapshot.bcc == bccLiveData.valueOrEmpty().toSet() &&
draftSnapshot.subject == subjectValue &&
- draftSnapshot.uiBody == uiBodyValue &&
+ sanitizeBody(draftSnapshot.uiBody) == sanitizeBody(uiBodyValue) &&
draftSnapshot.isEncrypted == isEncryptionActivated.value &&
draftSnapshot.encryptionPassword == encryptionPassword.value &&
draftSnapshot.attachmentsLocalUuids == attachmentsLiveData.valueOrEmpty()
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 85e7c24cf2..f93026d27b 100644
--- a/app/src/main/java/com/infomaniak/mail/utils/HtmlFormatter.kt
+++ b/app/src/main/java/com/infomaniak/mail/utils/HtmlFormatter.kt
@@ -27,6 +27,7 @@ import com.infomaniak.mail.utils.UiUtils.PRIMARY_COLOR_CODE
import com.infomaniak.mail.utils.extensions.getAttributeColor
import com.infomaniak.mail.utils.extensions.loadCss
import com.infomaniak.mail.utils.extensions.readRawResource
+import kotlinx.serialization.json.JsonPrimitive
import org.jsoup.nodes.Element
import org.jsoup.nodes.Node
import org.jsoup.nodes.TextNode
@@ -210,6 +211,11 @@ class HtmlFormatter(private val html: String) {
}
}
+ fun String.escapeForJS(): String {
+ if (isNullOrEmpty()) return this
+ return JsonPrimitive(this).toString().removeSurrounding("\"")
+ }
+
fun Context.getCustomDarkMode(): String = loadCss(R.raw.custom_dark_mode)
fun Context.getImproveRenderingStyle(): String = loadCss(R.raw.improve_rendering)
@@ -219,6 +225,11 @@ class HtmlFormatter(private val html: String) {
listOf(PRIMARY_COLOR_CODE to getAttributeColor(RAndroid.attr.colorPrimary)),
)
+ fun Context.getCustomEditorStyle(): String = loadCss(
+ R.raw.editor_style,
+ listOf(PRIMARY_COLOR_CODE to getAttributeColor(RAndroid.attr.colorPrimary))
+ )
+
fun Context.getSignatureMarginStyle(): String = loadCss(R.raw.signature_margins)
fun Context.getPrintMailStyle(): String = loadCss(R.raw.print_email)
@@ -228,6 +239,14 @@ class HtmlFormatter(private val html: String) {
listOf("MESSAGE_SELECTOR" to "#$KMAIL_MESSAGE_ID")
)
+ fun Context.getToggleQuotesButtonVisibilityScript(): String = loadScript(
+ R.raw.show_quotes_script
+ )
+
+ fun Context.getReplaceSignatureScript(): String = loadScript(
+ R.raw.replace_signature_script
+ )
+
fun Context.getFixStyleScript(): String {
return loadScript(R.raw.fix_email_style)
}
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 85b9f1b269..6b5486f0fb 100644
--- a/app/src/main/java/com/infomaniak/mail/utils/MessageBodyUtils.kt
+++ b/app/src/main/java/com/infomaniak/mail/utils/MessageBodyUtils.kt
@@ -63,6 +63,10 @@ object MessageBodyUtils {
"[name=\"quote\"]", // GMX
)
+ fun encapsulateQuotesWithInfomaniakClass(quotes: String): String {
+ return """$quotes
""".trimIndent()
+ }
+
suspend fun splitContentAndQuote(body: Body): SplitBody {
val bodyContent = body.value
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 3528eee9a8..6041ff2600 100644
--- a/app/src/main/java/com/infomaniak/mail/utils/SignatureUtils.kt
+++ b/app/src/main/java/com/infomaniak/mail/utils/SignatureUtils.kt
@@ -19,6 +19,7 @@ package com.infomaniak.mail.utils
import android.content.Context
import com.infomaniak.mail.R
+import com.infomaniak.mail.utils.MessageBodyUtils.INFOMANIAK_SIGNATURE_HTML_CLASS_NAME
import com.infomaniak.mail.utils.extensions.loadCss
import javax.inject.Inject
import javax.inject.Singleton
@@ -31,10 +32,16 @@ class SignatureUtils @Inject constructor(appContext: Context) {
fun encapsulateSignatureContentWithInfomaniakClass(signatureContent: String): String {
val verticalMarginsCss = signatureMargins
val verticalMarginAttributes = extractAttributesFromMarginCss(verticalMarginsCss)
- return """$signatureContent
"""
+ if (verticalMarginAttributes.isNullOrEmpty()) return signatureContent
+
+ return """
+
+ $signatureContent
+
+ """.trimIndent()
}
- private fun extractAttributesFromMarginCss(verticalMarginsCss: String): String {
- return Regex("""\{(.*)\}""").find(verticalMarginsCss)!!.groupValues[1]
+ private fun extractAttributesFromMarginCss(verticalMarginsCss: String): String? {
+ return Regex("""\{(.*)\}""").find(verticalMarginsCss)?.groupValues[1]
}
}
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 1eb2b7c294..61c8566625 100644
--- a/app/src/main/java/com/infomaniak/mail/utils/WebViewUtils.kt
+++ b/app/src/main/java/com/infomaniak/mail/utils/WebViewUtils.kt
@@ -31,7 +31,6 @@ import com.infomaniak.mail.utils.HtmlFormatter.Companion.getImproveRenderingStyl
import com.infomaniak.mail.utils.HtmlFormatter.Companion.getJsBridgeScript
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
@@ -40,7 +39,6 @@ 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 printMailStyle by lazy { context.getPrintMailStyle() }
private val resizeScript by lazy { context.getResizeScript() }
@@ -65,15 +63,6 @@ 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)
@@ -134,16 +123,14 @@ class WebViewUtils(context: Context) {
private fun WebSettings.setupCommonWebViewSettings() {
@SuppressLint("SetJavaScriptEnabled")
javaScriptEnabled = true
-
loadWithOverviewMode = true
- useWideViewPort = true
-
cacheMode = LOAD_CACHE_ELSE_NETWORK
}
fun WebSettings.setupThreadWebViewSettings() {
setupCommonWebViewSettings()
+ useWideViewPort = true
setSupportZoom(true)
builtInZoomControls = true
displayZoomControls = false
diff --git a/app/src/main/res/layout/fragment_new_message.xml b/app/src/main/res/layout/fragment_new_message.xml
index 8feb6528bb..60c912eb5a 100644
--- a/app/src/main/res/layout/fragment_new_message.xml
+++ b/app/src/main/res/layout/fragment_new_message.xml
@@ -312,13 +312,25 @@
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" />
+
+
-
-
-
-
-
-
-
-
-
-
-
-
-
.
+ */
+
+(function() {
+ var signatureElement = document.querySelector('.%s');
+ var newSigHtml = "%s";
+ if (signatureElement) {
+ if (newSigHtml === "") {
+ signatureElement.remove();
+ } else {
+ signatureElement.outerHTML = newSigHtml;
+ }
+ } else if (newSigHtml !== "") {
+ var quotes = document.querySelector('.%s');
+ if (quotes) {
+ quotes.insertAdjacentHTML('beforebegin', newSigHtml);
+ } else {
+ document.body.insertAdjacentHTML('beforeend', newSigHtml);
+ }
+ }
+})()
diff --git a/app/src/main/res/raw/show_quotes_script.js b/app/src/main/res/raw/show_quotes_script.js
new file mode 100644
index 0000000000..b4b42db1a6
--- /dev/null
+++ b/app/src/main/res/raw/show_quotes_script.js
@@ -0,0 +1,35 @@
+/*
+ * 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 .
+ */
+
+(function() {
+ const quoteElements = document.querySelectorAll('.ik_mail_quote, .forwardContentMessage');
+ quoteElements.forEach(el => {
+ el.style.display = 'block';
+
+ // Force reload images inside quotes
+ el.querySelectorAll('img').forEach(img => {
+ if (img.src.startsWith('cid:')) {
+ // Store original CID, then reload
+ const cid = img.src;
+ img.src = '';
+
+ setTimeout(() => img.src = cid, 0);
+ }
+ });
+ });
+})();
diff --git a/app/src/main/res/raw/signature_margins.css b/app/src/main/res/raw/signature_margins.css
index e109cffc86..bb22fc91a9 100644
--- a/app/src/main/res/raw/signature_margins.css
+++ b/app/src/main/res/raw/signature_margins.css
@@ -1 +1 @@
-body { margin-top: 1rem; margin-bottom: 1rem; } /* Only one selector in the whole file is supported. Here it's `body` */
+.editorUserSignature { margin-top: 1.5rem; margin-bottom: 1rem; } /* Only one selector in the whole file is supported. */
diff --git a/app/src/main/res/raw/style.css b/app/src/main/res/raw/style.css
index c7eb27097b..70880b5bbf 100644
--- a/app/src/main/res/raw/style.css
+++ b/app/src/main/res/raw/style.css
@@ -11,6 +11,20 @@ body {
min-width: auto !important;
}
+#ik-quotes, #ik-quotes > * {
+ max-width: 100% !important;
+ box-sizing: border-box !important;
+}
+
+#ik-quotes table {
+ width: auto !important;
+ max-width: 100% !important;
+}
+
+img {
+ max-width: 100% !important;
+}
+
blockquote {
padding: 0.2em 1.2em !important;
margin: 0 !important;