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;