Skip to content

Commit

Permalink
[Rich text editor] Add plain text mode and new attachment UI (#7459)
Browse files Browse the repository at this point in the history
* Add new attachments selection dialog

* Add rounded corners to bottom sheet dialog.

Note these are currently only visible in the collapsed state.
- [Google issue](https://issuetracker.google.com/issues/144859239)
- [Rejected PR](material-components/material-components-android#437)
- [Github issue](material-components/material-components-android#1278)

* Add changelog entry

* Remove redundant call to superclass click listener

* Refactor to use view visibility helper

* Change redundant sealed class to interface

* Remove unused string

* Revert "Add rounded corners to bottom sheet dialog."

This reverts commit 17c43c9.

* Remove redundant view group

* Remove redundant `this`

* Update rich text editor to latest

* Update rich text editor version

* Allow toggling rich text in the new editor

* Persist the text formatting setting

* Add changelog entry
  • Loading branch information
jonnyandrew authored and jmartinesp committed Nov 2, 2022
1 parent 9fd9e1e commit cd05c7e
Show file tree
Hide file tree
Showing 17 changed files with 318 additions and 48 deletions.
1 change: 1 addition & 0 deletions changelog.d/7452.feature
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
[Rich text editor] Add plain text mode
2 changes: 1 addition & 1 deletion dependencies.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -98,7 +98,7 @@ ext.libs = [
],
element : [
'opusencoder' : "io.element.android:opusencoder:1.1.0",
'wysiwyg' : "io.element.android:wysiwyg:0.2.1"
'wysiwyg' : "io.element.android:wysiwyg:0.4.0"
],
squareup : [
'moshi' : "com.squareup.moshi:moshi:$moshi",
Expand Down
1 change: 1 addition & 0 deletions library/ui-strings/src/main/res/values/strings.xml
Original file line number Diff line number Diff line change
Expand Up @@ -3222,6 +3222,7 @@
<string name="attachment_type_selector_location">Location</string>
<string name="attachment_type_selector_camera">Camera</string>
<string name="attachment_type_selector_contact">Contact</string>
<string name="attachment_type_selector_text_formatting">Text formatting</string>

<string name="message_reaction_show_less">Show less</string>
<plurals name="message_reaction_show_more">
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,18 +23,18 @@ import android.view.ViewGroup
import androidx.core.view.isVisible
import androidx.fragment.app.FragmentManager
import androidx.fragment.app.viewModels
import com.airbnb.mvrx.fragmentViewModel
import com.airbnb.mvrx.parentFragmentViewModel
import com.airbnb.mvrx.withState
import dagger.hilt.android.AndroidEntryPoint
import im.vector.app.R
import im.vector.app.core.platform.VectorBaseBottomSheetDialogFragment
import im.vector.app.databinding.BottomSheetAttachmentTypeSelectorBinding
import im.vector.app.features.home.room.detail.TimelineViewModel

@AndroidEntryPoint
class AttachmentTypeSelectorBottomSheet : VectorBaseBottomSheetDialogFragment<BottomSheetAttachmentTypeSelectorBinding>() {

private val viewModel: AttachmentTypeSelectorViewModel by fragmentViewModel()
private val viewModel: AttachmentTypeSelectorViewModel by parentFragmentViewModel()
private val timelineViewModel: TimelineViewModel by parentFragmentViewModel()
private val sharedActionViewModel: AttachmentTypeSelectorSharedActionViewModel by viewModels(
ownerProducer = { requireParentFragment() }
Expand All @@ -51,6 +51,14 @@ class AttachmentTypeSelectorBottomSheet : VectorBaseBottomSheetDialogFragment<Bo
views.location.isVisible = viewState.isLocationVisible
views.voiceBroadcast.isVisible = viewState.isVoiceBroadcastVisible
views.poll.isVisible = !timelineState.isThreadTimeline()
views.textFormatting.isChecked = viewState.isTextFormattingEnabled
views.textFormatting.setCompoundDrawablesRelativeWithIntrinsicBounds(
if (viewState.isTextFormattingEnabled) {
R.drawable.ic_text_formatting
} else {
R.drawable.ic_text_formatting_disabled
}, 0, 0, 0
)
}

override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
Expand All @@ -63,6 +71,7 @@ class AttachmentTypeSelectorBottomSheet : VectorBaseBottomSheetDialogFragment<Bo
views.location.debouncedClicks { onAttachmentSelected(AttachmentType.LOCATION) }
views.camera.debouncedClicks { onAttachmentSelected(AttachmentType.CAMERA) }
views.contact.debouncedClicks { onAttachmentSelected(AttachmentType.CONTACT) }
views.textFormatting.setOnCheckedChangeListener { _, isChecked -> onTextFormattingToggled(isChecked) }
}

private fun onAttachmentSelected(attachmentType: AttachmentType) {
Expand All @@ -71,6 +80,9 @@ class AttachmentTypeSelectorBottomSheet : VectorBaseBottomSheetDialogFragment<Bo
dismiss()
}

private fun onTextFormattingToggled(isEnabled: Boolean) =
viewModel.handle(AttachmentTypeSelectorAction.ToggleTextFormatting(isEnabled))

companion object {
fun show(fragmentManager: FragmentManager) {
val bottomSheet = AttachmentTypeSelectorBottomSheet()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,31 +23,43 @@ import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
import im.vector.app.core.di.MavericksAssistedViewModelFactory
import im.vector.app.core.di.hiltMavericksViewModelFactory
import im.vector.app.core.platform.EmptyAction
import im.vector.app.core.platform.EmptyViewEvents
import im.vector.app.core.platform.VectorViewModel
import im.vector.app.core.platform.VectorViewModelAction
import im.vector.app.features.VectorFeatures
import im.vector.app.features.settings.VectorPreferences

class AttachmentTypeSelectorViewModel @AssistedInject constructor(
@Assisted initialState: AttachmentTypeSelectorViewState,
private val vectorFeatures: VectorFeatures,
) : VectorViewModel<AttachmentTypeSelectorViewState, EmptyAction, EmptyViewEvents>(initialState) {
private val vectorPreferences: VectorPreferences,
) : VectorViewModel<AttachmentTypeSelectorViewState, AttachmentTypeSelectorAction, EmptyViewEvents>(initialState) {
@AssistedFactory
interface Factory : MavericksAssistedViewModelFactory<AttachmentTypeSelectorViewModel, AttachmentTypeSelectorViewState> {
override fun create(initialState: AttachmentTypeSelectorViewState): AttachmentTypeSelectorViewModel
}

companion object : MavericksViewModelFactory<AttachmentTypeSelectorViewModel, AttachmentTypeSelectorViewState> by hiltMavericksViewModelFactory()

override fun handle(action: EmptyAction) {
// do nothing
override fun handle(action: AttachmentTypeSelectorAction) = when (action) {
is AttachmentTypeSelectorAction.ToggleTextFormatting -> setTextFormattingEnabled(action.isEnabled)
}

init {
setState {
copy(
isLocationVisible = vectorFeatures.isLocationSharingEnabled(),
isVoiceBroadcastVisible = vectorFeatures.isVoiceBroadcastEnabled(),
isTextFormattingEnabled = vectorPreferences.isTextFormattingEnabled(),
)
}
}

private fun setTextFormattingEnabled(isEnabled: Boolean) {
vectorPreferences.setTextFormattingEnabled(isEnabled)
setState {
copy(
isTextFormattingEnabled = isEnabled
)
}
}
Expand All @@ -56,4 +68,9 @@ class AttachmentTypeSelectorViewModel @AssistedInject constructor(
data class AttachmentTypeSelectorViewState(
val isLocationVisible: Boolean = false,
val isVoiceBroadcastVisible: Boolean = false,
val isTextFormattingEnabled: Boolean = false,
) : MavericksState

sealed interface AttachmentTypeSelectorAction : VectorViewModelAction {
data class ToggleTextFormatting(val isEnabled: Boolean) : AttachmentTypeSelectorAction
}
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ import androidx.core.view.isVisible
import androidx.fragment.app.viewModels
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.lifecycleScope
import com.airbnb.mvrx.fragmentViewModel
import com.airbnb.mvrx.parentFragmentViewModel
import com.airbnb.mvrx.withState
import com.google.android.material.dialog.MaterialAlertDialogBuilder
Expand All @@ -69,6 +70,7 @@ import im.vector.app.features.attachments.AttachmentTypeSelectorBottomSheet
import im.vector.app.features.attachments.AttachmentTypeSelectorSharedAction
import im.vector.app.features.attachments.AttachmentTypeSelectorSharedActionViewModel
import im.vector.app.features.attachments.AttachmentTypeSelectorView
import im.vector.app.features.attachments.AttachmentTypeSelectorViewModel
import im.vector.app.features.attachments.AttachmentsHelper
import im.vector.app.features.attachments.ContactAttachment
import im.vector.app.features.attachments.ShareIntentHandler
Expand Down Expand Up @@ -96,8 +98,8 @@ import im.vector.app.features.poll.PollMode
import im.vector.app.features.settings.VectorPreferences
import im.vector.app.features.share.SharedData
import im.vector.app.features.voice.VoiceFailure
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.debounce
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.filterIsInstance
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.map
Expand Down Expand Up @@ -168,7 +170,8 @@ class MessageComposerFragment : VectorBaseFragment<FragmentComposerBinding>(), A
private val timelineViewModel: TimelineViewModel by parentFragmentViewModel()
private val messageComposerViewModel: MessageComposerViewModel by parentFragmentViewModel()
private lateinit var sharedActionViewModel: MessageSharedActionViewModel
private val attachmentViewModel: AttachmentTypeSelectorSharedActionViewModel by viewModels()
private val attachmentViewModel: AttachmentTypeSelectorViewModel by fragmentViewModel()
private val attachmentActionsViewModel: AttachmentTypeSelectorSharedActionViewModel by viewModels()

private val composer: MessageComposerView get() {
return if (vectorPreferences.isRichTextEditorEnabled()) {
Expand Down Expand Up @@ -279,11 +282,14 @@ class MessageComposerFragment : VectorBaseFragment<FragmentComposerBinding>(), A
messageComposerViewModel.endAllVoiceActions()
}

override fun invalidate() = withState(timelineViewModel, messageComposerViewModel) { mainState, messageComposerState ->
override fun invalidate() = withState(
timelineViewModel, messageComposerViewModel, attachmentViewModel
) { mainState, messageComposerState, attachmentState ->
if (mainState.tombstoneEvent != null) return@withState

composer.setInvisible(!messageComposerState.isComposerVisible)
composer.sendButton.isInvisible = !messageComposerState.isSendButtonVisible
(composer as? RichTextComposerLayout)?.isTextFormattingEnabled = attachmentState.isTextFormattingEnabled
}

private fun setupComposer() {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ import im.vector.app.core.extensions.setTextIfDifferent
import im.vector.app.databinding.ComposerRichTextLayoutBinding
import im.vector.app.databinding.ViewRichTextMenuButtonBinding
import io.element.android.wysiwyg.EditorEditText
import io.element.android.wysiwyg.InlineFormat
import io.element.android.wysiwyg.inputhandlers.models.InlineFormat
import uniffi.wysiwyg_composer.ComposerAction
import uniffi.wysiwyg_composer.MenuState

Expand All @@ -57,12 +57,24 @@ class RichTextComposerLayout @JvmOverloads constructor(

private var isFullScreen = false

var isTextFormattingEnabled = true
set(value) {
if (field == value) return
syncEditTexts()
field = value
updateEditTextVisibility()
}

override val text: Editable?
get() = views.composerEditText.text
get() = editText.text
override val formattedText: String?
get() = views.composerEditText.getHtmlOutput()
get() = (editText as? EditorEditText)?.getHtmlOutput()
override val editText: EditText
get() = views.composerEditText
get() = if (isTextFormattingEnabled) {
views.richTextComposerEditText
} else {
views.plainTextComposerEditText
}
override val emojiButton: ImageButton?
get() = null
override val sendButton: ImageButton
Expand Down Expand Up @@ -91,21 +103,12 @@ class RichTextComposerLayout @JvmOverloads constructor(

collapse(false)

views.composerEditText.addTextChangedListener(object : TextWatcher {
private var previousTextWasExpanded = false

override fun beforeTextChanged(s: CharSequence?, start: Int, count: Int, after: Int) {}
override fun onTextChanged(s: CharSequence?, start: Int, before: Int, count: Int) {}
override fun afterTextChanged(s: Editable) {
callback?.onTextChanged(s)

val isExpanded = s.lines().count() > 1
if (previousTextWasExpanded != isExpanded) {
updateTextFieldBorder(isExpanded)
}
previousTextWasExpanded = isExpanded
}
})
views.richTextComposerEditText.addTextChangedListener(
TextChangeListener({ callback?.onTextChanged(it) }, ::updateTextFieldBorder)
)
views.plainTextComposerEditText.addTextChangedListener(
TextChangeListener({ callback?.onTextChanged(it) }, ::updateTextFieldBorder)
)

views.composerRelatedMessageCloseButton.setOnClickListener {
collapse()
Expand All @@ -130,28 +133,50 @@ class RichTextComposerLayout @JvmOverloads constructor(

private fun setupRichTextMenu() {
addRichTextMenuItem(R.drawable.ic_composer_bold, R.string.rich_text_editor_format_bold, ComposerAction.Bold) {
views.composerEditText.toggleInlineFormat(InlineFormat.Bold)
views.richTextComposerEditText.toggleInlineFormat(InlineFormat.Bold)
}
addRichTextMenuItem(R.drawable.ic_composer_italic, R.string.rich_text_editor_format_italic, ComposerAction.Italic) {
views.composerEditText.toggleInlineFormat(InlineFormat.Italic)
views.richTextComposerEditText.toggleInlineFormat(InlineFormat.Italic)
}
addRichTextMenuItem(R.drawable.ic_composer_underlined, R.string.rich_text_editor_format_underline, ComposerAction.Underline) {
views.composerEditText.toggleInlineFormat(InlineFormat.Underline)
views.richTextComposerEditText.toggleInlineFormat(InlineFormat.Underline)
}
addRichTextMenuItem(R.drawable.ic_composer_strikethrough, R.string.rich_text_editor_format_strikethrough, ComposerAction.StrikeThrough) {
views.composerEditText.toggleInlineFormat(InlineFormat.StrikeThrough)
views.richTextComposerEditText.toggleInlineFormat(InlineFormat.StrikeThrough)
}
}

override fun onAttachedToWindow() {
super.onAttachedToWindow()

views.composerEditText.menuStateChangedListener = EditorEditText.OnMenuStateChangedListener { state ->
views.richTextComposerEditText.menuStateChangedListener = EditorEditText.OnMenuStateChangedListener { state ->
if (state is MenuState.Update) {
updateMenuStateFor(ComposerAction.Bold, state)
updateMenuStateFor(ComposerAction.Italic, state)
updateMenuStateFor(ComposerAction.Underline, state)
updateMenuStateFor(ComposerAction.StrikeThrough, state)
}
}

updateEditTextVisibility()
}

private fun updateEditTextVisibility() {
views.richTextComposerEditText.isVisible = isTextFormattingEnabled
views.richTextMenu.isVisible = isTextFormattingEnabled
views.plainTextComposerEditText.isVisible = !isTextFormattingEnabled
}

/**
* Updates the non-active input with the contents of the active input.
*/
private fun syncEditTexts() =
if (isTextFormattingEnabled) {
views.plainTextComposerEditText.setText(views.richTextComposerEditText.getPlainText())
} else {
views.richTextComposerEditText.setText(views.plainTextComposerEditText.text.toString())
}

private fun addRichTextMenuItem(@DrawableRes iconId: Int, @StringRes description: Int, action: ComposerAction, onClick: () -> Unit) {
val inflater = LayoutInflater.from(context)
val button = ViewRichTextMenuButtonBinding.inflate(inflater, views.richTextMenu, true)
Expand Down Expand Up @@ -181,7 +206,7 @@ class RichTextComposerLayout @JvmOverloads constructor(
}

override fun replaceFormattedContent(text: CharSequence) {
views.composerEditText.setHtml(text.toString())
views.richTextComposerEditText.setHtml(text.toString())
}

override fun collapse(animate: Boolean, transitionComplete: (() -> Unit)?) {
Expand All @@ -191,6 +216,7 @@ class RichTextComposerLayout @JvmOverloads constructor(
}
currentConstraintSetId = R.layout.composer_rich_text_layout_constraint_set_compact
applyNewConstraintSet(animate, transitionComplete)
updateEditTextVisibility()
}

override fun expand(animate: Boolean, transitionComplete: (() -> Unit)?) {
Expand All @@ -200,10 +226,11 @@ class RichTextComposerLayout @JvmOverloads constructor(
}
currentConstraintSetId = R.layout.composer_rich_text_layout_constraint_set_expanded
applyNewConstraintSet(animate, transitionComplete)
updateEditTextVisibility()
}

override fun setTextIfDifferent(text: CharSequence?): Boolean {
return views.composerEditText.setTextIfDifferent(text)
return editText.setTextIfDifferent(text)
}

override fun toggleFullScreen(newValue: Boolean) {
Expand All @@ -214,6 +241,7 @@ class RichTextComposerLayout @JvmOverloads constructor(
}

updateTextFieldBorder(newValue)
updateEditTextVisibility()
}

private fun applyNewConstraintSet(animate: Boolean, transitionComplete: (() -> Unit)?) {
Expand All @@ -233,4 +261,23 @@ class RichTextComposerLayout @JvmOverloads constructor(
override fun setInvisible(isInvisible: Boolean) {
this.isInvisible = isInvisible
}

private class TextChangeListener(
private val onTextChanged: (s: Editable) -> Unit,
private val onExpandedChanged: (isExpanded: Boolean) -> Unit,
) : TextWatcher {
private var previousTextWasExpanded = false

override fun beforeTextChanged(s: CharSequence?, start: Int, count: Int, after: Int) {}
override fun onTextChanged(s: CharSequence?, start: Int, before: Int, count: Int) {}
override fun afterTextChanged(s: Editable) {
onTextChanged.invoke(s)

val isExpanded = s.lines().count() > 1
if (previousTextWasExpanded != isExpanded) {
onExpandedChanged(isExpanded)
}
previousTextWasExpanded = isExpanded
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,7 @@ class VectorPreferences @Inject constructor(
const val SETTINGS_SHOW_URL_PREVIEW_KEY = "SETTINGS_SHOW_URL_PREVIEW_KEY"
private const val SETTINGS_SEND_TYPING_NOTIF_KEY = "SETTINGS_SEND_TYPING_NOTIF_KEY"
private const val SETTINGS_ENABLE_MARKDOWN_KEY = "SETTINGS_ENABLE_MARKDOWN_KEY"
private const val SETTINGS_ENABLE_RICH_TEXT_FORMATTING_KEY = "SETTINGS_ENABLE_RICH_TEXT_FORMATTING_KEY"
private const val SETTINGS_ALWAYS_SHOW_TIMESTAMPS_KEY = "SETTINGS_ALWAYS_SHOW_TIMESTAMPS_KEY"
private const val SETTINGS_12_24_TIMESTAMPS_KEY = "SETTINGS_12_24_TIMESTAMPS_KEY"
private const val SETTINGS_SHOW_READ_RECEIPTS_KEY = "SETTINGS_SHOW_READ_RECEIPTS_KEY"
Expand Down Expand Up @@ -759,6 +760,24 @@ class VectorPreferences @Inject constructor(
}
}

/**
* Tells if text formatting is enabled within the rich text editor.
*
* @return true if the text formatting is enabled
*/
fun isTextFormattingEnabled(): Boolean =
defaultPrefs.getBoolean(SETTINGS_ENABLE_RICH_TEXT_FORMATTING_KEY, true)

/**
* Update whether text formatting is enabled within the rich text editor.
*
* @param isEnabled true to enable the text formatting
*/
fun setTextFormattingEnabled(isEnabled: Boolean) =
defaultPrefs.edit {
putBoolean(SETTINGS_ENABLE_RICH_TEXT_FORMATTING_KEY, isEnabled)
}

/**
* Tells if a confirmation dialog should be displayed before staring a call.
*/
Expand Down
Loading

0 comments on commit cd05c7e

Please sign in to comment.