Skip to content

Commit cd05c7e

Browse files
jonnyandrewjmartinesp
authored andcommitted
[Rich text editor] Add plain text mode and new attachment UI (#7459)
* 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
1 parent 9fd9e1e commit cd05c7e

17 files changed

+318
-48
lines changed

changelog.d/7452.feature

+1
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
[Rich text editor] Add plain text mode

dependencies.gradle

+1-1
Original file line numberDiff line numberDiff line change
@@ -98,7 +98,7 @@ ext.libs = [
9898
],
9999
element : [
100100
'opusencoder' : "io.element.android:opusencoder:1.1.0",
101-
'wysiwyg' : "io.element.android:wysiwyg:0.2.1"
101+
'wysiwyg' : "io.element.android:wysiwyg:0.4.0"
102102
],
103103
squareup : [
104104
'moshi' : "com.squareup.moshi:moshi:$moshi",

library/ui-strings/src/main/res/values/strings.xml

+1
Original file line numberDiff line numberDiff line change
@@ -3222,6 +3222,7 @@
32223222
<string name="attachment_type_selector_location">Location</string>
32233223
<string name="attachment_type_selector_camera">Camera</string>
32243224
<string name="attachment_type_selector_contact">Contact</string>
3225+
<string name="attachment_type_selector_text_formatting">Text formatting</string>
32253226

32263227
<string name="message_reaction_show_less">Show less</string>
32273228
<plurals name="message_reaction_show_more">

vector/src/main/java/im/vector/app/features/attachments/AttachmentTypeSelectorBottomSheet.kt

+14-2
Original file line numberDiff line numberDiff line change
@@ -23,18 +23,18 @@ import android.view.ViewGroup
2323
import androidx.core.view.isVisible
2424
import androidx.fragment.app.FragmentManager
2525
import androidx.fragment.app.viewModels
26-
import com.airbnb.mvrx.fragmentViewModel
2726
import com.airbnb.mvrx.parentFragmentViewModel
2827
import com.airbnb.mvrx.withState
2928
import dagger.hilt.android.AndroidEntryPoint
29+
import im.vector.app.R
3030
import im.vector.app.core.platform.VectorBaseBottomSheetDialogFragment
3131
import im.vector.app.databinding.BottomSheetAttachmentTypeSelectorBinding
3232
import im.vector.app.features.home.room.detail.TimelineViewModel
3333

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

37-
private val viewModel: AttachmentTypeSelectorViewModel by fragmentViewModel()
37+
private val viewModel: AttachmentTypeSelectorViewModel by parentFragmentViewModel()
3838
private val timelineViewModel: TimelineViewModel by parentFragmentViewModel()
3939
private val sharedActionViewModel: AttachmentTypeSelectorSharedActionViewModel by viewModels(
4040
ownerProducer = { requireParentFragment() }
@@ -51,6 +51,14 @@ class AttachmentTypeSelectorBottomSheet : VectorBaseBottomSheetDialogFragment<Bo
5151
views.location.isVisible = viewState.isLocationVisible
5252
views.voiceBroadcast.isVisible = viewState.isVoiceBroadcastVisible
5353
views.poll.isVisible = !timelineState.isThreadTimeline()
54+
views.textFormatting.isChecked = viewState.isTextFormattingEnabled
55+
views.textFormatting.setCompoundDrawablesRelativeWithIntrinsicBounds(
56+
if (viewState.isTextFormattingEnabled) {
57+
R.drawable.ic_text_formatting
58+
} else {
59+
R.drawable.ic_text_formatting_disabled
60+
}, 0, 0, 0
61+
)
5462
}
5563

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

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

83+
private fun onTextFormattingToggled(isEnabled: Boolean) =
84+
viewModel.handle(AttachmentTypeSelectorAction.ToggleTextFormatting(isEnabled))
85+
7486
companion object {
7587
fun show(fragmentManager: FragmentManager) {
7688
val bottomSheet = AttachmentTypeSelectorBottomSheet()

vector/src/main/java/im/vector/app/features/attachments/AttachmentTypeSelectorViewModel.kt

+21-4
Original file line numberDiff line numberDiff line change
@@ -23,31 +23,43 @@ import dagger.assisted.AssistedFactory
2323
import dagger.assisted.AssistedInject
2424
import im.vector.app.core.di.MavericksAssistedViewModelFactory
2525
import im.vector.app.core.di.hiltMavericksViewModelFactory
26-
import im.vector.app.core.platform.EmptyAction
2726
import im.vector.app.core.platform.EmptyViewEvents
2827
import im.vector.app.core.platform.VectorViewModel
28+
import im.vector.app.core.platform.VectorViewModelAction
2929
import im.vector.app.features.VectorFeatures
30+
import im.vector.app.features.settings.VectorPreferences
3031

3132
class AttachmentTypeSelectorViewModel @AssistedInject constructor(
3233
@Assisted initialState: AttachmentTypeSelectorViewState,
3334
private val vectorFeatures: VectorFeatures,
34-
) : VectorViewModel<AttachmentTypeSelectorViewState, EmptyAction, EmptyViewEvents>(initialState) {
35+
private val vectorPreferences: VectorPreferences,
36+
) : VectorViewModel<AttachmentTypeSelectorViewState, AttachmentTypeSelectorAction, EmptyViewEvents>(initialState) {
3537
@AssistedFactory
3638
interface Factory : MavericksAssistedViewModelFactory<AttachmentTypeSelectorViewModel, AttachmentTypeSelectorViewState> {
3739
override fun create(initialState: AttachmentTypeSelectorViewState): AttachmentTypeSelectorViewModel
3840
}
3941

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

42-
override fun handle(action: EmptyAction) {
43-
// do nothing
44+
override fun handle(action: AttachmentTypeSelectorAction) = when (action) {
45+
is AttachmentTypeSelectorAction.ToggleTextFormatting -> setTextFormattingEnabled(action.isEnabled)
4446
}
4547

4648
init {
4749
setState {
4850
copy(
4951
isLocationVisible = vectorFeatures.isLocationSharingEnabled(),
5052
isVoiceBroadcastVisible = vectorFeatures.isVoiceBroadcastEnabled(),
53+
isTextFormattingEnabled = vectorPreferences.isTextFormattingEnabled(),
54+
)
55+
}
56+
}
57+
58+
private fun setTextFormattingEnabled(isEnabled: Boolean) {
59+
vectorPreferences.setTextFormattingEnabled(isEnabled)
60+
setState {
61+
copy(
62+
isTextFormattingEnabled = isEnabled
5163
)
5264
}
5365
}
@@ -56,4 +68,9 @@ class AttachmentTypeSelectorViewModel @AssistedInject constructor(
5668
data class AttachmentTypeSelectorViewState(
5769
val isLocationVisible: Boolean = false,
5870
val isVoiceBroadcastVisible: Boolean = false,
71+
val isTextFormattingEnabled: Boolean = false,
5972
) : MavericksState
73+
74+
sealed interface AttachmentTypeSelectorAction : VectorViewModelAction {
75+
data class ToggleTextFormatting(val isEnabled: Boolean) : AttachmentTypeSelectorAction
76+
}

vector/src/main/java/im/vector/app/features/home/room/detail/composer/MessageComposerFragment.kt

+9-3
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@ import androidx.core.view.isVisible
4343
import androidx.fragment.app.viewModels
4444
import androidx.lifecycle.Lifecycle
4545
import androidx.lifecycle.lifecycleScope
46+
import com.airbnb.mvrx.fragmentViewModel
4647
import com.airbnb.mvrx.parentFragmentViewModel
4748
import com.airbnb.mvrx.withState
4849
import com.google.android.material.dialog.MaterialAlertDialogBuilder
@@ -69,6 +70,7 @@ import im.vector.app.features.attachments.AttachmentTypeSelectorBottomSheet
6970
import im.vector.app.features.attachments.AttachmentTypeSelectorSharedAction
7071
import im.vector.app.features.attachments.AttachmentTypeSelectorSharedActionViewModel
7172
import im.vector.app.features.attachments.AttachmentTypeSelectorView
73+
import im.vector.app.features.attachments.AttachmentTypeSelectorViewModel
7274
import im.vector.app.features.attachments.AttachmentsHelper
7375
import im.vector.app.features.attachments.ContactAttachment
7476
import im.vector.app.features.attachments.ShareIntentHandler
@@ -96,8 +98,8 @@ import im.vector.app.features.poll.PollMode
9698
import im.vector.app.features.settings.VectorPreferences
9799
import im.vector.app.features.share.SharedData
98100
import im.vector.app.features.voice.VoiceFailure
99-
import kotlinx.coroutines.flow.distinctUntilChanged
100101
import kotlinx.coroutines.flow.debounce
102+
import kotlinx.coroutines.flow.distinctUntilChanged
101103
import kotlinx.coroutines.flow.filterIsInstance
102104
import kotlinx.coroutines.flow.launchIn
103105
import kotlinx.coroutines.flow.map
@@ -168,7 +170,8 @@ class MessageComposerFragment : VectorBaseFragment<FragmentComposerBinding>(), A
168170
private val timelineViewModel: TimelineViewModel by parentFragmentViewModel()
169171
private val messageComposerViewModel: MessageComposerViewModel by parentFragmentViewModel()
170172
private lateinit var sharedActionViewModel: MessageSharedActionViewModel
171-
private val attachmentViewModel: AttachmentTypeSelectorSharedActionViewModel by viewModels()
173+
private val attachmentViewModel: AttachmentTypeSelectorViewModel by fragmentViewModel()
174+
private val attachmentActionsViewModel: AttachmentTypeSelectorSharedActionViewModel by viewModels()
172175

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

282-
override fun invalidate() = withState(timelineViewModel, messageComposerViewModel) { mainState, messageComposerState ->
285+
override fun invalidate() = withState(
286+
timelineViewModel, messageComposerViewModel, attachmentViewModel
287+
) { mainState, messageComposerState, attachmentState ->
283288
if (mainState.tombstoneEvent != null) return@withState
284289

285290
composer.setInvisible(!messageComposerState.isComposerVisible)
286291
composer.sendButton.isInvisible = !messageComposerState.isSendButtonVisible
292+
(composer as? RichTextComposerLayout)?.isTextFormattingEnabled = attachmentState.isTextFormattingEnabled
287293
}
288294

289295
private fun setupComposer() {

vector/src/main/java/im/vector/app/features/home/room/detail/composer/RichTextComposerLayout.kt

+73-26
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,7 @@ import im.vector.app.core.extensions.setTextIfDifferent
3838
import im.vector.app.databinding.ComposerRichTextLayoutBinding
3939
import im.vector.app.databinding.ViewRichTextMenuButtonBinding
4040
import io.element.android.wysiwyg.EditorEditText
41-
import io.element.android.wysiwyg.InlineFormat
41+
import io.element.android.wysiwyg.inputhandlers.models.InlineFormat
4242
import uniffi.wysiwyg_composer.ComposerAction
4343
import uniffi.wysiwyg_composer.MenuState
4444

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

5858
private var isFullScreen = false
5959

60+
var isTextFormattingEnabled = true
61+
set(value) {
62+
if (field == value) return
63+
syncEditTexts()
64+
field = value
65+
updateEditTextVisibility()
66+
}
67+
6068
override val text: Editable?
61-
get() = views.composerEditText.text
69+
get() = editText.text
6270
override val formattedText: String?
63-
get() = views.composerEditText.getHtmlOutput()
71+
get() = (editText as? EditorEditText)?.getHtmlOutput()
6472
override val editText: EditText
65-
get() = views.composerEditText
73+
get() = if (isTextFormattingEnabled) {
74+
views.richTextComposerEditText
75+
} else {
76+
views.plainTextComposerEditText
77+
}
6678
override val emojiButton: ImageButton?
6779
get() = null
6880
override val sendButton: ImageButton
@@ -91,21 +103,12 @@ class RichTextComposerLayout @JvmOverloads constructor(
91103

92104
collapse(false)
93105

94-
views.composerEditText.addTextChangedListener(object : TextWatcher {
95-
private var previousTextWasExpanded = false
96-
97-
override fun beforeTextChanged(s: CharSequence?, start: Int, count: Int, after: Int) {}
98-
override fun onTextChanged(s: CharSequence?, start: Int, before: Int, count: Int) {}
99-
override fun afterTextChanged(s: Editable) {
100-
callback?.onTextChanged(s)
101-
102-
val isExpanded = s.lines().count() > 1
103-
if (previousTextWasExpanded != isExpanded) {
104-
updateTextFieldBorder(isExpanded)
105-
}
106-
previousTextWasExpanded = isExpanded
107-
}
108-
})
106+
views.richTextComposerEditText.addTextChangedListener(
107+
TextChangeListener({ callback?.onTextChanged(it) }, ::updateTextFieldBorder)
108+
)
109+
views.plainTextComposerEditText.addTextChangedListener(
110+
TextChangeListener({ callback?.onTextChanged(it) }, ::updateTextFieldBorder)
111+
)
109112

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

131134
private fun setupRichTextMenu() {
132135
addRichTextMenuItem(R.drawable.ic_composer_bold, R.string.rich_text_editor_format_bold, ComposerAction.Bold) {
133-
views.composerEditText.toggleInlineFormat(InlineFormat.Bold)
136+
views.richTextComposerEditText.toggleInlineFormat(InlineFormat.Bold)
134137
}
135138
addRichTextMenuItem(R.drawable.ic_composer_italic, R.string.rich_text_editor_format_italic, ComposerAction.Italic) {
136-
views.composerEditText.toggleInlineFormat(InlineFormat.Italic)
139+
views.richTextComposerEditText.toggleInlineFormat(InlineFormat.Italic)
137140
}
138141
addRichTextMenuItem(R.drawable.ic_composer_underlined, R.string.rich_text_editor_format_underline, ComposerAction.Underline) {
139-
views.composerEditText.toggleInlineFormat(InlineFormat.Underline)
142+
views.richTextComposerEditText.toggleInlineFormat(InlineFormat.Underline)
140143
}
141144
addRichTextMenuItem(R.drawable.ic_composer_strikethrough, R.string.rich_text_editor_format_strikethrough, ComposerAction.StrikeThrough) {
142-
views.composerEditText.toggleInlineFormat(InlineFormat.StrikeThrough)
145+
views.richTextComposerEditText.toggleInlineFormat(InlineFormat.StrikeThrough)
143146
}
147+
}
148+
149+
override fun onAttachedToWindow() {
150+
super.onAttachedToWindow()
144151

145-
views.composerEditText.menuStateChangedListener = EditorEditText.OnMenuStateChangedListener { state ->
152+
views.richTextComposerEditText.menuStateChangedListener = EditorEditText.OnMenuStateChangedListener { state ->
146153
if (state is MenuState.Update) {
147154
updateMenuStateFor(ComposerAction.Bold, state)
148155
updateMenuStateFor(ComposerAction.Italic, state)
149156
updateMenuStateFor(ComposerAction.Underline, state)
150157
updateMenuStateFor(ComposerAction.StrikeThrough, state)
151158
}
152159
}
160+
161+
updateEditTextVisibility()
153162
}
154163

164+
private fun updateEditTextVisibility() {
165+
views.richTextComposerEditText.isVisible = isTextFormattingEnabled
166+
views.richTextMenu.isVisible = isTextFormattingEnabled
167+
views.plainTextComposerEditText.isVisible = !isTextFormattingEnabled
168+
}
169+
170+
/**
171+
* Updates the non-active input with the contents of the active input.
172+
*/
173+
private fun syncEditTexts() =
174+
if (isTextFormattingEnabled) {
175+
views.plainTextComposerEditText.setText(views.richTextComposerEditText.getPlainText())
176+
} else {
177+
views.richTextComposerEditText.setText(views.plainTextComposerEditText.text.toString())
178+
}
179+
155180
private fun addRichTextMenuItem(@DrawableRes iconId: Int, @StringRes description: Int, action: ComposerAction, onClick: () -> Unit) {
156181
val inflater = LayoutInflater.from(context)
157182
val button = ViewRichTextMenuButtonBinding.inflate(inflater, views.richTextMenu, true)
@@ -181,7 +206,7 @@ class RichTextComposerLayout @JvmOverloads constructor(
181206
}
182207

183208
override fun replaceFormattedContent(text: CharSequence) {
184-
views.composerEditText.setHtml(text.toString())
209+
views.richTextComposerEditText.setHtml(text.toString())
185210
}
186211

187212
override fun collapse(animate: Boolean, transitionComplete: (() -> Unit)?) {
@@ -191,6 +216,7 @@ class RichTextComposerLayout @JvmOverloads constructor(
191216
}
192217
currentConstraintSetId = R.layout.composer_rich_text_layout_constraint_set_compact
193218
applyNewConstraintSet(animate, transitionComplete)
219+
updateEditTextVisibility()
194220
}
195221

196222
override fun expand(animate: Boolean, transitionComplete: (() -> Unit)?) {
@@ -200,10 +226,11 @@ class RichTextComposerLayout @JvmOverloads constructor(
200226
}
201227
currentConstraintSetId = R.layout.composer_rich_text_layout_constraint_set_expanded
202228
applyNewConstraintSet(animate, transitionComplete)
229+
updateEditTextVisibility()
203230
}
204231

205232
override fun setTextIfDifferent(text: CharSequence?): Boolean {
206-
return views.composerEditText.setTextIfDifferent(text)
233+
return editText.setTextIfDifferent(text)
207234
}
208235

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

216243
updateTextFieldBorder(newValue)
244+
updateEditTextVisibility()
217245
}
218246

219247
private fun applyNewConstraintSet(animate: Boolean, transitionComplete: (() -> Unit)?) {
@@ -233,4 +261,23 @@ class RichTextComposerLayout @JvmOverloads constructor(
233261
override fun setInvisible(isInvisible: Boolean) {
234262
this.isInvisible = isInvisible
235263
}
264+
265+
private class TextChangeListener(
266+
private val onTextChanged: (s: Editable) -> Unit,
267+
private val onExpandedChanged: (isExpanded: Boolean) -> Unit,
268+
) : TextWatcher {
269+
private var previousTextWasExpanded = false
270+
271+
override fun beforeTextChanged(s: CharSequence?, start: Int, count: Int, after: Int) {}
272+
override fun onTextChanged(s: CharSequence?, start: Int, before: Int, count: Int) {}
273+
override fun afterTextChanged(s: Editable) {
274+
onTextChanged.invoke(s)
275+
276+
val isExpanded = s.lines().count() > 1
277+
if (previousTextWasExpanded != isExpanded) {
278+
onExpandedChanged(isExpanded)
279+
}
280+
previousTextWasExpanded = isExpanded
281+
}
282+
}
236283
}

vector/src/main/java/im/vector/app/features/settings/VectorPreferences.kt

+19
Original file line numberDiff line numberDiff line change
@@ -109,6 +109,7 @@ class VectorPreferences @Inject constructor(
109109
const val SETTINGS_SHOW_URL_PREVIEW_KEY = "SETTINGS_SHOW_URL_PREVIEW_KEY"
110110
private const val SETTINGS_SEND_TYPING_NOTIF_KEY = "SETTINGS_SEND_TYPING_NOTIF_KEY"
111111
private const val SETTINGS_ENABLE_MARKDOWN_KEY = "SETTINGS_ENABLE_MARKDOWN_KEY"
112+
private const val SETTINGS_ENABLE_RICH_TEXT_FORMATTING_KEY = "SETTINGS_ENABLE_RICH_TEXT_FORMATTING_KEY"
112113
private const val SETTINGS_ALWAYS_SHOW_TIMESTAMPS_KEY = "SETTINGS_ALWAYS_SHOW_TIMESTAMPS_KEY"
113114
private const val SETTINGS_12_24_TIMESTAMPS_KEY = "SETTINGS_12_24_TIMESTAMPS_KEY"
114115
private const val SETTINGS_SHOW_READ_RECEIPTS_KEY = "SETTINGS_SHOW_READ_RECEIPTS_KEY"
@@ -759,6 +760,24 @@ class VectorPreferences @Inject constructor(
759760
}
760761
}
761762

763+
/**
764+
* Tells if text formatting is enabled within the rich text editor.
765+
*
766+
* @return true if the text formatting is enabled
767+
*/
768+
fun isTextFormattingEnabled(): Boolean =
769+
defaultPrefs.getBoolean(SETTINGS_ENABLE_RICH_TEXT_FORMATTING_KEY, true)
770+
771+
/**
772+
* Update whether text formatting is enabled within the rich text editor.
773+
*
774+
* @param isEnabled true to enable the text formatting
775+
*/
776+
fun setTextFormattingEnabled(isEnabled: Boolean) =
777+
defaultPrefs.edit {
778+
putBoolean(SETTINGS_ENABLE_RICH_TEXT_FORMATTING_KEY, isEnabled)
779+
}
780+
762781
/**
763782
* Tells if a confirmation dialog should be displayed before staring a call.
764783
*/

0 commit comments

Comments
 (0)