Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add toolbar formatting options to the new rich html editor #1996

Merged
merged 31 commits into from
Aug 14, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
31 commits
Select commit Hold shift + click to select a range
89baa4e
Redo the tooolbar layout
LunarX Jul 12, 2024
e788305
Connect format option buttons to their respective action
LunarX Aug 6, 2024
12071f2
Disable toolbar formatting options when the webview is unfocused
LunarX Aug 6, 2024
d2f4567
Coherent naming across editor format action methods
LunarX Aug 6, 2024
5d9af99
Correctly build the toolbar content so it's easy to swap between the …
LunarX Aug 6, 2024
9c1f0c5
Add content descriptions to the formatting options buttons
LunarX Aug 6, 2024
9fa8954
Remove send button and divider from the formatting options toolbar
LunarX Aug 6, 2024
539b8dc
Add insert link dialog
LunarX Aug 8, 2024
4285859
Use common dialog title
LunarX Aug 8, 2024
9eea30b
Move link inside edition toolbar so it can be displayed as activated …
LunarX Aug 8, 2024
b620a10
Remove prefilled default values until we actually handle them properly
LunarX Aug 8, 2024
efb21df
Focus the editor if the format toolbar is opened
LunarX Aug 9, 2024
8a86e25
Add url validation
LunarX Aug 8, 2024
b09d440
Fix url error end icon padding
LunarX Aug 9, 2024
cfde8f4
Correctly reset the insert dialog edit text states when opening the d…
LunarX Aug 9, 2024
3a3ccac
Fix insert dialog paddings
LunarX Aug 9, 2024
56362d8
Automatically focus url field when opening insert link dialog
LunarX Aug 9, 2024
51d8bf0
Simplify the synchronizing of display text and url to avoid unnecessa…
LunarX Aug 9, 2024
b3a225c
Clean some code before opening the PR for reviews
LunarX Aug 9, 2024
9232b21
Add missing RCore import
KevinBoulongne Aug 13, 2024
b4c7a55
Only set `urlEditText.doOnTextChanged { … }` listener once
KevinBoulongne Aug 13, 2024
4e98e05
Extract PROTOCOL_SEPARATOR to companion object
KevinBoulongne Aug 13, 2024
250cee4
Oneline validate method for link insertion
KevinBoulongne Aug 13, 2024
cfb8e26
Fix wrongly renamed view
LunarX Aug 13, 2024
7d08669
Remove unnecessary var
LunarX Aug 13, 2024
e362cce
Remove useless new line
LunarX Aug 13, 2024
4c19040
Refactor updateEditorVisibility with better method and argument name
LunarX Aug 13, 2024
1214937
Add missing coma
LunarX Aug 13, 2024
31b0075
Fix comment
LunarX Aug 13, 2024
7755c24
Simplify editor focus handling
LunarX Aug 13, 2024
b518377
Apply suggestions from code review
LunarX Aug 13, 2024
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,136 @@
/*
* Infomaniak Mail - Android
* Copyright (C) 2024 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 <http://www.gnu.org/licenses/>.
*/
package com.infomaniak.mail.ui.newMessage

import android.content.Context
import android.content.DialogInterface
import android.util.Patterns
import androidx.appcompat.app.AlertDialog
import androidx.core.widget.addTextChangedListener
import androidx.core.widget.doOnTextChanged
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import com.infomaniak.lib.core.utils.context
import com.infomaniak.lib.core.utils.showKeyboard
import com.infomaniak.mail.R
import com.infomaniak.mail.databinding.DialogInsertLinkBinding
import com.infomaniak.mail.ui.alertDialogs.BaseAlertDialog
import com.infomaniak.mail.utils.extensions.trimmedText
import dagger.hilt.android.qualifiers.ActivityContext
import dagger.hilt.android.scopes.ActivityScoped
import javax.inject.Inject
import com.infomaniak.lib.core.R as RCore

