From c776aae9d06052abe2eb0d799ac33cc77dc94e9f Mon Sep 17 00:00:00 2001 From: jonnyandrew <jonny.andrew@protonmail.com> Date: Wed, 26 Oct 2022 17:37:40 +0100 Subject: [PATCH] [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 --- changelog.d/7429.feature | 1 + changelog.d/7452.feature | 1 + dependencies.gradle | 2 +- .../src/main/res/values/strings.xml | 10 ++ .../app/core/di/MavericksViewModelModule.kt | 6 + .../core/ui/views/BottomSheetActionButton.kt | 4 + .../features/attachments/AttachmentType.kt | 37 +++++ .../AttachmentTypeSelectorBottomSheet.kt | 92 ++++++++++++ ...chmentTypeSelectorSharedActionViewModel.kt | 30 ++++ .../attachments/AttachmentTypeSelectorView.kt | 70 +++++---- .../AttachmentTypeSelectorViewModel.kt | 76 ++++++++++ .../features/attachments/AttachmentsHelper.kt | 2 +- .../composer/MessageComposerFragment.kt | 74 +++++---- .../detail/composer/RichTextComposerLayout.kt | 99 ++++++++---- .../features/settings/VectorPreferences.kt | 19 +++ .../main/res/drawable/ic_text_formatting.xml | 13 ++ .../drawable/ic_text_formatting_disabled.xml | 18 +++ .../bottom_sheet_attachment_type_selector.xml | 106 +++++++++++++ .../res/layout/composer_rich_text_layout.xml | 19 ++- ...ich_text_layout_constraint_set_compact.xml | 22 ++- ...ch_text_layout_constraint_set_expanded.xml | 22 ++- ..._text_layout_constraint_set_fullscreen.xml | 23 ++- .../AttachmentTypeSelectorViewModelTest.kt | 142 ++++++++++++++++++ .../app/test/fakes/FakeVectorFeatures.kt | 8 + .../app/test/fakes/FakeVectorPreferences.kt | 3 + 25 files changed, 797 insertions(+), 102 deletions(-) create mode 100644 changelog.d/7429.feature create mode 100644 changelog.d/7452.feature create mode 100644 vector/src/main/java/im/vector/app/features/attachments/AttachmentType.kt create mode 100644 vector/src/main/java/im/vector/app/features/attachments/AttachmentTypeSelectorBottomSheet.kt create mode 100644 vector/src/main/java/im/vector/app/features/attachments/AttachmentTypeSelectorSharedActionViewModel.kt create mode 100644 vector/src/main/java/im/vector/app/features/attachments/AttachmentTypeSelectorViewModel.kt create mode 100644 vector/src/main/res/drawable/ic_text_formatting.xml create mode 100644 vector/src/main/res/drawable/ic_text_formatting_disabled.xml create mode 100644 vector/src/main/res/layout/bottom_sheet_attachment_type_selector.xml create mode 100644 vector/src/test/java/im/vector/app/features/attachments/AttachmentTypeSelectorViewModelTest.kt diff --git a/changelog.d/7429.feature b/changelog.d/7429.feature new file mode 100644 index 0000000000..9857452eca --- /dev/null +++ b/changelog.d/7429.feature @@ -0,0 +1 @@ +Add new UI for selecting an attachment diff --git a/changelog.d/7452.feature b/changelog.d/7452.feature new file mode 100644 index 0000000000..a811f87c84 --- /dev/null +++ b/changelog.d/7452.feature @@ -0,0 +1 @@ +[Rich text editor] Add plain text mode diff --git a/dependencies.gradle b/dependencies.gradle index f081e0a874..db6e92552a 100644 --- a/dependencies.gradle +++ b/dependencies.gradle @@ -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", diff --git a/library/ui-strings/src/main/res/values/strings.xml b/library/ui-strings/src/main/res/values/strings.xml index 450dcab1f7..9edd7d836a 100644 --- a/library/ui-strings/src/main/res/values/strings.xml +++ b/library/ui-strings/src/main/res/values/strings.xml @@ -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> diff --git a/vector/src/main/java/im/vector/app/core/di/MavericksViewModelModule.kt b/vector/src/main/java/im/vector/app/core/di/MavericksViewModelModule.kt index 97590028d8..2242abb7aa 100644 --- a/vector/src/main/java/im/vector/app/core/di/MavericksViewModelModule.kt +++ b/vector/src/main/java/im/vector/app/core/di/MavericksViewModelModule.kt @@ -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<*, *> } diff --git a/vector/src/main/java/im/vector/app/core/ui/views/BottomSheetActionButton.kt b/vector/src/main/java/im/vector/app/core/ui/views/BottomSheetActionButton.kt index a3e8b3780c..ca3e6a360a 100644 --- a/vector/src/main/java/im/vector/app/core/ui/views/BottomSheetActionButton.kt +++ b/vector/src/main/java/im/vector/app/core/ui/views/BottomSheetActionButton.kt @@ -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 diff --git a/vector/src/main/java/im/vector/app/features/attachments/AttachmentType.kt b/vector/src/main/java/im/vector/app/features/attachments/AttachmentType.kt new file mode 100644 index 0000000000..f4b97b9f9c --- /dev/null +++ b/vector/src/main/java/im/vector/app/features/attachments/AttachmentType.kt @@ -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), +} diff --git a/vector/src/main/java/im/vector/app/features/attachments/AttachmentTypeSelectorBottomSheet.kt b/vector/src/main/java/im/vector/app/features/attachments/AttachmentTypeSelectorBottomSheet.kt new file mode 100644 index 0000000000..f8d5d768ef --- /dev/null +++ b/vector/src/main/java/im/vector/app/features/attachments/AttachmentTypeSelectorBottomSheet.kt @@ -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") + } + } +} diff --git a/vector/src/main/java/im/vector/app/features/attachments/AttachmentTypeSelectorSharedActionViewModel.kt b/vector/src/main/java/im/vector/app/features/attachments/AttachmentTypeSelectorSharedActionViewModel.kt new file mode 100644 index 0000000000..e02b10c54b --- /dev/null +++ b/vector/src/main/java/im/vector/app/features/attachments/AttachmentTypeSelectorSharedActionViewModel.kt @@ -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 +} diff --git a/vector/src/main/java/im/vector/app/features/attachments/AttachmentTypeSelectorView.kt b/vector/src/main/java/im/vector/app/features/attachments/AttachmentTypeSelectorView.kt index 8536b765d4..55805a0728 100644 --- a/vector/src/main/java/im/vector/app/features/attachments/AttachmentTypeSelectorView.kt +++ b/vector/src/main/java/im/vector/app/features/attachments/AttachmentTypeSelectorView.kt @@ -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 + } + } } } diff --git a/vector/src/main/java/im/vector/app/features/attachments/AttachmentTypeSelectorViewModel.kt b/vector/src/main/java/im/vector/app/features/attachments/AttachmentTypeSelectorViewModel.kt new file mode 100644 index 0000000000..cb74661eba --- /dev/null +++ b/vector/src/main/java/im/vector/app/features/attachments/AttachmentTypeSelectorViewModel.kt @@ -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 +} diff --git a/vector/src/main/java/im/vector/app/features/attachments/AttachmentsHelper.kt b/vector/src/main/java/im/vector/app/features/attachments/AttachmentsHelper.kt index 1a8e10d102..9692777e15 100644 --- a/vector/src/main/java/im/vector/app/features/attachments/AttachmentsHelper.kt +++ b/vector/src/main/java/im/vector/app/features/attachments/AttachmentsHelper.kt @@ -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 diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/composer/MessageComposerFragment.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/composer/MessageComposerFragment.kt index 463a8fe440..5666c28605 100644 --- a/vector/src/main/java/im/vector/app/features/home/room/detail/composer/MessageComposerFragment.kt +++ b/vector/src/main/java/im/vector/app/features/home/room/detail/composer/MessageComposerFragment.kt @@ -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 { diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/composer/RichTextComposerLayout.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/composer/RichTextComposerLayout.kt index cac8f8bed4..2c09f351bb 100644 --- a/vector/src/main/java/im/vector/app/features/home/room/detail/composer/RichTextComposerLayout.kt +++ b/vector/src/main/java/im/vector/app/features/home/room/detail/composer/RichTextComposerLayout.kt @@ -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 + } + } } diff --git a/vector/src/main/java/im/vector/app/features/settings/VectorPreferences.kt b/vector/src/main/java/im/vector/app/features/settings/VectorPreferences.kt index 2dc8b12160..9f40a7cede 100755 --- a/vector/src/main/java/im/vector/app/features/settings/VectorPreferences.kt +++ b/vector/src/main/java/im/vector/app/features/settings/VectorPreferences.kt @@ -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. */ diff --git a/vector/src/main/res/drawable/ic_text_formatting.xml b/vector/src/main/res/drawable/ic_text_formatting.xml new file mode 100644 index 0000000000..375c459692 --- /dev/null +++ b/vector/src/main/res/drawable/ic_text_formatting.xml @@ -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> diff --git a/vector/src/main/res/drawable/ic_text_formatting_disabled.xml b/vector/src/main/res/drawable/ic_text_formatting_disabled.xml new file mode 100644 index 0000000000..bb34211c7a --- /dev/null +++ b/vector/src/main/res/drawable/ic_text_formatting_disabled.xml @@ -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> diff --git a/vector/src/main/res/layout/bottom_sheet_attachment_type_selector.xml b/vector/src/main/res/layout/bottom_sheet_attachment_type_selector.xml new file mode 100644 index 0000000000..7a22ab57f8 --- /dev/null +++ b/vector/src/main/res/layout/bottom_sheet_attachment_type_selector.xml @@ -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> diff --git a/vector/src/main/res/layout/composer_rich_text_layout.xml b/vector/src/main/res/layout/composer_rich_text_layout.xml index 9f49b8f9d6..c5afe1eb44 100644 --- a/vector/src/main/res/layout/composer_rich_text_layout.xml +++ b/vector/src/main/res/layout/composer_rich_text_layout.xml @@ -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" /> diff --git a/vector/src/main/res/layout/composer_rich_text_layout_constraint_set_compact.xml b/vector/src/main/res/layout/composer_rich_text_layout_constraint_set_compact.xml index 7aaa9f6a07..1a3023a805 100644 --- a/vector/src/main/res/layout/composer_rich_text_layout_constraint_set_compact.xml +++ b/vector/src/main/res/layout/composer_rich_text_layout_constraint_set_compact.xml @@ -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" diff --git a/vector/src/main/res/layout/composer_rich_text_layout_constraint_set_expanded.xml b/vector/src/main/res/layout/composer_rich_text_layout_constraint_set_expanded.xml index 214b3158b5..b0380d2e13 100644 --- a/vector/src/main/res/layout/composer_rich_text_layout_constraint_set_expanded.xml +++ b/vector/src/main/res/layout/composer_rich_text_layout_constraint_set_expanded.xml @@ -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" diff --git a/vector/src/main/res/layout/composer_rich_text_layout_constraint_set_fullscreen.xml b/vector/src/main/res/layout/composer_rich_text_layout_constraint_set_fullscreen.xml index fd1efc7e44..3105063933 100644 --- a/vector/src/main/res/layout/composer_rich_text_layout_constraint_set_fullscreen.xml +++ b/vector/src/main/res/layout/composer_rich_text_layout_constraint_set_fullscreen.xml @@ -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" diff --git a/vector/src/test/java/im/vector/app/features/attachments/AttachmentTypeSelectorViewModelTest.kt b/vector/src/test/java/im/vector/app/features/attachments/AttachmentTypeSelectorViewModelTest.kt new file mode 100644 index 0000000000..e20d498a37 --- /dev/null +++ b/vector/src/test/java/im/vector/app/features/attachments/AttachmentTypeSelectorViewModelTest.kt @@ -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, + ) + } +} diff --git a/vector/src/test/java/im/vector/app/test/fakes/FakeVectorFeatures.kt b/vector/src/test/java/im/vector/app/test/fakes/FakeVectorFeatures.kt index 4e6b4fc3df..d989abc214 100644 --- a/vector/src/test/java/im/vector/app/test/fakes/FakeVectorFeatures.kt +++ b/vector/src/test/java/im/vector/app/test/fakes/FakeVectorFeatures.kt @@ -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 + } } diff --git a/vector/src/test/java/im/vector/app/test/fakes/FakeVectorPreferences.kt b/vector/src/test/java/im/vector/app/test/fakes/FakeVectorPreferences.kt index 8b0630c24f..cd4f70bf63 100644 --- a/vector/src/test/java/im/vector/app/test/fakes/FakeVectorPreferences.kt +++ b/vector/src/test/java/im/vector/app/test/fakes/FakeVectorPreferences.kt @@ -40,4 +40,7 @@ class FakeVectorPreferences { fun givenIsClientInfoRecordingEnabled(isEnabled: Boolean) { every { instance.isClientInfoRecordingEnabled() } returns isEnabled } + + fun givenTextFormatting(isEnabled: Boolean) = + every { instance.isTextFormattingEnabled() } returns isEnabled }