[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 17c43c9188.

* 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 : [
'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",

View file

@ -3205,6 +3205,16 @@
<string name="tooltip_attachment_location">Share location</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>
<plurals name="message_reaction_show_more">
<item quantity="one">"%1$d more"</item>

View file

@ -22,6 +22,7 @@ import dagger.hilt.InstallIn
import dagger.multibindings.IntoMap
import im.vector.app.features.analytics.accountdata.AnalyticsAccountDataViewModel
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.call.VectorCallViewModel
import im.vector.app.features.call.conference.JitsiCallViewModel
@ -677,4 +678,9 @@ interface MavericksViewModelModule {
@IntoMap
@MavericksViewModelKey(VectorSettingsLabsViewModel::class)
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) {
val views: ViewBottomSheetActionButtonBinding
override fun setOnClickListener(l: OnClickListener?) {
views.bottomSheetActionClickableZone.setOnClickListener(l)
}
var title: String? = null
set(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.LinearLayout
import android.widget.PopupWindow
import androidx.annotation.StringRes
import androidx.appcompat.widget.TooltipCompat
import androidx.core.view.doOnNextLayout
import androidx.core.view.isVisible
import im.vector.app.R
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.features.attachments.AttachmentTypeSelectorView.Callback
import kotlin.math.max
@ -59,7 +53,7 @@ class AttachmentTypeSelectorView(
) : PopupWindow(context) {
interface Callback {
fun onTypeSelected(type: Type)
fun onTypeSelected(type: AttachmentType)
}
private val views: ViewAttachmentTypeSelectorBinding
@ -69,14 +63,14 @@ class AttachmentTypeSelectorView(
init {
contentView = inflater.inflate(R.layout.view_attachment_type_selector, null, false)
views = ViewAttachmentTypeSelectorBinding.bind(contentView)
views.attachmentGalleryButton.configure(Type.GALLERY)
views.attachmentCameraButton.configure(Type.CAMERA)
views.attachmentFileButton.configure(Type.FILE)
views.attachmentStickersButton.configure(Type.STICKER)
views.attachmentContactButton.configure(Type.CONTACT)
views.attachmentPollButton.configure(Type.POLL)
views.attachmentLocationButton.configure(Type.LOCATION)
views.attachmentVoiceBroadcast.configure(Type.VOICE_BROADCAST)
views.attachmentGalleryButton.configure(AttachmentType.GALLERY)
views.attachmentCameraButton.configure(AttachmentType.CAMERA)
views.attachmentFileButton.configure(AttachmentType.FILE)
views.attachmentStickersButton.configure(AttachmentType.STICKER)
views.attachmentContactButton.configure(AttachmentType.CONTACT)
views.attachmentPollButton.configure(AttachmentType.POLL)
views.attachmentLocationButton.configure(AttachmentType.LOCATION)
views.attachmentVoiceBroadcast.configure(AttachmentType.VOICE_BROADCAST)
width = LinearLayout.LayoutParams.MATCH_PARENT
height = LinearLayout.LayoutParams.WRAP_CONTENT
animationStyle = 0
@ -127,16 +121,16 @@ class AttachmentTypeSelectorView(
}
}
fun setAttachmentVisibility(type: Type, isVisible: Boolean) {
fun setAttachmentVisibility(type: AttachmentType, isVisible: Boolean) {
when (type) {
Type.CAMERA -> views.attachmentCameraButton
Type.GALLERY -> views.attachmentGalleryButton
Type.FILE -> views.attachmentFileButton
Type.STICKER -> views.attachmentStickersButton
Type.CONTACT -> views.attachmentContactButton
Type.POLL -> views.attachmentPollButton
Type.LOCATION -> views.attachmentLocationButton
Type.VOICE_BROADCAST -> views.attachmentVoiceBroadcast
AttachmentType.CAMERA -> views.attachmentCameraButton
AttachmentType.GALLERY -> views.attachmentGalleryButton
AttachmentType.FILE -> views.attachmentFileButton
AttachmentType.STICKER -> views.attachmentStickersButton
AttachmentType.CONTACT -> views.attachmentContactButton
AttachmentType.POLL -> views.attachmentPollButton
AttachmentType.LOCATION -> views.attachmentLocationButton
AttachmentType.VOICE_BROADCAST -> views.attachmentVoiceBroadcast
}.let {
it.isVisible = isVisible
}
@ -200,13 +194,13 @@ class AttachmentTypeSelectorView(
return Pair(x, y)
}
private fun ImageButton.configure(type: Type): ImageButton {
private fun ImageButton.configure(type: AttachmentType): ImageButton {
this.setOnClickListener(TypeClickListener(type))
TooltipCompat.setTooltipText(this, context.getString(type.tooltipRes))
TooltipCompat.setTooltipText(this, context.getString(attachmentTooltipLabels.getValue(type)))
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) {
dismiss()
@ -217,14 +211,18 @@ class AttachmentTypeSelectorView(
/**
* The all possible types to pick with their required permissions and tooltip resource.
*/
enum class Type(val permissions: List<String>, @StringRes val tooltipRes: Int) {
CAMERA(PERMISSIONS_FOR_TAKING_PHOTO, R.string.tooltip_attachment_photo),
GALLERY(PERMISSIONS_EMPTY, R.string.tooltip_attachment_gallery),
FILE(PERMISSIONS_EMPTY, R.string.tooltip_attachment_file),
STICKER(PERMISSIONS_EMPTY, R.string.tooltip_attachment_sticker),
CONTACT(PERMISSIONS_FOR_PICKING_CONTACT, R.string.tooltip_attachment_contact),
POLL(PERMISSIONS_EMPTY, R.string.tooltip_attachment_poll),
LOCATION(PERMISSIONS_FOR_FOREGROUND_LOCATION_SHARING, R.string.tooltip_attachment_location),
VOICE_BROADCAST(PERMISSIONS_FOR_VOICE_BROADCAST, R.string.tooltip_attachment_voice_broadcast),
private companion object {
private val attachmentTooltipLabels: Map<AttachmentType, Int> = AttachmentType.values().associateWith {
when (it) {
AttachmentType.CAMERA -> R.string.tooltip_attachment_photo
AttachmentType.GALLERY -> R.string.tooltip_attachment_gallery
AttachmentType.FILE -> R.string.tooltip_attachment_file
AttachmentType.STICKER -> R.string.tooltip_attachment_sticker
AttachmentType.CONTACT -> R.string.tooltip_attachment_contact
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
// 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

View file

@ -40,8 +40,10 @@ import androidx.core.text.buildSpannedString
import androidx.core.view.isGone
import androidx.core.view.isInvisible
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
@ -63,7 +65,12 @@ import im.vector.app.core.utils.onPermissionDeniedDialog
import im.vector.app.core.utils.registerForPermissionsResult
import im.vector.app.databinding.FragmentComposerBinding
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.AttachmentTypeSelectorViewModel
import im.vector.app.features.attachments.AttachmentsHelper
import im.vector.app.features.attachments.ContactAttachment
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.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
import kotlinx.coroutines.flow.onEach
@ -162,6 +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: AttachmentTypeSelectorViewModel by fragmentViewModel()
private val attachmentActionsViewModel: AttachmentTypeSelectorSharedActionViewModel by viewModels()
private val composer: MessageComposerView get() {
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) {
handleShareData()
}
@ -260,11 +275,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() {
@ -307,21 +325,25 @@ class MessageComposerFragment : VectorBaseFragment<FragmentComposerBinding>(), A
}
composer.callback = object : Callback {
override fun onAddAttachment() {
if (!::attachmentTypeSelector.isInitialized) {
attachmentTypeSelector = AttachmentTypeSelectorView(vectorBaseActivity, vectorBaseActivity.layoutInflater, this@MessageComposerFragment)
attachmentTypeSelector.setAttachmentVisibility(
AttachmentTypeSelectorView.Type.LOCATION,
vectorFeatures.isLocationSharingEnabled(),
)
attachmentTypeSelector.setAttachmentVisibility(
AttachmentTypeSelectorView.Type.POLL, !isThreadTimeLine()
)
attachmentTypeSelector.setAttachmentVisibility(
AttachmentTypeSelectorView.Type.VOICE_BROADCAST,
vectorPreferences.isVoiceBroadcastEnabled(), // TODO check user permission
)
if (vectorPreferences.isRichTextEditorEnabled()) {
AttachmentTypeSelectorBottomSheet.show(childFragmentManager)
} else {
if (!::attachmentTypeSelector.isInitialized) {
attachmentTypeSelector = AttachmentTypeSelectorView(vectorBaseActivity, vectorBaseActivity.layoutInflater, this@MessageComposerFragment)
attachmentTypeSelector.setAttachmentVisibility(
AttachmentType.LOCATION,
vectorFeatures.isLocationSharingEnabled(),
)
attachmentTypeSelector.setAttachmentVisibility(
AttachmentType.POLL, !isThreadTimeLine()
)
attachmentTypeSelector.setAttachmentVisibility(
AttachmentType.VOICE_BROADCAST,
vectorPreferences.isVoiceBroadcastEnabled(), // TODO check user permission
)
}
attachmentTypeSelector.show(composer.attachmentButton)
}
attachmentTypeSelector.show(composer.attachmentButton)
}
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) {
AttachmentTypeSelectorView.Type.CAMERA -> attachmentsHelper.openCamera(
AttachmentType.CAMERA -> attachmentsHelper.openCamera(
activity = requireActivity(),
vectorPreferences = vectorPreferences,
cameraActivityResultLauncher = attachmentCameraActivityResultLauncher,
cameraVideoActivityResultLauncher = attachmentCameraVideoActivityResultLauncher
)
AttachmentTypeSelectorView.Type.FILE -> attachmentsHelper.selectFile(attachmentFileActivityResultLauncher)
AttachmentTypeSelectorView.Type.GALLERY -> attachmentsHelper.selectGallery(attachmentMediaActivityResultLauncher)
AttachmentTypeSelectorView.Type.CONTACT -> attachmentsHelper.selectContact(attachmentContactActivityResultLauncher)
AttachmentTypeSelectorView.Type.STICKER -> timelineViewModel.handle(RoomDetailAction.SelectStickerAttachment)
AttachmentTypeSelectorView.Type.POLL -> navigator.openCreatePoll(requireContext(), roomId, null, PollMode.CREATE)
AttachmentTypeSelectorView.Type.LOCATION -> {
AttachmentType.FILE -> attachmentsHelper.selectFile(attachmentFileActivityResultLauncher)
AttachmentType.GALLERY -> attachmentsHelper.selectGallery(attachmentMediaActivityResultLauncher)
AttachmentType.CONTACT -> attachmentsHelper.selectContact(attachmentContactActivityResultLauncher)
AttachmentType.STICKER -> timelineViewModel.handle(RoomDetailAction.SelectStickerAttachment)
AttachmentType.POLL -> navigator.openCreatePoll(requireContext(), roomId, null, PollMode.CREATE)
AttachmentType.LOCATION -> {
navigator
.openLocationSharing(
context = requireContext(),
@ -701,11 +723,11 @@ class MessageComposerFragment : VectorBaseFragment<FragmentComposerBinding>(), A
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)) {
launchAttachmentProcess(type)
} 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.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
@ -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
@ -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()
@ -130,19 +133,23 @@ 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)
}
}
views.composerEditText.menuStateChangedListener = EditorEditText.OnMenuStateChangedListener { state ->
override fun onAttachedToWindow() {
super.onAttachedToWindow()
views.richTextComposerEditText.menuStateChangedListener = EditorEditText.OnMenuStateChangedListener { state ->
if (state is MenuState.Update) {
updateMenuStateFor(ComposerAction.Bold, state)
updateMenuStateFor(ComposerAction.Italic, state)
@ -150,8 +157,26 @@ class RichTextComposerLayout @JvmOverloads constructor(
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)
@ -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)?) {
@ -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)?) {
@ -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) {
@ -214,6 +241,7 @@ class RichTextComposerLayout @JvmOverloads constructor(
}
updateTextFieldBorder(newValue)
updateEditTextVisibility()
}
private fun applyNewConstraintSet(animate: Boolean, transitionComplete: (() -> Unit)?) {
@ -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
}
}
}

View file

@ -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"
@ -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.
*/

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" />
<io.element.android.wysiwyg.EditorEditText
android:id="@+id/composerEditText"
android:id="@+id/richTextComposerEditText"
style="@style/Widget.Vector.EditText.RichTextComposer"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:gravity="top"
android:nextFocusLeft="@id/composerEditText"
android:nextFocusUp="@id/composerEditText"
android:nextFocusLeft="@id/richTextComposerEditText"
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:text="@tools:sample/lorem/random"
tools:ignore="MissingConstraints" />

View file

@ -136,13 +136,29 @@
app:layout_constraintEnd_toEndOf="parent" />
<io.element.android.wysiwyg.EditorEditText
android:id="@+id/composerEditText"
android:id="@+id/richTextComposerEditText"
style="@style/Widget.Vector.EditText.RichTextComposer"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:hint="@string/room_message_placeholder"
android:nextFocusLeft="@id/composerEditText"
android:nextFocusUp="@id/composerEditText"
android:nextFocusLeft="@id/richTextComposerEditText"
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_marginVertical="10dp"
app:layout_constraintBottom_toBottomOf="@id/composerEditTextOuterBorder"

View file

@ -149,13 +149,29 @@
app:layout_constraintEnd_toEndOf="parent" />
<io.element.android.wysiwyg.EditorEditText
android:id="@+id/composerEditText"
android:id="@+id/richTextComposerEditText"
style="@style/Widget.Vector.EditText.RichTextComposer"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:hint="@string/room_message_placeholder"
android:nextFocusLeft="@id/composerEditText"
android:nextFocusUp="@id/composerEditText"
android:nextFocusLeft="@id/richTextComposerEditText"
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_marginVertical="10dp"
app:layout_constraintBottom_toBottomOf="@id/composerEditTextOuterBorder"

View file

@ -136,13 +136,30 @@
app:layout_constraintEnd_toEndOf="parent" />
<io.element.android.wysiwyg.EditorEditText
android:id="@+id/composerEditText"
android:id="@+id/richTextComposerEditText"
style="@style/Widget.Vector.EditText.RichTextComposer"
android:layout_width="0dp"
android:layout_height="0dp"
android:hint="@string/room_message_placeholder"
android:nextFocusLeft="@id/composerEditText"
android:nextFocusUp="@id/composerEditText"
android:nextFocusLeft="@id/richTextComposerEditText"
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_marginVertical="10dp"
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() {
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) {
every { instance.isClientInfoRecordingEnabled() } returns isEnabled
}
fun givenTextFormatting(isEnabled: Boolean) =
every { instance.isTextFormattingEnabled() } returns isEnabled
}