@ActivityScoped
class InsertLinkDialog @Inject constructor(
@ActivityContext private val activityContext: Context,
) : BaseAlertDialog(activityContext) {

val binding: DialogInsertLinkBinding by lazy { DialogInsertLinkBinding.inflate(activity.layoutInflater) }
private var addLink: ((String, String) -> Unit)? = null

override val alertDialog: AlertDialog = with(binding) {
showDisplayNamePreview()

MaterialAlertDialogBuilder(context)
.setView(root)
.setPositiveButton(R.string.buttonConfirm, null)
.setNegativeButton(RCore.string.buttonCancel, null)
.create()
.also {
it.setOnShowListener { dialog ->
urlEditText.showKeyboard()
resetDialogState()
setConfirmButtonListener(dialog)
}
urlEditText.doOnTextChanged { _, _, _, _ ->
urlLayout.setError(null)
}
}
}

override fun resetCallbacks() {
addLink = null
}

fun show(defaultDisplayNameValue: String = "", defaultUrlValue: String = "", addLinkCallback: (String, String) -> Unit) {
binding.apply {
displayNameEditText.setText(defaultDisplayNameValue)
urlEditText.setText(defaultUrlValue)
}

addLink = addLinkCallback
alertDialog.show()
}

// Pre-fills the display name with the url's content if the fields contain the same value.
private fun showDisplayNamePreview() = with(binding) {
displayNameEditText.setTextColor(activityContext.getColor(R.color.tertiaryTextColor))

var areInputsSynced = false
urlEditText.addTextChangedListener(
beforeTextChanged = { text, _, _, _ ->
areInputsSynced = text.toString() == displayNameEditText.text.toString()
},
onTextChanged = { text, _, _, _ ->
if (areInputsSynced || displayNameEditText.text.isNullOrBlank()) displayNameEditText.setText(text)
}
)

displayNameEditText.setOnFocusChangeListener { _, hasFocus ->
val textColor = activityContext.getColor(if (hasFocus) R.color.primaryTextColor else R.color.tertiaryTextColor)
displayNameEditText.setTextColor(textColor)
}
}

private fun resetDialogState() = with(binding) {
urlLayout.setError(null)
displayNameLayout.placeholderText = null
}

private fun setConfirmButtonListener(dialog: DialogInterface) = with(binding) {
(dialog as AlertDialog).getButton(AlertDialog.BUTTON_POSITIVE).setOnClickListener {
val url = addMissingHttpsProtocol(urlEditText.trimmedText)

if (validate(url)) {
val displayName = (displayNameEditText.text?.takeIf { it.isNotBlank() } ?: urlEditText.text).toString().trim()
addLink?.invoke(displayName, url)

dialog.dismiss()
} else {
urlLayout.setError(activityContext.getString(R.string.snackbarInvalidUrl))
}
}
}

private fun addMissingHttpsProtocol(link: String): String {
val protocolEndIndex = link.indexOf(PROTOCOL_SEPARATOR)
val isProtocolSpecified = protocolEndIndex > 0 // If there is a specified protocol and it is at least 1 char long

if (isProtocolSpecified) return link

val strippedUserInput = if (protocolEndIndex == -1) link else link.substring(PROTOCOL_SEPARATOR.length)

return "https://$strippedUserInput"
}

private fun validate(userUrlInput: String): Boolean = Patterns.WEB_URL.matcher(userUrlInput).matches()

companion object {
private const val PROTOCOL_SEPARATOR = "://"
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ package com.infomaniak.mail.ui.newMessage
import android.content.res.ColorStateList
import androidx.core.view.isGone
import androidx.core.view.isVisible
import androidx.lifecycle.lifecycleScope
import com.google.android.material.button.MaterialButton
import com.infomaniak.mail.MatomoMail
import com.infomaniak.mail.MatomoMail.trackEvent
Expand All @@ -28,11 +29,12 @@ import com.infomaniak.mail.databinding.FragmentNewMessageBinding
import com.infomaniak.mail.utils.extensions.getAttributeColor
import com.infomaniak.mail.utils.extensions.notYetImplemented
import dagger.hilt.android.scopes.FragmentScoped
import kotlinx.coroutines.launch
import javax.inject.Inject
import com.google.android.material.R as RMaterial

@FragmentScoped
class NewMessageEditorManager @Inject constructor() : NewMessageManager() {
class NewMessageEditorManager @Inject constructor(private val insertLinkDialog: InsertLinkDialog) : NewMessageManager() {

private var _aiManager: NewMessageAiManager? = null
private inline val aiManager: NewMessageAiManager get() = _aiManager!!
Expand All @@ -57,19 +59,30 @@ class NewMessageEditorManager @Inject constructor() : NewMessageManager() {
_openFilePicker = openFilePicker
}

fun observeEditorActions() {
fun observeEditorFormatActions() = with(binding) {
newMessageViewModel.editorAction.observe(viewLifecycleOwner) { (editorAction, _) ->
when (editorAction) {
EditorAction.ATTACHMENT -> _openFilePicker?.invoke()
EditorAction.CAMERA -> fragment.notYetImplemented()
EditorAction.LINK -> fragment.notYetImplemented()
EditorAction.LINK -> if (buttonLink.isActivated) {
editorWebView.unlink()
} else {
insertLinkDialog.show { displayText, url ->
editorWebView.createLink(displayText, url)
}
}
EditorAction.CLOCK -> fragment.notYetImplemented()
EditorAction.AI -> aiManager.openAiPrompt()
EditorAction.BOLD -> editorWebView.toggleBold()
EditorAction.ITALIC -> editorWebView.toggleItalic()
EditorAction.UNDERLINE -> editorWebView.toggleUnderline()
EditorAction.STRIKE_THROUGH -> editorWebView.toggleStrikeThrough()
EditorAction.UNORDERED_LIST -> editorWebView.toggleUnorderedList()
}
}
}

fun setupEditorActions() = with(binding) {
fun setupEditorFormatActions() = with(binding) {
fun linkEditor(view: MaterialButton, action: EditorAction) {
view.setOnClickListener {
context.trackEvent("editorActions", action.matomoValue)
Expand All @@ -79,34 +92,55 @@ class NewMessageEditorManager @Inject constructor() : NewMessageManager() {

linkEditor(editorAttachment, EditorAction.ATTACHMENT)
linkEditor(editorCamera, EditorAction.CAMERA)
linkEditor(editorLink, EditorAction.LINK)
linkEditor(editorClock, EditorAction.CLOCK)
linkEditor(editorAi, EditorAction.AI)

linkEditor(buttonBold, EditorAction.BOLD)
linkEditor(buttonItalic, EditorAction.ITALIC)
linkEditor(buttonUnderline, EditorAction.UNDERLINE)
linkEditor(buttonStrikeThrough, EditorAction.STRIKE_THROUGH)
linkEditor(buttonList, EditorAction.UNORDERED_LIST)
linkEditor(buttonLink, EditorAction.LINK)
}

fun setupEditorFormatActionsToggle() = with(binding) {
editorTextOptions.setOnClickListener {
newMessageViewModel.isEditorExpanded = !newMessageViewModel.isEditorExpanded
updateEditorVisibility(newMessageViewModel.isEditorExpanded)
updateEditorFormatActionsVisibility(newMessageViewModel.isEditorExpanded)
}
}

private fun updateEditorVisibility(isEditorExpanded: Boolean) = with(binding) {
private fun updateEditorFormatActionsVisibility(isExpanded: Boolean) = with(binding) {
if (isExpanded) editorWebView.requestFocus()

val color = if (isEditorExpanded) {
val color = if (isExpanded) {
context.getAttributeColor(RMaterial.attr.colorPrimary)
} else {
context.getColor(R.color.iconColor)
}
val resId = if (isEditorExpanded) R.string.buttonTextOptionsClose else R.string.buttonTextOptionsOpen
val resId = if (isExpanded) R.string.buttonTextOptionsClose else R.string.buttonTextOptionsOpen

editorTextOptions.apply {
iconTint = ColorStateList.valueOf(color)
contentDescription = context.getString(resId)
}

editorActions.isGone = isEditorExpanded
textEditing.isVisible = isEditorExpanded
editorActions.isGone = isExpanded
sendButton.isGone = isExpanded
formatOptionsScrollView.isVisible = isExpanded
}

fun observeEditorStatus(): Unit = with(binding) {
viewLifecycleOwner.lifecycleScope.launch {
editorWebView.editorStatusesFlow.collect {
buttonBold.isActivated = it.isBold
buttonItalic.isActivated = it.isItalic
buttonUnderline.isActivated = it.isUnderlined
buttonStrikeThrough.isActivated = it.isStrikeThrough
buttonList.isActivated = it.isUnorderedListSelected
buttonLink.isActivated = it.isLinkSelected
}
}
}

enum class EditorAction(val matomoValue: String) {
Expand All @@ -115,10 +149,10 @@ class NewMessageEditorManager @Inject constructor() : NewMessageManager() {
LINK("addLink"),
CLOCK(MatomoMail.ACTION_POSTPONE_NAME),
AI("aiWriter"),
// BOLD("bold"),
// ITALIC("italic"),
// UNDERLINE("underline"),
// STRIKE_THROUGH("strikeThrough"),
// UNORDERED_LIST("unorderedList"),
BOLD("bold"),
ITALIC("italic"),
UNDERLINE("underline"),
STRIKE_THROUGH("strikeThrough"),
UNORDERED_LIST("unorderedList"),
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ 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
import androidx.fragment.app.Fragment
Expand All @@ -47,6 +48,7 @@ import com.infomaniak.lib.core.utils.SnackbarUtils.showSnackbar
import com.infomaniak.lib.core.utils.getBackNavigationResult
import com.infomaniak.lib.core.utils.isNightModeEnabled
import com.infomaniak.lib.core.utils.showToast
import com.infomaniak.lib.richhtmleditor.StatusCommand.*
import com.infomaniak.mail.MatomoMail.OPEN_FROM_DRAFT_NAME
import com.infomaniak.mail.MatomoMail.trackAttachmentActionsEvent
import com.infomaniak.mail.MatomoMail.trackNewMessageEvent
Expand Down Expand Up @@ -183,7 +185,11 @@ class NewMessageFragment : Fragment() {
observeUiQuote()
observeShimmering()

editorManager.observeEditorActions()
with(editorManager) {
observeEditorFormatActions()
observeEditorStatus()
}

externalsManager.observeExternals(newMessageViewModel.arrivedFromExistingDraft())

with(aiManager) {
Expand Down Expand Up @@ -342,23 +348,40 @@ class NewMessageFragment : Fragment() {
}

private fun initEditorUi() {
binding.editorWebView.apply {
enableAlgorithmicDarkening(isEnabled = true)
if (context.isNightModeEnabled()) addCss(context.loadCss(R.raw.custom_dark_mode))
binding.editorWebView.subscribeToStates(setOf(BOLD, ITALIC, UNDERLINE, STRIKE_THROUGH, UNORDERED_LIST, CREATE_LINK))
setEditorStyle()
handleEditorPlaceholderVisibility()

setToolbarEnabledStatus(false)
disableButtonsWhenFocusIsLost()
}

val customColors = listOf(PRIMARY_COLOR_CODE to context.getAttributeColor(RMaterial.attr.colorPrimary))
addCss(context.loadCss(R.raw.style, customColors))
addCss(context.loadCss(R.raw.editor_style, customColors))
private fun setEditorStyle() = with(binding.editorWebView) {
enableAlgorithmicDarkening(isEnabled = true)
if (context.isNightModeEnabled()) addCss(context.loadCss(R.raw.custom_dark_mode))

val isPlaceholderVisible = combine(
isEmptyFlow.filterNotNull(),
newMessageViewModel.isShimmering,
) { isEditorEmpty, isShimmering -> isEditorEmpty && !isShimmering }
val customColors = listOf(PRIMARY_COLOR_CODE to context.getAttributeColor(RMaterial.attr.colorPrimary))
addCss(context.loadCss(R.raw.style, customColors))
addCss(context.loadCss(R.raw.editor_style, customColors))
}

isPlaceholderVisible
.onEach { isVisible -> binding.newMessagePlaceholder.isVisible = isVisible }
.launchIn(lifecycleScope)
}
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 disableButtonsWhenFocusIsLost() {
newMessageViewModel.isEditorWebViewFocusedLiveData.observe(viewLifecycleOwner, ::setToolbarEnabledStatus)
}

private fun setToolbarEnabledStatus(isEnabled: Boolean) {
binding.formatOptionsLayout.forEach { view -> view.isEnabled = isEnabled }
}

private fun initializeDraft() = with(newMessageViewModel) {
Expand Down Expand Up @@ -505,7 +528,7 @@ class NewMessageFragment : Fragment() {
newMessageViewModel.initResult.observe(viewLifecycleOwner) { (draft, signatures) ->
configureUiWithDraftData(draft)
setupFromField(signatures)
editorManager.setupEditorActions()
editorManager.setupEditorFormatActions()
editorManager.setupEditorFormatActionsToggle()
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,6 @@
*/
package com.infomaniak.mail.ui.newMessage

import android.view.View
import androidx.core.view.isGone
import androidx.core.view.isVisible
import com.infomaniak.mail.data.models.correspondent.Recipient
Expand Down Expand Up @@ -121,10 +120,11 @@ class NewMessageRecipientFieldsManager @Inject constructor(private val snackbarM
initializeFieldsAsOpen.observe(viewLifecycleOwner) { openAdvancedFields(isCollapsed = !it) }
}

fun setOnFocusChangedListeners() = with(binding) {
val listener = View.OnFocusChangeListener { _, hasFocus -> if (hasFocus) fieldGotFocus(null) }
subjectTextField.onFocusChangeListener = listener
editorWebView.onFocusChangeListener = listener
fun setOnFocusChangedListeners() = with(newMessageViewModel) {
binding.subjectTextField.setOnFocusChangeListener { _, hasFocus -> if (hasFocus) fieldGotFocus(field = null) }
binding.editorWebView.setOnFocusChangeListener { _, hasFocus -> isEditorWebViewFocusedLiveData.value = hasFocus }

isEditorWebViewFocusedLiveData.observe(viewLifecycleOwner) { hasFocus -> if (hasFocus) fieldGotFocus(field = null) }
}

fun focusBodyField() {
Expand Down
Loading
Loading