Skip to content

Commit

Permalink
Add support for links in the rich text editor
Browse files Browse the repository at this point in the history
  • Loading branch information
jonnyandrew committed Dec 8, 2022
1 parent de18f37 commit 994dddf
Show file tree
Hide file tree
Showing 18 changed files with 824 additions and 10 deletions.
1 change: 1 addition & 0 deletions changelog.d/7746.feature
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
[Rich text editor] Add support for links
2 changes: 1 addition & 1 deletion dependencies.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
8 changes: 7 additions & 1 deletion library/ui-strings/src/main/res/values/strings.xml
Original file line number Diff line number Diff line change
Expand Up @@ -3470,13 +3470,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>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -691,4 +692,9 @@ interface MavericksViewModelModule {
fun vectorSettingsNotificationPreferenceViewModelFactory(
factory: VectorSettingsNotificationPreferenceViewModel.Factory
): MavericksAssistedViewModelFactory<*, *>

@Binds
@IntoMap
@MavericksViewModelKey(SetLinkViewModel::class)
fun setLinkViewModelFactory(factory: SetLinkViewModel.Factory): MavericksAssistedViewModelFactory<*, *>
}
Original file line number Diff line number Diff line change
@@ -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)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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()) {
Expand Down Expand Up @@ -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 ->
Expand Down Expand Up @@ -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)
}
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -45,4 +45,5 @@ interface Callback : ComposerEditText.Callback {
fun onAddAttachment()
fun onExpandOrCompactChange()
fun onFullScreenModeChanged()
fun onSetLink(isTextSupported: Boolean, initialLink: String?)
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -224,8 +225,25 @@ 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.set_link_create, 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.setOnTouchListener { v, event ->
Expand Down
Original file line number Diff line number Diff line change
@@ -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()
}
Loading

0 comments on commit 994dddf

Please sign in to comment.