[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](https://github.com/material-components/material-components-android/pull/437)
- [Github issue](https://github.com/material-components/material-components-android/issues/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 17c43c91888162d3c7675511ff910c46c3aa32fc.

* 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
This commit is contained in:
jonnyandrew 2022-10-26 17:37:40 +01:00 committed by GitHub
parent bdfc96ff66
commit c776aae9d0
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
25 changed files with 797 additions and 102 deletions

1
changelog.d/7429.feature Normal file
View File

@ -0,0 +1 @@
Add new UI for selecting an attachment

1
changelog.d/7452.feature Normal file
View File

@ -0,0 +1 @@
[Rich text editor] Add plain text mode

View File

@ -101,7 +101,7 @@ ext.libs = [
], ],
element : [ element : [
'opusencoder' : "io.element.android:opusencoder:1.1.0", '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 : [ squareup : [
'moshi' : "com.squareup.moshi:moshi:$moshi", 'moshi' : "com.squareup.moshi:moshi:$moshi",

View File

@ -3205,6 +3205,16 @@
<string name="tooltip_attachment_location">Share location</string> <string name="tooltip_attachment_location">Share location</string>
<string name="tooltip_attachment_voice_broadcast">Start a voice broadcast</string> <string name="tooltip_attachment_voice_broadcast">Start a voice broadcast</string>
<string name="attachment_type_selector_gallery">Photo library</string>
<string name="attachment_type_selector_sticker">Stickers</string>
<string name="attachment_type_selector_file">Attachments</string>
<string name="attachment_type_selector_voice_broadcast">Voice broadcast</string>
<string name="attachment_type_selector_poll">Polls</string>
<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> <string name="message_reaction_show_less">Show less</string>
<plurals name="message_reaction_show_more"> <plurals name="message_reaction_show_more">
<item quantity="one">"%1$d more"</item> <item quantity="one">"%1$d more"</item>

View File

@ -22,6 +22,7 @@ import dagger.hilt.InstallIn
import dagger.multibindings.IntoMap import dagger.multibindings.IntoMap
import im.vector.app.features.analytics.accountdata.AnalyticsAccountDataViewModel import im.vector.app.features.analytics.accountdata.AnalyticsAccountDataViewModel
import im.vector.app.features.analytics.ui.consent.AnalyticsConsentViewModel import im.vector.app.features.analytics.ui.consent.AnalyticsConsentViewModel
import im.vector.app.features.attachments.AttachmentTypeSelectorViewModel
import im.vector.app.features.auth.ReAuthViewModel import im.vector.app.features.auth.ReAuthViewModel
import im.vector.app.features.call.VectorCallViewModel import im.vector.app.features.call.VectorCallViewModel
import im.vector.app.features.call.conference.JitsiCallViewModel import im.vector.app.features.call.conference.JitsiCallViewModel
@ -677,4 +678,9 @@ interface MavericksViewModelModule {
@IntoMap @IntoMap
@MavericksViewModelKey(VectorSettingsLabsViewModel::class) @MavericksViewModelKey(VectorSettingsLabsViewModel::class)
fun vectorSettingsLabsViewModelFactory(factory: VectorSettingsLabsViewModel.Factory): MavericksAssistedViewModelFactory<*, *> fun vectorSettingsLabsViewModelFactory(factory: VectorSettingsLabsViewModel.Factory): MavericksAssistedViewModelFactory<*, *>
@Binds
@IntoMap
@MavericksViewModelKey(AttachmentTypeSelectorViewModel::class)
fun attachmentTypeSelectorViewModelFactory(factory: AttachmentTypeSelectorViewModel.Factory): MavericksAssistedViewModelFactory<*, *>
} }

View File

@ -38,6 +38,10 @@ class BottomSheetActionButton @JvmOverloads constructor(
) : FrameLayout(context, attrs, defStyleAttr) { ) : FrameLayout(context, attrs, defStyleAttr) {
val views: ViewBottomSheetActionButtonBinding val views: ViewBottomSheetActionButtonBinding
override fun setOnClickListener(l: OnClickListener?) {
views.bottomSheetActionClickableZone.setOnClickListener(l)
}
var title: String? = null var title: String? = null
set(value) { set(value) {
field = value field = value

View File

@ -0,0 +1,37 @@
/*
* Copyright (c) 2022 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package im.vector.app.features.attachments
import im.vector.app.core.utils.PERMISSIONS_EMPTY
import im.vector.app.core.utils.PERMISSIONS_FOR_FOREGROUND_LOCATION_SHARING
import im.vector.app.core.utils.PERMISSIONS_FOR_PICKING_CONTACT
import im.vector.app.core.utils.PERMISSIONS_FOR_TAKING_PHOTO
import im.vector.app.core.utils.PERMISSIONS_FOR_VOICE_BROADCAST
/**
* The all possible types to pick with their required permissions.
*/
enum class AttachmentType(val permissions: List<String>) {
CAMERA(PERMISSIONS_FOR_TAKING_PHOTO),
GALLERY(PERMISSIONS_EMPTY),
FILE(PERMISSIONS_EMPTY),
STICKER(PERMISSIONS_EMPTY),
CONTACT(PERMISSIONS_FOR_PICKING_CONTACT),
POLL(PERMISSIONS_EMPTY),
LOCATION(PERMISSIONS_FOR_FOREGROUND_LOCATION_SHARING),
VOICE_BROADCAST(PERMISSIONS_FOR_VOICE_BROADCAST),
}

View File

@ -0,0 +1,92 @@
/*
* Copyright (c) 2022 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package im.vector.app.features.attachments
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.core.view.isVisible
import androidx.fragment.app.FragmentManager
import androidx.fragment.app.viewModels
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 parentFragmentViewModel()
private val timelineViewModel: TimelineViewModel by parentFragmentViewModel()
private val sharedActionViewModel: AttachmentTypeSelectorSharedActionViewModel by viewModels(
ownerProducer = { requireParentFragment() }
)
override val showExpanded = true
override fun getBinding(inflater: LayoutInflater, container: ViewGroup?): BottomSheetAttachmentTypeSelectorBinding {
return BottomSheetAttachmentTypeSelectorBinding.inflate(inflater, container, false)
}
override fun invalidate() = withState(viewModel, timelineViewModel) { viewState, timelineState ->
super.invalidate()
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?) {
super.onViewCreated(view, savedInstanceState)
views.gallery.debouncedClicks { onAttachmentSelected(AttachmentType.GALLERY) }
views.stickers.debouncedClicks { onAttachmentSelected(AttachmentType.STICKER) }
views.file.debouncedClicks { onAttachmentSelected(AttachmentType.FILE) }
views.voiceBroadcast.debouncedClicks { onAttachmentSelected(AttachmentType.VOICE_BROADCAST) }
views.poll.debouncedClicks { onAttachmentSelected(AttachmentType.POLL) }
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) {
val action = AttachmentTypeSelectorSharedAction.SelectAttachmentTypeAction(attachmentType)
sharedActionViewModel.post(action)
dismiss()
}
private fun onTextFormattingToggled(isEnabled: Boolean) =
viewModel.handle(AttachmentTypeSelectorAction.ToggleTextFormatting(isEnabled))
companion object {
fun show(fragmentManager: FragmentManager) {
val bottomSheet = AttachmentTypeSelectorBottomSheet()
bottomSheet.show(fragmentManager, "AttachmentTypeSelectorBottomSheet")
}
}
}

View File

@ -0,0 +1,30 @@
/*
* Copyright (c) 2022 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*
*/
package im.vector.app.features.attachments
import im.vector.app.core.platform.VectorSharedAction
import im.vector.app.core.platform.VectorSharedActionViewModel
import javax.inject.Inject
class AttachmentTypeSelectorSharedActionViewModel @Inject constructor() :
VectorSharedActionViewModel<AttachmentTypeSelectorSharedAction>()
sealed interface AttachmentTypeSelectorSharedAction : VectorSharedAction {
data class SelectAttachmentTypeAction(
val attachmentType: AttachmentType
) : AttachmentTypeSelectorSharedAction
}

View File

@ -30,17 +30,11 @@ import android.view.animation.TranslateAnimation
import android.widget.ImageButton import android.widget.ImageButton
import android.widget.LinearLayout import android.widget.LinearLayout
import android.widget.PopupWindow import android.widget.PopupWindow
import androidx.annotation.StringRes
import androidx.appcompat.widget.TooltipCompat import androidx.appcompat.widget.TooltipCompat
import androidx.core.view.doOnNextLayout import androidx.core.view.doOnNextLayout
import androidx.core.view.isVisible import androidx.core.view.isVisible
import im.vector.app.R import im.vector.app.R
import im.vector.app.core.epoxy.onClick import im.vector.app.core.epoxy.onClick
import im.vector.app.core.utils.PERMISSIONS_EMPTY
import im.vector.app.core.utils.PERMISSIONS_FOR_FOREGROUND_LOCATION_SHARING
import im.vector.app.core.utils.PERMISSIONS_FOR_PICKING_CONTACT
import im.vector.app.core.utils.PERMISSIONS_FOR_TAKING_PHOTO
import im.vector.app.core.utils.PERMISSIONS_FOR_VOICE_BROADCAST
import im.vector.app.databinding.ViewAttachmentTypeSelectorBinding import im.vector.app.databinding.ViewAttachmentTypeSelectorBinding
import im.vector.app.features.attachments.AttachmentTypeSelectorView.Callback import im.vector.app.features.attachments.AttachmentTypeSelectorView.Callback
import kotlin.math.max import kotlin.math.max
@ -59,7 +53,7 @@ class AttachmentTypeSelectorView(
) : PopupWindow(context) { ) : PopupWindow(context) {
interface Callback { interface Callback {
fun onTypeSelected(type: Type) fun onTypeSelected(type: AttachmentType)
} }
private val views: ViewAttachmentTypeSelectorBinding private val views: ViewAttachmentTypeSelectorBinding
@ -69,14 +63,14 @@ class AttachmentTypeSelectorView(
init { init {
contentView = inflater.inflate(R.layout.view_attachment_type_selector, null, false) contentView = inflater.inflate(R.layout.view_attachment_type_selector, null, false)
views = ViewAttachmentTypeSelectorBinding.bind(contentView) views = ViewAttachmentTypeSelectorBinding.bind(contentView)
views.attachmentGalleryButton.configure(Type.GALLERY) views.attachmentGalleryButton.configure(AttachmentType.GALLERY)
views.attachmentCameraButton.configure(Type.CAMERA) views.attachmentCameraButton.configure(AttachmentType.CAMERA)
views.attachmentFileButton.configure(Type.FILE) views.attachmentFileButton.configure(AttachmentType.FILE)
views.attachmentStickersButton.configure(Type.STICKER) views.attachmentStickersButton.configure(AttachmentType.STICKER)
views.attachmentContactButton.configure(Type.CONTACT) views.attachmentContactButton.configure(AttachmentType.CONTACT)
views.attachmentPollButton.configure(Type.POLL) views.attachmentPollButton.configure(AttachmentType.POLL)
views.attachmentLocationButton.configure(Type.LOCATION) views.attachmentLocationButton.configure(AttachmentType.LOCATION)
views.attachmentVoiceBroadcast.configure(Type.VOICE_BROADCAST) views.attachmentVoiceBroadcast.configure(AttachmentType.VOICE_BROADCAST)
width = LinearLayout.LayoutParams.MATCH_PARENT width = LinearLayout.LayoutParams.MATCH_PARENT
height = LinearLayout.LayoutParams.WRAP_CONTENT height = LinearLayout.LayoutParams.WRAP_CONTENT
animationStyle = 0 animationStyle = 0
@ -127,16 +121,16 @@ class AttachmentTypeSelectorView(
} }
} }
fun setAttachmentVisibility(type: Type, isVisible: Boolean) { fun setAttachmentVisibility(type: AttachmentType, isVisible: Boolean) {
when (type) { when (type) {
Type.CAMERA -> views.attachmentCameraButton AttachmentType.CAMERA -> views.attachmentCameraButton
Type.GALLERY -> views.attachmentGalleryButton AttachmentType.GALLERY -> views.attachmentGalleryButton
Type.FILE -> views.attachmentFileButton AttachmentType.FILE -> views.attachmentFileButton
Type.STICKER -> views.attachmentStickersButton AttachmentType.STICKER -> views.attachmentStickersButton
Type.CONTACT -> views.attachmentContactButton AttachmentType.CONTACT -> views.attachmentContactButton
Type.POLL -> views.attachmentPollButton AttachmentType.POLL -> views.attachmentPollButton
Type.LOCATION -> views.attachmentLocationButton AttachmentType.LOCATION -> views.attachmentLocationButton
Type.VOICE_BROADCAST -> views.attachmentVoiceBroadcast AttachmentType.VOICE_BROADCAST -> views.attachmentVoiceBroadcast
}.let { }.let {
it.isVisible = isVisible it.isVisible = isVisible
} }
@ -200,13 +194,13 @@ class AttachmentTypeSelectorView(
return Pair(x, y) return Pair(x, y)
} }
private fun ImageButton.configure(type: Type): ImageButton { private fun ImageButton.configure(type: AttachmentType): ImageButton {
this.setOnClickListener(TypeClickListener(type)) this.setOnClickListener(TypeClickListener(type))
TooltipCompat.setTooltipText(this, context.getString(type.tooltipRes)) TooltipCompat.setTooltipText(this, context.getString(attachmentTooltipLabels.getValue(type)))
return this return this
} }
private inner class TypeClickListener(private val type: Type) : View.OnClickListener { private inner class TypeClickListener(private val type: AttachmentType) : View.OnClickListener {
override fun onClick(v: View) { override fun onClick(v: View) {
dismiss() dismiss()
@ -217,14 +211,18 @@ class AttachmentTypeSelectorView(
/** /**
* The all possible types to pick with their required permissions and tooltip resource. * The all possible types to pick with their required permissions and tooltip resource.
*/ */
enum class Type(val permissions: List<String>, @StringRes val tooltipRes: Int) { private companion object {
CAMERA(PERMISSIONS_FOR_TAKING_PHOTO, R.string.tooltip_attachment_photo), private val attachmentTooltipLabels: Map<AttachmentType, Int> = AttachmentType.values().associateWith {
GALLERY(PERMISSIONS_EMPTY, R.string.tooltip_attachment_gallery), when (it) {
FILE(PERMISSIONS_EMPTY, R.string.tooltip_attachment_file), AttachmentType.CAMERA -> R.string.tooltip_attachment_photo
STICKER(PERMISSIONS_EMPTY, R.string.tooltip_attachment_sticker), AttachmentType.GALLERY -> R.string.tooltip_attachment_gallery
CONTACT(PERMISSIONS_FOR_PICKING_CONTACT, R.string.tooltip_attachment_contact), AttachmentType.FILE -> R.string.tooltip_attachment_file
POLL(PERMISSIONS_EMPTY, R.string.tooltip_attachment_poll), AttachmentType.STICKER -> R.string.tooltip_attachment_sticker
LOCATION(PERMISSIONS_FOR_FOREGROUND_LOCATION_SHARING, R.string.tooltip_attachment_location), AttachmentType.CONTACT -> R.string.tooltip_attachment_contact
VOICE_BROADCAST(PERMISSIONS_FOR_VOICE_BROADCAST, R.string.tooltip_attachment_voice_broadcast), AttachmentType.POLL -> R.string.tooltip_attachment_poll
AttachmentType.LOCATION -> R.string.tooltip_attachment_location
AttachmentType.VOICE_BROADCAST -> R.string.tooltip_attachment_voice_broadcast
}
}
} }
} }

View File

@ -0,0 +1,76 @@
/*
* Copyright (c) 2022 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package im.vector.app.features.attachments
import com.airbnb.mvrx.MavericksState
import com.airbnb.mvrx.MavericksViewModelFactory
import dagger.assisted.Assisted
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.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,
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: 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
)
}
}
}
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
}

View File

@ -54,7 +54,7 @@ class AttachmentsHelper(
private var captureUri: Uri? = null private var captureUri: Uri? = null
// The pending type is set if we have to handle permission request. It must be restored if the activity gets killed. // The pending type is set if we have to handle permission request. It must be restored if the activity gets killed.
var pendingType: AttachmentTypeSelectorView.Type? = null var pendingType: AttachmentType? = null
// Restorable // Restorable

View File

@ -40,8 +40,10 @@ import androidx.core.text.buildSpannedString
import androidx.core.view.isGone import androidx.core.view.isGone
import androidx.core.view.isInvisible import androidx.core.view.isInvisible
import androidx.core.view.isVisible import androidx.core.view.isVisible
import androidx.fragment.app.viewModels
import androidx.lifecycle.Lifecycle import androidx.lifecycle.Lifecycle
import androidx.lifecycle.lifecycleScope import androidx.lifecycle.lifecycleScope
import com.airbnb.mvrx.fragmentViewModel
import com.airbnb.mvrx.parentFragmentViewModel import com.airbnb.mvrx.parentFragmentViewModel
import com.airbnb.mvrx.withState import com.airbnb.mvrx.withState
import com.google.android.material.dialog.MaterialAlertDialogBuilder import com.google.android.material.dialog.MaterialAlertDialogBuilder
@ -63,7 +65,12 @@ import im.vector.app.core.utils.onPermissionDeniedDialog
import im.vector.app.core.utils.registerForPermissionsResult import im.vector.app.core.utils.registerForPermissionsResult
import im.vector.app.databinding.FragmentComposerBinding import im.vector.app.databinding.FragmentComposerBinding
import im.vector.app.features.VectorFeatures import im.vector.app.features.VectorFeatures
import im.vector.app.features.attachments.AttachmentType
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.AttachmentTypeSelectorView
import im.vector.app.features.attachments.AttachmentTypeSelectorViewModel
import im.vector.app.features.attachments.AttachmentsHelper import im.vector.app.features.attachments.AttachmentsHelper
import im.vector.app.features.attachments.ContactAttachment import im.vector.app.features.attachments.ContactAttachment
import im.vector.app.features.attachments.ShareIntentHandler import im.vector.app.features.attachments.ShareIntentHandler
@ -91,8 +98,9 @@ import im.vector.app.features.poll.PollMode
import im.vector.app.features.settings.VectorPreferences import im.vector.app.features.settings.VectorPreferences
import im.vector.app.features.share.SharedData import im.vector.app.features.share.SharedData
import im.vector.app.features.voice.VoiceFailure import im.vector.app.features.voice.VoiceFailure
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.debounce import kotlinx.coroutines.flow.debounce
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.filterIsInstance
import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.onEach
@ -162,6 +170,8 @@ class MessageComposerFragment : VectorBaseFragment<FragmentComposerBinding>(), A
private val timelineViewModel: TimelineViewModel by parentFragmentViewModel() private val timelineViewModel: TimelineViewModel by parentFragmentViewModel()
private val messageComposerViewModel: MessageComposerViewModel by parentFragmentViewModel() private val messageComposerViewModel: MessageComposerViewModel by parentFragmentViewModel()
private lateinit var sharedActionViewModel: MessageSharedActionViewModel private lateinit var sharedActionViewModel: MessageSharedActionViewModel
private val attachmentViewModel: AttachmentTypeSelectorViewModel by fragmentViewModel()
private val attachmentActionsViewModel: AttachmentTypeSelectorSharedActionViewModel by viewModels()
private val composer: MessageComposerView get() { private val composer: MessageComposerView get() {
return if (vectorPreferences.isRichTextEditorEnabled()) { return if (vectorPreferences.isRichTextEditorEnabled()) {
@ -227,6 +237,11 @@ class MessageComposerFragment : VectorBaseFragment<FragmentComposerBinding>(), A
} }
} }
attachmentActionsViewModel.stream()
.filterIsInstance<AttachmentTypeSelectorSharedAction.SelectAttachmentTypeAction>()
.onEach { onTypeSelected(it.attachmentType) }
.launchIn(lifecycleScope)
if (savedInstanceState != null) { if (savedInstanceState != null) {
handleShareData() handleShareData()
} }
@ -260,11 +275,14 @@ class MessageComposerFragment : VectorBaseFragment<FragmentComposerBinding>(), A
messageComposerViewModel.endAllVoiceActions() 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 if (mainState.tombstoneEvent != null) return@withState
composer.setInvisible(!messageComposerState.isComposerVisible) composer.setInvisible(!messageComposerState.isComposerVisible)
composer.sendButton.isInvisible = !messageComposerState.isSendButtonVisible composer.sendButton.isInvisible = !messageComposerState.isSendButtonVisible
(composer as? RichTextComposerLayout)?.isTextFormattingEnabled = attachmentState.isTextFormattingEnabled
} }
private fun setupComposer() { private fun setupComposer() {
@ -307,21 +325,25 @@ class MessageComposerFragment : VectorBaseFragment<FragmentComposerBinding>(), A
} }
composer.callback = object : Callback { composer.callback = object : Callback {
override fun onAddAttachment() { override fun onAddAttachment() {
if (!::attachmentTypeSelector.isInitialized) { if (vectorPreferences.isRichTextEditorEnabled()) {
attachmentTypeSelector = AttachmentTypeSelectorView(vectorBaseActivity, vectorBaseActivity.layoutInflater, this@MessageComposerFragment) AttachmentTypeSelectorBottomSheet.show(childFragmentManager)
attachmentTypeSelector.setAttachmentVisibility( } else {
AttachmentTypeSelectorView.Type.LOCATION, if (!::attachmentTypeSelector.isInitialized) {
vectorFeatures.isLocationSharingEnabled(), attachmentTypeSelector = AttachmentTypeSelectorView(vectorBaseActivity, vectorBaseActivity.layoutInflater, this@MessageComposerFragment)
) attachmentTypeSelector.setAttachmentVisibility(
attachmentTypeSelector.setAttachmentVisibility( AttachmentType.LOCATION,
AttachmentTypeSelectorView.Type.POLL, !isThreadTimeLine() vectorFeatures.isLocationSharingEnabled(),
) )
attachmentTypeSelector.setAttachmentVisibility( attachmentTypeSelector.setAttachmentVisibility(
AttachmentTypeSelectorView.Type.VOICE_BROADCAST, AttachmentType.POLL, !isThreadTimeLine()
vectorPreferences.isVoiceBroadcastEnabled(), // TODO check user permission )
) attachmentTypeSelector.setAttachmentVisibility(
AttachmentType.VOICE_BROADCAST,
vectorPreferences.isVoiceBroadcastEnabled(), // TODO check user permission
)
}
attachmentTypeSelector.show(composer.attachmentButton)
} }
attachmentTypeSelector.show(composer.attachmentButton)
} }
override fun onExpandOrCompactChange() { override fun onExpandOrCompactChange() {
@ -678,20 +700,20 @@ class MessageComposerFragment : VectorBaseFragment<FragmentComposerBinding>(), A
} }
} }
private fun launchAttachmentProcess(type: AttachmentTypeSelectorView.Type) { private fun launchAttachmentProcess(type: AttachmentType) {
when (type) { when (type) {
AttachmentTypeSelectorView.Type.CAMERA -> attachmentsHelper.openCamera( AttachmentType.CAMERA -> attachmentsHelper.openCamera(
activity = requireActivity(), activity = requireActivity(),
vectorPreferences = vectorPreferences, vectorPreferences = vectorPreferences,
cameraActivityResultLauncher = attachmentCameraActivityResultLauncher, cameraActivityResultLauncher = attachmentCameraActivityResultLauncher,
cameraVideoActivityResultLauncher = attachmentCameraVideoActivityResultLauncher cameraVideoActivityResultLauncher = attachmentCameraVideoActivityResultLauncher
) )
AttachmentTypeSelectorView.Type.FILE -> attachmentsHelper.selectFile(attachmentFileActivityResultLauncher) AttachmentType.FILE -> attachmentsHelper.selectFile(attachmentFileActivityResultLauncher)
AttachmentTypeSelectorView.Type.GALLERY -> attachmentsHelper.selectGallery(attachmentMediaActivityResultLauncher) AttachmentType.GALLERY -> attachmentsHelper.selectGallery(attachmentMediaActivityResultLauncher)
AttachmentTypeSelectorView.Type.CONTACT -> attachmentsHelper.selectContact(attachmentContactActivityResultLauncher) AttachmentType.CONTACT -> attachmentsHelper.selectContact(attachmentContactActivityResultLauncher)
AttachmentTypeSelectorView.Type.STICKER -> timelineViewModel.handle(RoomDetailAction.SelectStickerAttachment) AttachmentType.STICKER -> timelineViewModel.handle(RoomDetailAction.SelectStickerAttachment)
AttachmentTypeSelectorView.Type.POLL -> navigator.openCreatePoll(requireContext(), roomId, null, PollMode.CREATE) AttachmentType.POLL -> navigator.openCreatePoll(requireContext(), roomId, null, PollMode.CREATE)
AttachmentTypeSelectorView.Type.LOCATION -> { AttachmentType.LOCATION -> {
navigator navigator
.openLocationSharing( .openLocationSharing(
context = requireContext(), context = requireContext(),
@ -701,11 +723,11 @@ class MessageComposerFragment : VectorBaseFragment<FragmentComposerBinding>(), A
locationOwnerId = session.myUserId locationOwnerId = session.myUserId
) )
} }
AttachmentTypeSelectorView.Type.VOICE_BROADCAST -> timelineViewModel.handle(VoiceBroadcastAction.Recording.Start) AttachmentType.VOICE_BROADCAST -> timelineViewModel.handle(VoiceBroadcastAction.Recording.Start)
} }
} }
override fun onTypeSelected(type: AttachmentTypeSelectorView.Type) { override fun onTypeSelected(type: AttachmentType) {
if (checkPermissions(type.permissions, requireActivity(), typeSelectedActivityResultLauncher)) { if (checkPermissions(type.permissions, requireActivity(), typeSelectedActivityResultLauncher)) {
launchAttachmentProcess(type) launchAttachmentProcess(type)
} else { } else {

View File

@ -38,7 +38,7 @@ import im.vector.app.core.extensions.setTextIfDifferent
import im.vector.app.databinding.ComposerRichTextLayoutBinding import im.vector.app.databinding.ComposerRichTextLayoutBinding
import im.vector.app.databinding.ViewRichTextMenuButtonBinding import im.vector.app.databinding.ViewRichTextMenuButtonBinding
import io.element.android.wysiwyg.EditorEditText 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.ComposerAction
import uniffi.wysiwyg_composer.MenuState import uniffi.wysiwyg_composer.MenuState
@ -57,12 +57,24 @@ class RichTextComposerLayout @JvmOverloads constructor(
private var isFullScreen = false private var isFullScreen = false
var isTextFormattingEnabled = true
set(value) {
if (field == value) return
syncEditTexts()
field = value
updateEditTextVisibility()
}
override val text: Editable? override val text: Editable?
get() = views.composerEditText.text get() = editText.text
override val formattedText: String? override val formattedText: String?
get() = views.composerEditText.getHtmlOutput() get() = (editText as? EditorEditText)?.getHtmlOutput()
override val editText: EditText override val editText: EditText
get() = views.composerEditText get() = if (isTextFormattingEnabled) {
views.richTextComposerEditText
} else {
views.plainTextComposerEditText
}
override val emojiButton: ImageButton? override val emojiButton: ImageButton?
get() = null get() = null
override val sendButton: ImageButton override val sendButton: ImageButton
@ -91,21 +103,12 @@ class RichTextComposerLayout @JvmOverloads constructor(
collapse(false) collapse(false)
views.composerEditText.addTextChangedListener(object : TextWatcher { views.richTextComposerEditText.addTextChangedListener(
private var previousTextWasExpanded = false TextChangeListener({ callback?.onTextChanged(it) }, ::updateTextFieldBorder)
)
override fun beforeTextChanged(s: CharSequence?, start: Int, count: Int, after: Int) {} views.plainTextComposerEditText.addTextChangedListener(
override fun onTextChanged(s: CharSequence?, start: Int, before: Int, count: Int) {} TextChangeListener({ callback?.onTextChanged(it) }, ::updateTextFieldBorder)
override fun afterTextChanged(s: Editable) { )
callback?.onTextChanged(s)
val isExpanded = s.lines().count() > 1
if (previousTextWasExpanded != isExpanded) {
updateTextFieldBorder(isExpanded)
}
previousTextWasExpanded = isExpanded
}
})
views.composerRelatedMessageCloseButton.setOnClickListener { views.composerRelatedMessageCloseButton.setOnClickListener {
collapse() collapse()
@ -130,19 +133,23 @@ class RichTextComposerLayout @JvmOverloads constructor(
private fun setupRichTextMenu() { private fun setupRichTextMenu() {
addRichTextMenuItem(R.drawable.ic_composer_bold, R.string.rich_text_editor_format_bold, ComposerAction.Bold) { 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) { 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) { 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) { 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)
} }
}
views.composerEditText.menuStateChangedListener = EditorEditText.OnMenuStateChangedListener { state -> override fun onAttachedToWindow() {
super.onAttachedToWindow()
views.richTextComposerEditText.menuStateChangedListener = EditorEditText.OnMenuStateChangedListener { state ->
if (state is MenuState.Update) { if (state is MenuState.Update) {
updateMenuStateFor(ComposerAction.Bold, state) updateMenuStateFor(ComposerAction.Bold, state)
updateMenuStateFor(ComposerAction.Italic, state) updateMenuStateFor(ComposerAction.Italic, state)
@ -150,8 +157,26 @@ class RichTextComposerLayout @JvmOverloads constructor(
updateMenuStateFor(ComposerAction.StrikeThrough, 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) { private fun addRichTextMenuItem(@DrawableRes iconId: Int, @StringRes description: Int, action: ComposerAction, onClick: () -> Unit) {
val inflater = LayoutInflater.from(context) val inflater = LayoutInflater.from(context)
val button = ViewRichTextMenuButtonBinding.inflate(inflater, views.richTextMenu, true) val button = ViewRichTextMenuButtonBinding.inflate(inflater, views.richTextMenu, true)
@ -181,7 +206,7 @@ class RichTextComposerLayout @JvmOverloads constructor(
} }
override fun replaceFormattedContent(text: CharSequence) { override fun replaceFormattedContent(text: CharSequence) {
views.composerEditText.setHtml(text.toString()) views.richTextComposerEditText.setHtml(text.toString())
} }
override fun collapse(animate: Boolean, transitionComplete: (() -> Unit)?) { override fun collapse(animate: Boolean, transitionComplete: (() -> Unit)?) {
@ -191,6 +216,7 @@ class RichTextComposerLayout @JvmOverloads constructor(
} }
currentConstraintSetId = R.layout.composer_rich_text_layout_constraint_set_compact currentConstraintSetId = R.layout.composer_rich_text_layout_constraint_set_compact
applyNewConstraintSet(animate, transitionComplete) applyNewConstraintSet(animate, transitionComplete)
updateEditTextVisibility()
} }
override fun expand(animate: Boolean, transitionComplete: (() -> Unit)?) { override fun expand(animate: Boolean, transitionComplete: (() -> Unit)?) {
@ -200,10 +226,11 @@ class RichTextComposerLayout @JvmOverloads constructor(
} }
currentConstraintSetId = R.layout.composer_rich_text_layout_constraint_set_expanded currentConstraintSetId = R.layout.composer_rich_text_layout_constraint_set_expanded
applyNewConstraintSet(animate, transitionComplete) applyNewConstraintSet(animate, transitionComplete)
updateEditTextVisibility()
} }
override fun setTextIfDifferent(text: CharSequence?): Boolean { override fun setTextIfDifferent(text: CharSequence?): Boolean {
return views.composerEditText.setTextIfDifferent(text) return editText.setTextIfDifferent(text)
} }
override fun toggleFullScreen(newValue: Boolean) { override fun toggleFullScreen(newValue: Boolean) {
@ -214,6 +241,7 @@ class RichTextComposerLayout @JvmOverloads constructor(
} }
updateTextFieldBorder(newValue) updateTextFieldBorder(newValue)
updateEditTextVisibility()
} }
private fun applyNewConstraintSet(animate: Boolean, transitionComplete: (() -> Unit)?) { private fun applyNewConstraintSet(animate: Boolean, transitionComplete: (() -> Unit)?) {
@ -233,4 +261,23 @@ class RichTextComposerLayout @JvmOverloads constructor(
override fun setInvisible(isInvisible: Boolean) { override fun setInvisible(isInvisible: Boolean) {
this.isInvisible = isInvisible 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
}
}
} }

View File

@ -109,6 +109,7 @@ class VectorPreferences @Inject constructor(
const val SETTINGS_SHOW_URL_PREVIEW_KEY = "SETTINGS_SHOW_URL_PREVIEW_KEY" 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_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_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_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_12_24_TIMESTAMPS_KEY = "SETTINGS_12_24_TIMESTAMPS_KEY"
private const val SETTINGS_SHOW_READ_RECEIPTS_KEY = "SETTINGS_SHOW_READ_RECEIPTS_KEY" private const val SETTINGS_SHOW_READ_RECEIPTS_KEY = "SETTINGS_SHOW_READ_RECEIPTS_KEY"
@ -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. * Tells if a confirmation dialog should be displayed before staring a call.
*/ */

View File

@ -0,0 +1,13 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<group>
<clip-path
android:pathData="M0,0h24v24h-24z"/>
<path
android:pathData="M3,20.667C3,21.4 3.6,22 4.333,22H20.333C21.067,22 21.667,21.4 21.667,20.667C21.667,19.933 21.067,19.333 20.333,19.333H4.333C3.6,19.333 3,19.933 3,20.667ZM9,13.733H15.667L16.547,15.867C16.747,16.347 17.213,16.667 17.733,16.667C18.653,16.667 19.267,15.72 18.907,14.88L13.733,2.92C13.493,2.36 12.947,2 12.333,2C11.72,2 11.173,2.36 10.933,2.92L5.76,14.88C5.4,15.72 6.027,16.667 6.947,16.667C7.467,16.667 7.933,16.347 8.133,15.867L9,13.733ZM12.333,4.64L14.827,11.333H9.84L12.333,4.64Z"
android:fillColor="#0DBD8B"/>
</group>
</vector>

View File

@ -0,0 +1,18 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<group>
<clip-path
android:pathData="M0,0h24v24h-24z"/>
<path
android:pathData="M9,15.733H15.667L16.547,17.867C16.747,18.347 17.213,18.667 17.733,18.667C18.653,18.667 19.267,17.72 18.907,16.88L13.733,4.92C13.493,4.36 12.947,4 12.333,4C11.72,4 11.173,4.36 10.933,4.92L5.76,16.88C5.4,17.72 6.027,18.667 6.947,18.667C7.467,18.667 7.933,18.347 8.133,17.867L9,15.733ZM12.333,6.64L14.827,13.333H9.84L12.333,6.64Z"
android:fillColor="#0DBD8B"/>
<path
android:strokeWidth="1"
android:pathData="M2.5,11.667C2.5,12.676 3.324,13.5 4.333,13.5H20.333C21.343,13.5 22.167,12.676 22.167,11.667C22.167,10.657 21.343,9.833 20.333,9.833H4.333C3.324,9.833 2.5,10.657 2.5,11.667Z"
android:fillColor="#0DBD8B"
android:strokeColor="#ffffff"/>
</group>
</vector>

View File

@ -0,0 +1,106 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.core.widget.NestedScrollView xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="?colorSurface">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical">
<im.vector.app.core.ui.views.BottomSheetActionButton
android:id="@+id/gallery"
android:layout_width="match_parent"
android:layout_height="wrap_content"
app:actionTitle="@string/attachment_type_selector_gallery"
app:leftIcon="@drawable/ic_attachment_gallery"
app:tint="?colorPrimary"
app:titleTextColor="?vctr_content_primary" />
<im.vector.app.core.ui.views.BottomSheetActionButton
android:id="@+id/stickers"
android:layout_width="match_parent"
android:layout_height="wrap_content"
app:actionTitle="@string/attachment_type_selector_sticker"
app:leftIcon="@drawable/ic_attachment_sticker"
app:tint="?colorPrimary"
app:titleTextColor="?vctr_content_primary" />
<im.vector.app.core.ui.views.BottomSheetActionButton
android:id="@+id/file"
android:layout_width="match_parent"
android:layout_height="wrap_content"
app:actionTitle="@string/attachment_type_selector_file"
app:leftIcon="@drawable/ic_attachment_file"
app:tint="?colorPrimary"
app:titleTextColor="?vctr_content_primary" />
<im.vector.app.core.ui.views.BottomSheetActionButton
android:id="@+id/voiceBroadcast"
android:layout_width="match_parent"
android:layout_height="wrap_content"
app:actionTitle="@string/attachment_type_selector_voice_broadcast"
app:leftIcon="@drawable/ic_attachment_voice_broadcast"
app:tint="?colorPrimary"
app:titleTextColor="?vctr_content_primary" />
<im.vector.app.core.ui.views.BottomSheetActionButton
android:id="@+id/poll"
android:layout_width="match_parent"
android:layout_height="wrap_content"
app:actionTitle="@string/attachment_type_selector_poll"
app:leftIcon="@drawable/ic_attachment_poll"
app:tint="?colorPrimary"
app:titleTextColor="?vctr_content_primary" />
<im.vector.app.core.ui.views.BottomSheetActionButton
android:id="@+id/location"
android:layout_width="match_parent"
android:layout_height="wrap_content"
app:actionTitle="@string/attachment_type_selector_location"
app:leftIcon="@drawable/ic_attachment_location"
app:tint="?colorPrimary"
app:titleTextColor="?vctr_content_primary" />
<im.vector.app.core.ui.views.BottomSheetActionButton
android:id="@+id/camera"
android:layout_width="match_parent"
android:layout_height="wrap_content"
app:actionTitle="@string/attachment_type_selector_camera"
app:leftIcon="@drawable/ic_attachment_camera"
app:tint="?colorPrimary"
app:titleTextColor="?vctr_content_primary" />
<im.vector.app.core.ui.views.BottomSheetActionButton
android:id="@+id/contact"
android:layout_width="match_parent"
android:layout_height="wrap_content"
app:actionTitle="@string/attachment_type_selector_contact"
app:leftIcon="@drawable/ic_attachment_contact_white_24dp"
app:tint="?colorPrimary"
app:titleTextColor="?vctr_content_primary" />
<View
android:layout_width="match_parent"
android:layout_height="1dp"
android:background="?vctr_list_separator" />
<androidx.appcompat.widget.SwitchCompat
android:id="@+id/textFormatting"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:drawableStart="@drawable/ic_text_formatting"
android:drawablePadding="20dp"
android:padding="20dp"
android:paddingStart="28dp"
android:text="@string/attachment_type_selector_text_formatting"
android:textAppearance="@style/TextAppearance.Vector.Subtitle"
android:textColor="?vctr_content_primary"
app:drawableTint="?colorPrimary"
tools:ignore="RtlSymmetry" />
</LinearLayout>
</androidx.core.widget.NestedScrollView>

View File

@ -104,13 +104,26 @@
android:background="@drawable/bg_composer_rich_edit_text_single_line" /> android:background="@drawable/bg_composer_rich_edit_text_single_line" />
<io.element.android.wysiwyg.EditorEditText <io.element.android.wysiwyg.EditorEditText
android:id="@+id/composerEditText" android:id="@+id/richTextComposerEditText"
style="@style/Widget.Vector.EditText.RichTextComposer" style="@style/Widget.Vector.EditText.RichTextComposer"
android:layout_width="0dp" android:layout_width="0dp"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:gravity="top" android:gravity="top"
android:nextFocusLeft="@id/composerEditText" android:nextFocusLeft="@id/richTextComposerEditText"
android:nextFocusUp="@id/composerEditText" android:nextFocusUp="@id/richTextComposerEditText"
tools:hint="@string/room_message_placeholder"
tools:text="@tools:sample/lorem/random"
tools:ignore="MissingConstraints" />
<!-- Use a separate EditText for plain text editing while the rich text editor doesn't support this mode -->
<com.google.android.material.textfield.TextInputEditText
android:id="@+id/plainTextComposerEditText"
style="@style/Widget.Vector.EditText.RichTextComposer"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:gravity="top"
android:nextFocusLeft="@id/plainTextComposerEditText"
android:nextFocusUp="@id/plainTextComposerEditText"
tools:hint="@string/room_message_placeholder" tools:hint="@string/room_message_placeholder"
tools:text="@tools:sample/lorem/random" tools:text="@tools:sample/lorem/random"
tools:ignore="MissingConstraints" /> tools:ignore="MissingConstraints" />

View File

@ -136,13 +136,29 @@
app:layout_constraintEnd_toEndOf="parent" /> app:layout_constraintEnd_toEndOf="parent" />
<io.element.android.wysiwyg.EditorEditText <io.element.android.wysiwyg.EditorEditText
android:id="@+id/composerEditText" android:id="@+id/richTextComposerEditText"
style="@style/Widget.Vector.EditText.RichTextComposer" style="@style/Widget.Vector.EditText.RichTextComposer"
android:layout_width="0dp" android:layout_width="0dp"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:hint="@string/room_message_placeholder" android:hint="@string/room_message_placeholder"
android:nextFocusLeft="@id/composerEditText" android:nextFocusLeft="@id/richTextComposerEditText"
android:nextFocusUp="@id/composerEditText" android:nextFocusUp="@id/richTextComposerEditText"
android:layout_marginHorizontal="12dp"
android:layout_marginVertical="10dp"
app:layout_constraintBottom_toBottomOf="@id/composerEditTextOuterBorder"
app:layout_constraintEnd_toEndOf="@id/composerEditTextOuterBorder"
app:layout_constraintStart_toStartOf="@id/composerEditTextOuterBorder"
app:layout_constraintTop_toTopOf="@id/composerEditTextOuterBorder"
tools:text="@tools:sample/lorem/random" />
<com.google.android.material.textfield.TextInputEditText
android:id="@+id/plainTextComposerEditText"
style="@style/Widget.Vector.EditText.RichTextComposer"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:hint="@string/room_message_placeholder"
android:nextFocusLeft="@id/plainTextComposerEditText"
android:nextFocusUp="@id/plainTextComposerEditText"
android:layout_marginStart="12dp" android:layout_marginStart="12dp"
android:layout_marginVertical="10dp" android:layout_marginVertical="10dp"
app:layout_constraintBottom_toBottomOf="@id/composerEditTextOuterBorder" app:layout_constraintBottom_toBottomOf="@id/composerEditTextOuterBorder"

View File

@ -149,13 +149,29 @@
app:layout_constraintEnd_toEndOf="parent" /> app:layout_constraintEnd_toEndOf="parent" />
<io.element.android.wysiwyg.EditorEditText <io.element.android.wysiwyg.EditorEditText
android:id="@+id/composerEditText" android:id="@+id/richTextComposerEditText"
style="@style/Widget.Vector.EditText.RichTextComposer" style="@style/Widget.Vector.EditText.RichTextComposer"
android:layout_width="0dp" android:layout_width="0dp"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:hint="@string/room_message_placeholder" android:hint="@string/room_message_placeholder"
android:nextFocusLeft="@id/composerEditText" android:nextFocusLeft="@id/richTextComposerEditText"
android:nextFocusUp="@id/composerEditText" android:nextFocusUp="@id/richTextComposerEditText"
android:layout_marginStart="12dp"
android:layout_marginVertical="10dp"
app:layout_constraintBottom_toBottomOf="@id/composerEditTextOuterBorder"
app:layout_constraintEnd_toStartOf="@id/composerFullScreenButton"
app:layout_constraintStart_toStartOf="@id/composerEditTextOuterBorder"
app:layout_constraintTop_toTopOf="@id/composerEditTextOuterBorder"
tools:text="@tools:sample/lorem/random" />
<com.google.android.material.textfield.TextInputEditText
android:id="@+id/plainTextComposerEditText"
style="@style/Widget.Vector.EditText.RichTextComposer"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:hint="@string/room_message_placeholder"
android:nextFocusLeft="@id/plainTextComposerEditText"
android:nextFocusUp="@id/plainTextComposerEditText"
android:layout_marginStart="12dp" android:layout_marginStart="12dp"
android:layout_marginVertical="10dp" android:layout_marginVertical="10dp"
app:layout_constraintBottom_toBottomOf="@id/composerEditTextOuterBorder" app:layout_constraintBottom_toBottomOf="@id/composerEditTextOuterBorder"

View File

@ -136,13 +136,30 @@
app:layout_constraintEnd_toEndOf="parent" /> app:layout_constraintEnd_toEndOf="parent" />
<io.element.android.wysiwyg.EditorEditText <io.element.android.wysiwyg.EditorEditText
android:id="@+id/composerEditText" android:id="@+id/richTextComposerEditText"
style="@style/Widget.Vector.EditText.RichTextComposer" style="@style/Widget.Vector.EditText.RichTextComposer"
android:layout_width="0dp" android:layout_width="0dp"
android:layout_height="0dp" android:layout_height="0dp"
android:hint="@string/room_message_placeholder" android:hint="@string/room_message_placeholder"
android:nextFocusLeft="@id/composerEditText" android:nextFocusLeft="@id/richTextComposerEditText"
android:nextFocusUp="@id/composerEditText" android:nextFocusUp="@id/richTextComposerEditText"
android:layout_marginStart="12dp"
android:layout_marginVertical="10dp"
android:gravity="top"
app:layout_constraintBottom_toBottomOf="@id/composerEditTextOuterBorder"
app:layout_constraintEnd_toStartOf="@id/composerFullScreenButton"
app:layout_constraintStart_toStartOf="@id/composerEditTextOuterBorder"
app:layout_constraintTop_toTopOf="@id/composerEditTextOuterBorder"
tools:text="@tools:sample/lorem/random" />
<com.google.android.material.textfield.TextInputEditText
android:id="@+id/plainTextComposerEditText"
style="@style/Widget.Vector.EditText.RichTextComposer"
android:layout_width="0dp"
android:layout_height="0dp"
android:hint="@string/room_message_placeholder"
android:nextFocusLeft="@id/plainTextComposerEditText"
android:nextFocusUp="@id/plainTextComposerEditText"
android:layout_marginStart="12dp" android:layout_marginStart="12dp"
android:layout_marginVertical="10dp" android:layout_marginVertical="10dp"
android:gravity="top" android:gravity="top"

View File

@ -0,0 +1,142 @@
/*
* Copyright (c) 2022 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package im.vector.app.features.attachments
import com.airbnb.mvrx.test.MavericksTestRule
import im.vector.app.test.fakes.FakeVectorFeatures
import im.vector.app.test.fakes.FakeVectorPreferences
import im.vector.app.test.test
import io.mockk.verifyOrder
import org.junit.Before
import org.junit.Rule
import org.junit.Test
internal class AttachmentTypeSelectorViewModelTest {
@get:Rule
val mavericksTestRule = MavericksTestRule()
private val fakeVectorFeatures = FakeVectorFeatures()
private val fakeVectorPreferences = FakeVectorPreferences()
private val initialState = AttachmentTypeSelectorViewState()
@Before
fun setUp() {
// Disable all features by default
fakeVectorFeatures.givenLocationSharing(isEnabled = false)
fakeVectorFeatures.givenVoiceBroadcast(isEnabled = false)
fakeVectorPreferences.givenTextFormatting(isEnabled = false)
}
@Test
fun `given features are not enabled, then options are not visible`() {
createViewModel()
.test()
.assertStates(
listOf(
initialState,
)
)
.finish()
}
@Test
fun `given location sharing is enabled, then location sharing option is visible`() {
fakeVectorFeatures.givenLocationSharing(isEnabled = true)
createViewModel()
.test()
.assertStates(
listOf(
initialState.copy(
isLocationVisible = true
),
)
)
.finish()
}
@Test
fun `given voice broadcast is enabled, then voice broadcast option is visible`() {
fakeVectorFeatures.givenVoiceBroadcast(isEnabled = true)
createViewModel()
.test()
.assertStates(
listOf(
initialState.copy(
isVoiceBroadcastVisible = true
),
)
)
.finish()
}
@Test
fun `given text formatting is enabled, then text formatting option is checked`() {
fakeVectorPreferences.givenTextFormatting(isEnabled = true)
createViewModel()
.test()
.assertStates(
listOf(
initialState.copy(
isTextFormattingEnabled = true
),
)
)
.finish()
}
@Test
fun `when text formatting is changed, then it updates the UI`() {
createViewModel()
.apply {
handle(AttachmentTypeSelectorAction.ToggleTextFormatting(isEnabled = true))
}
.test()
.assertStates(
listOf(
initialState.copy(
isTextFormattingEnabled = true
),
)
)
.finish()
}
@Test
fun `when text formatting is changed, then it persists the change`() {
createViewModel()
.apply {
handle(AttachmentTypeSelectorAction.ToggleTextFormatting(isEnabled = true))
handle(AttachmentTypeSelectorAction.ToggleTextFormatting(isEnabled = false))
}
verifyOrder {
fakeVectorPreferences.instance.setTextFormattingEnabled(true)
fakeVectorPreferences.instance.setTextFormattingEnabled(false)
}
}
private fun createViewModel(): AttachmentTypeSelectorViewModel {
return AttachmentTypeSelectorViewModel(
initialState,
vectorFeatures = fakeVectorFeatures,
vectorPreferences = fakeVectorPreferences.instance,
)
}
}

View File

@ -42,4 +42,12 @@ class FakeVectorFeatures : VectorFeatures by spyk<DefaultVectorFeatures>() {
fun givenCombinedLoginDisabled() { fun givenCombinedLoginDisabled() {
every { isOnboardingCombinedLoginEnabled() } returns false every { isOnboardingCombinedLoginEnabled() } returns false
} }
fun givenLocationSharing(isEnabled: Boolean) {
every { isLocationSharingEnabled() } returns isEnabled
}
fun givenVoiceBroadcast(isEnabled: Boolean) {
every { isVoiceBroadcastEnabled() } returns isEnabled
}
} }

View File

@ -40,4 +40,7 @@ class FakeVectorPreferences {
fun givenIsClientInfoRecordingEnabled(isEnabled: Boolean) { fun givenIsClientInfoRecordingEnabled(isEnabled: Boolean) {
every { instance.isClientInfoRecordingEnabled() } returns isEnabled every { instance.isClientInfoRecordingEnabled() } returns isEnabled
} }
fun givenTextFormatting(isEnabled: Boolean) =
every { instance.isTextFormattingEnabled() } returns isEnabled
} }