[Rich text editor] Add support for links in the rich text editor (#7746)

This commit is contained in:
jonnyandrew 2022-12-21 10:40:19 +00:00 committed by GitHub
parent 67e15a42c0
commit 50466792c6
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
18 changed files with 824 additions and 10 deletions

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

@ -0,0 +1 @@
[Rich text editor] Add support for links

View File

@ -98,7 +98,7 @@ ext.libs = [
],
element : [
'opusencoder' : "io.element.android:opusencoder:1.1.0",
'wysiwyg' : "io.element.android:wysiwyg:0.9.0"
'wysiwyg' : "io.element.android:wysiwyg:0.10.0"
],
squareup : [
'moshi' : "com.squareup.moshi:moshi:$moshi",

View File

@ -3479,13 +3479,19 @@
<string name="qr_code_login_confirm_security_code">Confirm</string>
<string name="qr_code_login_confirm_security_code_description">Please ensure that you know the origin of this code. By linking devices, you will provide someone with full access to your account.</string>
<!-- WYSIWYG Composer -->
<!-- Rich text editor -->
<string name="rich_text_editor_format_bold">Apply bold format</string>
<string name="rich_text_editor_format_italic">Apply italic format</string>
<string name="rich_text_editor_format_strikethrough">Apply strikethrough format</string>
<string name="rich_text_editor_format_underline">Apply underline format</string>
<string name="rich_text_editor_link">Set link</string>
<string name="rich_text_editor_full_screen_toggle">Toggle full screen mode</string>
<string name="set_link_text">Text</string>
<string name="set_link_link">Link</string>
<string name="set_link_create">Create a link</string>
<string name="set_link_edit">Edit link</string>
<!-- ReplyTo events -->
<string name="message_reply_to_prefix">In reply to</string>
<string name="message_reply_to_sender_sent_file">sent a file.</string>

View File

@ -46,6 +46,7 @@ import im.vector.app.features.home.UserColorAccountDataViewModel
import im.vector.app.features.home.room.breadcrumbs.BreadcrumbsViewModel
import im.vector.app.features.home.room.detail.TimelineViewModel
import im.vector.app.features.home.room.detail.composer.MessageComposerViewModel
import im.vector.app.features.home.room.detail.composer.link.SetLinkViewModel
import im.vector.app.features.home.room.detail.search.SearchViewModel
import im.vector.app.features.home.room.detail.timeline.action.MessageActionsViewModel
import im.vector.app.features.home.room.detail.timeline.edithistory.ViewEditHistoryViewModel
@ -691,4 +692,9 @@ interface MavericksViewModelModule {
fun vectorSettingsNotificationPreferenceViewModelFactory(
factory: VectorSettingsNotificationPreferenceViewModel.Factory
): MavericksAssistedViewModelFactory<*, *>
@Binds
@IntoMap
@MavericksViewModelKey(SetLinkViewModel::class)
fun setLinkViewModelFactory(factory: SetLinkViewModel.Factory): MavericksAssistedViewModelFactory<*, *>
}

View File

@ -0,0 +1,155 @@
/*
* Copyright 2019 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.core.platform
import android.content.Context
import android.os.Bundle
import android.os.Parcelable
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.annotation.CallSuper
import androidx.fragment.app.DialogFragment
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.lifecycleScope
import androidx.viewbinding.ViewBinding
import com.airbnb.mvrx.MavericksView
import dagger.hilt.android.EntryPointAccessors
import im.vector.app.R
import im.vector.app.core.di.ActivityEntryPoint
import im.vector.app.core.extensions.singletonEntryPoint
import im.vector.app.core.extensions.toMvRxBundle
import im.vector.app.features.analytics.AnalyticsTracker
import im.vector.app.features.analytics.plan.MobileScreen
import im.vector.app.features.themes.ThemeUtils
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import reactivecircus.flowbinding.android.view.clicks
import timber.log.Timber
/**
* Add Mavericks capabilities, handle DI and bindings.
*/
abstract class VectorBaseDialogFragment<VB : ViewBinding> : DialogFragment(), MavericksView {
/* ==========================================================================================
* Analytics
* ========================================================================================== */
protected var analyticsScreenName: MobileScreen.ScreenName? = null
protected lateinit var analyticsTracker: AnalyticsTracker
/* ==========================================================================================
* View
* ========================================================================================== */
private var _binding: VB? = null
// This property is only valid between onCreateView and onDestroyView.
protected val views: VB
get() = _binding!!
abstract fun getBinding(inflater: LayoutInflater, container: ViewGroup?): VB
/* ==========================================================================================
* View model
* ========================================================================================== */
private lateinit var viewModelFactory: ViewModelProvider.Factory
protected val activityViewModelProvider
get() = ViewModelProvider(requireActivity(), viewModelFactory)
protected val fragmentViewModelProvider
get() = ViewModelProvider(this, viewModelFactory)
val vectorBaseActivity: VectorBaseActivity<*> by lazy {
activity as VectorBaseActivity<*>
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setStyle(STYLE_NORMAL, ThemeUtils.getApplicationThemeRes(requireContext()))
}
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? {
_binding = getBinding(inflater, container)
return views.root
}
@CallSuper
override fun onDestroyView() {
_binding = null
super.onDestroyView()
}
@CallSuper
override fun onDestroy() {
super.onDestroy()
}
override fun onAttach(context: Context) {
val activityEntryPoint = EntryPointAccessors.fromActivity(vectorBaseActivity, ActivityEntryPoint::class.java)
viewModelFactory = activityEntryPoint.viewModelFactory()
val singletonEntryPoint = context.singletonEntryPoint()
analyticsTracker = singletonEntryPoint.analyticsTracker()
super.onAttach(context)
}
override fun onResume() {
super.onResume()
Timber.i("onResume BottomSheet ${javaClass.simpleName}")
analyticsScreenName?.let {
analyticsTracker.screen(MobileScreen(screenName = it))
}
}
override fun onStart() {
super.onStart()
// This ensures that invalidate() is called for static screens that don't
// subscribe to a ViewModel.
postInvalidate()
requireDialog().window?.setWindowAnimations(R.style.Animation_AppCompat_Dialog)
}
protected fun setArguments(args: Parcelable? = null) {
arguments = args.toMvRxBundle()
}
/* ==========================================================================================
* Views
* ========================================================================================== */
protected fun View.debouncedClicks(onClicked: () -> Unit) {
clicks()
.onEach { onClicked() }
.launchIn(viewLifecycleOwner.lifecycleScope)
}
/* ==========================================================================================
* ViewEvents
* ========================================================================================== */
protected fun <T : VectorViewEvents> VectorViewModel<*, *, T>.observeViewEvents(observer: (T) -> Unit) {
viewEvents
.stream()
.onEach {
observer(it)
}
.launchIn(viewLifecycleOwner.lifecycleScope)
}
}

View File

@ -80,6 +80,9 @@ import im.vector.app.features.home.room.detail.AutoCompleter
import im.vector.app.features.home.room.detail.RoomDetailAction
import im.vector.app.features.home.room.detail.RoomDetailAction.VoiceBroadcastAction
import im.vector.app.features.home.room.detail.TimelineViewModel
import im.vector.app.features.home.room.detail.composer.link.SetLinkFragment
import im.vector.app.features.home.room.detail.composer.link.SetLinkSharedAction
import im.vector.app.features.home.room.detail.composer.link.SetLinkSharedActionViewModel
import im.vector.app.features.home.room.detail.composer.voice.VoiceMessageRecorderView
import im.vector.app.features.home.room.detail.timeline.action.MessageSharedActionViewModel
import im.vector.app.features.home.room.detail.upgrade.MigrateRoomBottomSheet
@ -147,6 +150,7 @@ class MessageComposerFragment : VectorBaseFragment<FragmentComposerBinding>(), A
private lateinit var sharedActionViewModel: MessageSharedActionViewModel
private val attachmentViewModel: AttachmentTypeSelectorViewModel by fragmentViewModel()
private val attachmentActionsViewModel: AttachmentTypeSelectorSharedActionViewModel by viewModels()
private val setLinkActionsViewModel: SetLinkSharedActionViewModel by viewModels()
private val composer: MessageComposerView get() {
return if (vectorPreferences.isRichTextEditorEnabled()) {
@ -212,6 +216,14 @@ class MessageComposerFragment : VectorBaseFragment<FragmentComposerBinding>(), A
.onEach { onTypeSelected(it.attachmentType) }
.launchIn(lifecycleScope)
setLinkActionsViewModel.stream()
.onEach { when (it) {
is SetLinkSharedAction.Insert -> views.richTextComposerLayout.insertLink(it.link, it.text)
is SetLinkSharedAction.Set -> views.richTextComposerLayout.setLink(it.link)
SetLinkSharedAction.Remove -> views.richTextComposerLayout.removeLink()
} }
.launchIn(lifecycleScope)
messageComposerViewModel.stateFlow.map { it.isFullScreen }
.distinctUntilChanged()
.onEach { isFullScreen ->
@ -385,6 +397,10 @@ class MessageComposerFragment : VectorBaseFragment<FragmentComposerBinding>(), A
override fun onFullScreenModeChanged() = withState(messageComposerViewModel) { state ->
messageComposerViewModel.handle(MessageComposerAction.SetFullScreen(!state.isFullScreen))
}
override fun onSetLink(isTextSupported: Boolean, initialLink: String?) {
SetLinkFragment.show(isTextSupported, initialLink, childFragmentManager)
}
}
}

View File

@ -45,4 +45,5 @@ interface Callback : ComposerEditText.Callback {
fun onAddAttachment()
fun onExpandOrCompactChange()
fun onFullScreenModeChanged()
fun onSetLink(isTextSupported: Boolean, initialLink: String?)
}

View File

@ -49,6 +49,7 @@ import im.vector.app.databinding.ComposerRichTextLayoutBinding
import im.vector.app.databinding.ViewRichTextMenuButtonBinding
import io.element.android.wysiwyg.EditorEditText
import io.element.android.wysiwyg.inputhandlers.models.InlineFormat
import io.element.android.wysiwyg.inputhandlers.models.LinkAction
import io.element.android.wysiwyg.utils.RustErrorCollector
import uniffi.wysiwyg_composer.ActionState
import uniffi.wysiwyg_composer.ComposerAction
@ -231,7 +232,24 @@ internal class RichTextComposerLayout @JvmOverloads constructor(
addRichTextMenuItem(R.drawable.ic_composer_strikethrough, R.string.rich_text_editor_format_strikethrough, ComposerAction.STRIKE_THROUGH) {
views.richTextComposerEditText.toggleInlineFormat(InlineFormat.StrikeThrough)
}
addRichTextMenuItem(R.drawable.ic_composer_link, R.string.rich_text_editor_link, ComposerAction.LINK) {
views.richTextComposerEditText.getLinkAction()?.let {
when (it) {
LinkAction.InsertLink -> callback?.onSetLink(isTextSupported = true, initialLink = null)
is LinkAction.SetLink -> callback?.onSetLink(isTextSupported = false, initialLink = it.currentLink)
}
}
}
}
fun setLink(link: String?) =
views.richTextComposerEditText.setLink(link)
fun insertLink(link: String, text: String) =
views.richTextComposerEditText.insertLink(link, text)
fun removeLink() =
views.richTextComposerEditText.removeLink()
@SuppressLint("ClickableViewAccessibility")
private fun disallowParentInterceptTouchEvent(view: View) {

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.home.room.detail.composer.link
import im.vector.app.core.platform.VectorViewModelAction
sealed class SetLinkAction : VectorViewModelAction {
data class LinkChanged(
val newLink: String
) : SetLinkAction()
data class Save(
val link: String,
val text: String,
) : SetLinkAction()
}

View File

@ -0,0 +1,131 @@
/*
* Copyright (c) 2021 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.home.room.detail.composer.link
import android.os.Bundle
import android.os.Parcelable
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.core.view.isGone
import androidx.fragment.app.FragmentManager
import androidx.fragment.app.viewModels
import androidx.lifecycle.lifecycleScope
import com.airbnb.mvrx.args
import com.airbnb.mvrx.fragmentViewModel
import com.airbnb.mvrx.withState
import dagger.hilt.android.AndroidEntryPoint
import im.vector.app.R
import im.vector.app.core.platform.VectorBaseDialogFragment
import im.vector.app.databinding.FragmentSetLinkBinding
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import kotlinx.parcelize.Parcelize
import reactivecircus.flowbinding.android.widget.textChanges
@AndroidEntryPoint
class SetLinkFragment :
VectorBaseDialogFragment<FragmentSetLinkBinding>() {
@Parcelize
data class Args(
val isTextSupported: Boolean,
val initialLink: String?,
) : Parcelable
private val viewModel: SetLinkViewModel by fragmentViewModel()
private val sharedActionViewModel: SetLinkSharedActionViewModel by viewModels(
ownerProducer = { requireParentFragment() }
)
private val args: Args by args()
override fun getBinding(inflater: LayoutInflater, container: ViewGroup?): FragmentSetLinkBinding {
return FragmentSetLinkBinding.inflate(inflater, container, false)
}
companion object {
fun show(isTextSupported: Boolean, initialLink: String?, fragmentManager: FragmentManager) =
SetLinkFragment().apply {
setArguments(Args(isTextSupported, initialLink))
}.show(fragmentManager, "SetLinkBottomSheet")
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
views.link.setText(args.initialLink)
views.link.textChanges()
.onEach {
viewModel.handle(SetLinkAction.LinkChanged(it.toString()))
}
.launchIn(viewLifecycleOwner.lifecycleScope)
views.save.debouncedClicks {
viewModel.handle(
SetLinkAction.Save(
link = views.link.text.toString(),
text = views.text.text.toString(),
)
)
}
views.cancel.debouncedClicks(::onCancel)
views.remove.debouncedClicks(::onRemove)
viewModel.observeViewEvents {
when (it) {
is SetLinkViewEvents.SavedLinkAndText -> handleInsert(link = it.link, text = it.text)
is SetLinkViewEvents.SavedLink -> handleSet(link = it.link)
}
}
views.toolbar.setNavigationOnClickListener {
dismiss()
}
}
override fun invalidate() = withState(viewModel) { viewState ->
views.toolbar.title = getString(
if (viewState.initialLink != null) {
R.string.set_link_edit
} else {
R.string.set_link_create
}
)
views.remove.isGone = !viewState.removeVisible
views.save.isEnabled = viewState.saveEnabled
views.textLayout.isGone = !viewState.isTextSupported
}
private fun handleInsert(link: String, text: String) {
sharedActionViewModel.post(SetLinkSharedAction.Insert(text, link))
dismiss()
}
private fun handleSet(link: String) {
sharedActionViewModel.post(SetLinkSharedAction.Set(link))
dismiss()
}
private fun onRemove() {
sharedActionViewModel.post(SetLinkSharedAction.Remove)
dismiss()
}
private fun onCancel() = dismiss()
}

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.home.room.detail.composer.link
import im.vector.app.core.platform.VectorSharedAction
import im.vector.app.core.platform.VectorSharedActionViewModel
import javax.inject.Inject
class SetLinkSharedActionViewModel @Inject constructor() :
VectorSharedActionViewModel<SetLinkSharedAction>()
sealed interface SetLinkSharedAction : VectorSharedAction {
data class Set(
val link: String,
) : SetLinkSharedAction
data class Insert(
val text: String,
val link: String,
) : SetLinkSharedAction
object Remove : SetLinkSharedAction
}

View File

@ -0,0 +1,31 @@
/*
* Copyright (c) 2021 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.home.room.detail.composer.link
import im.vector.app.core.platform.VectorViewEvents
sealed class SetLinkViewEvents : VectorViewEvents {
data class SavedLink(
val link: String,
) : SetLinkViewEvents()
data class SavedLinkAndText(
val link: String,
val text: String,
) : SetLinkViewEvents()
}

View File

@ -0,0 +1,55 @@
/*
* Copyright (c) 2021 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.home.room.detail.composer.link
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.VectorViewModel
class SetLinkViewModel @AssistedInject constructor(
@Assisted private val initialState: SetLinkViewState,
) : VectorViewModel<SetLinkViewState, SetLinkAction, SetLinkViewEvents>(initialState) {
@AssistedFactory
interface Factory : MavericksAssistedViewModelFactory<SetLinkViewModel, SetLinkViewState> {
override fun create(initialState: SetLinkViewState): SetLinkViewModel
}
companion object : MavericksViewModelFactory<SetLinkViewModel, SetLinkViewState> by hiltMavericksViewModelFactory()
override fun handle(action: SetLinkAction) = when (action) {
is SetLinkAction.LinkChanged -> handleLinkChanged(action.newLink)
is SetLinkAction.Save -> handleSave(action.link, action.text)
}
private fun handleLinkChanged(newLink: String) = setState {
copy(saveEnabled = newLink != initialLink.orEmpty())
}
private fun handleSave(
link: String,
text: String
) = if (initialState.isTextSupported) {
_viewEvents.post(SetLinkViewEvents.SavedLinkAndText(link, text))
} else {
_viewEvents.post(SetLinkViewEvents.SavedLink(link))
}
}

View File

@ -0,0 +1,34 @@
/*
* Copyright (c) 2021 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.home.room.detail.composer.link
import com.airbnb.mvrx.MavericksState
data class SetLinkViewState(
val isTextSupported: Boolean,
val initialLink: String?,
val saveEnabled: Boolean,
) : MavericksState {
constructor(args: SetLinkFragment.Args) : this(
isTextSupported = args.isTextSupported,
initialLink = args.initialLink,
saveEnabled = false,
)
val removeVisible = initialLink != null
}

View File

@ -24,6 +24,7 @@ import android.graphics.drawable.Drawable
import android.util.TypedValue
import androidx.annotation.AttrRes
import androidx.annotation.ColorInt
import androidx.annotation.StyleRes
import androidx.core.content.ContextCompat
import androidx.core.content.edit
import androidx.core.graphics.drawable.DrawableCompat
@ -113,19 +114,16 @@ object ThemeUtils {
*/
fun setApplicationTheme(context: Context, aTheme: String) {
currentTheme.set(aTheme)
context.setTheme(
when (aTheme) {
SYSTEM_THEME_VALUE -> if (isSystemDarkTheme(context.resources)) R.style.Theme_Vector_Dark else R.style.Theme_Vector_Light
THEME_DARK_VALUE -> R.style.Theme_Vector_Dark
THEME_BLACK_VALUE -> R.style.Theme_Vector_Black
else -> R.style.Theme_Vector_Light
}
)
context.setTheme(themeToRes(context, aTheme))
// Clear the cache
mColorByAttr.clear()
}
@StyleRes
fun getApplicationThemeRes(context: Context) =
themeToRes(context, currentTheme.get())
/**
* Set the activity theme according to the selected one. Default is Light, so if this is the current
* theme, the theme is not changed.
@ -200,4 +198,13 @@ object ThemeUtils {
DrawableCompat.setTint(tinted, color)
return tinted
}
@StyleRes
private fun themeToRes(context: Context, theme: String): Int =
when (theme) {
SYSTEM_THEME_VALUE -> if (isSystemDarkTheme(context.resources)) R.style.Theme_Vector_Dark else R.style.Theme_Vector_Light
THEME_DARK_VALUE -> R.style.Theme_Vector_Dark
THEME_BLACK_VALUE -> R.style.Theme_Vector_Black
else -> R.style.Theme_Vector_Light
}
}

View File

@ -0,0 +1,12 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="44dp"
android:height="44dp"
android:viewportWidth="44"
android:viewportHeight="44">
<path
android:pathData="M22.566,16.151L23.101,15.616C24.577,14.14 26.956,14.126 28.415,15.585C29.874,17.044 29.86,19.423 28.383,20.899L25.844,23.438C24.368,24.915 21.989,24.929 20.53,23.47M21.434,27.849L20.899,28.383C19.423,29.86 17.044,29.874 15.585,28.415C14.126,26.956 14.14,24.577 15.616,23.101L18.156,20.562C19.632,19.086 22.011,19.071 23.47,20.53"
android:strokeWidth="1.5"
android:fillColor="#00000000"
android:strokeColor="#8D97A5"
android:strokeLineCap="round"/>
</vector>

View File

@ -0,0 +1,117 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout 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">
<com.google.android.material.appbar.AppBarLayout
android:id="@+id/appBarLayout"
android:layout_width="match_parent"
android:layout_height="wrap_content"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent">
<com.google.android.material.appbar.MaterialToolbar
android:id="@+id/toolbar"
android:layout_width="match_parent"
android:layout_height="?actionBarSize"
app:navigationIcon="@drawable/ic_x_18dp"
app:title="@string/set_link_create" />
</com.google.android.material.appbar.AppBarLayout>
<ScrollView
android:layout_width="match_parent"
android:layout_height="0dp"
app:layout_constraintBottom_toTopOf="@id/save"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintHeight_min="100dp"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/appBarLayout">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:padding="16dp">
<com.google.android.material.textfield.TextInputLayout
android:id="@+id/textLayout"
style="@style/Widget.Vector.TextInputLayout.Form"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginBottom="16dp"
android:hint="@string/set_link_text">
<com.google.android.material.textfield.TextInputEditText
android:id="@+id/text"
android:layout_width="match_parent"
android:layout_height="wrap_content" />
</com.google.android.material.textfield.TextInputLayout>
<com.google.android.material.textfield.TextInputLayout
android:id="@+id/linkLayout"
style="@style/Widget.Vector.TextInputLayout.Form"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginBottom="16dp"
android:hint="@string/set_link_link">
<com.google.android.material.textfield.TextInputEditText
android:id="@+id/link"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:inputType="textUri" />
</com.google.android.material.textfield.TextInputLayout>
</LinearLayout>
</ScrollView>
<Button
android:id="@+id/save"
style="@style/Widget.Vector.Button.CallToAction"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginHorizontal="16dp"
android:height="56dp"
android:text="@string/action_save"
android:textAllCaps="false"
app:iconGravity="textStart"
app:layout_constraintBottom_toTopOf="@id/remove"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent" />
<Button
android:id="@+id/remove"
style="@style/Widget.Vector.Button.Destructive"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginHorizontal="16dp"
android:height="56dp"
android:text="@string/action_remove"
android:textAllCaps="false"
android:visibility="gone"
app:iconGravity="textStart"
app:layout_constraintBottom_toTopOf="@id/cancel"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
tools:visibility="visible" />
<Button
android:id="@+id/cancel"
style="@style/Widget.Vector.Button.Outlined"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginHorizontal="16dp"
android:layout_marginBottom="8dp"
android:height="56dp"
android:text="@string/action_cancel"
android:textAllCaps="false"
app:iconGravity="textStart"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent" />
</androidx.constraintlayout.widget.ConstraintLayout>

View File

@ -0,0 +1,157 @@
/*
* 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.home.room.detail.composer.link
import com.airbnb.mvrx.test.MavericksTestRule
import im.vector.app.test.test
import im.vector.app.test.testDispatcher
import org.junit.Rule
import org.junit.Test
class SetLinkViewModelTest {
@get:Rule
val mavericksTestRule = MavericksTestRule(testDispatcher = testDispatcher)
companion object {
const val link = "https://matrix.org"
const val newLink = "https://matrix.org/new"
const val text = "Matrix"
}
private val fragmentArgs = SetLinkFragment.Args(
isTextSupported = true,
initialLink = link
)
private fun createViewModel(
args: SetLinkFragment.Args
) = SetLinkViewModel(
initialState = SetLinkViewState(args),
)
@Test
fun `given no initial link, then remove button is hidden`() {
val viewModel = createViewModel(
fragmentArgs
.copy(initialLink = null)
)
val viewModelTest = viewModel.test()
viewModelTest
.assertLatestState { !it.removeVisible }
.finish()
}
@Test
fun `given no initial link, when link changed, then remove button is still hidden`() {
val viewModel = createViewModel(
fragmentArgs.copy(initialLink = null)
)
val viewModelTest = viewModel.test()
viewModel.handle(SetLinkAction.LinkChanged(newLink))
viewModelTest
.assertLatestState { !it.removeVisible }
.finish()
}
@Test
fun `when link is unchanged, it disables the save button`() {
val viewModel = createViewModel(
fragmentArgs
.copy(initialLink = link)
)
val viewModelTest = viewModel.test()
viewModelTest
.assertLatestState { !it.saveEnabled }
.finish()
}
@Test
fun `when link is changed, it enables the save button`() {
val viewModel = createViewModel(
fragmentArgs.copy(initialLink = link)
)
val viewModelTest = viewModel.test()
viewModel.handle(SetLinkAction.LinkChanged(newLink))
viewModelTest
.assertLatestState { it.saveEnabled }
.finish()
}
@Test
fun `given no initial link, when link is changed to empty, it disables the save button`() {
val viewModel = createViewModel(
fragmentArgs.copy(initialLink = null)
)
val viewModelTest = viewModel.test()
viewModel.handle(SetLinkAction.LinkChanged(""))
viewModelTest
.assertLatestState {
!it.saveEnabled
}
.finish()
}
@Test
fun `given text is supported, when saved, it emits the right event`() {
val viewModel = createViewModel(
fragmentArgs.copy(isTextSupported = true)
)
val viewModelTest = viewModel.test()
viewModel.handle(
SetLinkAction.Save(link = newLink, text = text)
)
viewModelTest
.assertEvent {
it == SetLinkViewEvents.SavedLinkAndText(
link = newLink,
text = text,
)
}
.finish()
}
@Test
fun `given text is not supported, when saved, it emits the right event`() {
val viewModel = createViewModel(
fragmentArgs.copy(isTextSupported = false)
)
val viewModelTest = viewModel.test()
viewModel.handle(
SetLinkAction.Save(link = newLink, text = text)
)
viewModelTest
.assertEvent {
it == SetLinkViewEvents.SavedLink(link = newLink)
}
.finish()
}
}