From 04a5e56da72eef3f8ae932b76812a90dc8a76d7e Mon Sep 17 00:00:00 2001 From: Cody Henthorne Date: Wed, 28 Jun 2023 11:43:26 -0400 Subject: [PATCH] Add mentions support to CFv2. --- .../ui/inlinequery/InlineQueryReplacement.kt | 19 +++ .../InlineQueryResultsControllerV2.kt | 134 ++++++++++++++++++ .../ui/inlinequery/InlineQueryViewModelV2.kt | 111 +++++++++++++++ .../ui/mentions/MentionsPickerFragmentV2.kt | 126 ++++++++++++++++ .../ui/mentions/MentionsPickerRepositoryV2.kt | 25 ++++ .../conversation/v2/ConversationFragment.kt | 19 ++- .../securesms/util/ViewModelFactory.kt | 10 ++ .../res/layout/v2_conversation_fragment.xml | 9 ++ 8 files changed, 446 insertions(+), 7 deletions(-) create mode 100644 app/src/main/java/org/thoughtcrime/securesms/conversation/ui/inlinequery/InlineQueryResultsControllerV2.kt create mode 100644 app/src/main/java/org/thoughtcrime/securesms/conversation/ui/inlinequery/InlineQueryViewModelV2.kt create mode 100644 app/src/main/java/org/thoughtcrime/securesms/conversation/ui/mentions/MentionsPickerFragmentV2.kt create mode 100644 app/src/main/java/org/thoughtcrime/securesms/conversation/ui/mentions/MentionsPickerRepositoryV2.kt diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/ui/inlinequery/InlineQueryReplacement.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/ui/inlinequery/InlineQueryReplacement.kt index 31691f3c7b..83f2d72c17 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/ui/inlinequery/InlineQueryReplacement.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/ui/inlinequery/InlineQueryReplacement.kt @@ -1,6 +1,11 @@ package org.thoughtcrime.securesms.conversation.ui.inlinequery import android.content.Context +import android.text.SpannableStringBuilder +import android.text.Spanned +import org.thoughtcrime.securesms.components.mention.MentionAnnotation +import org.thoughtcrime.securesms.database.MentionUtil +import org.thoughtcrime.securesms.recipients.Recipient /** * Encapsulate how to replace a query with a user selected result. @@ -13,4 +18,18 @@ sealed class InlineQueryReplacement(@get:JvmName("isKeywordSearch") val keywordS return emoji } } + + class Mention(private val recipient: Recipient, keywordSearch: Boolean) : InlineQueryReplacement(keywordSearch) { + override fun toCharSequence(context: Context): CharSequence { + val builder = SpannableStringBuilder().apply { + append(MentionUtil.MENTION_STARTER) + append(recipient.getDisplayName(context)) + append(" ") + } + + builder.setSpan(MentionAnnotation.mentionAnnotationForRecipientId(recipient.id), 0, builder.length - 1, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE) + + return builder + } + } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/ui/inlinequery/InlineQueryResultsControllerV2.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/ui/inlinequery/InlineQueryResultsControllerV2.kt new file mode 100644 index 0000000000..ba9b542f0c --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/ui/inlinequery/InlineQueryResultsControllerV2.kt @@ -0,0 +1,134 @@ +package org.thoughtcrime.securesms.conversation.ui.inlinequery + +import android.view.View +import android.view.ViewGroup +import androidx.fragment.app.Fragment +import androidx.fragment.app.commit +import androidx.lifecycle.DefaultLifecycleObserver +import androidx.lifecycle.LifecycleOwner +import io.reactivex.rxjava3.kotlin.subscribeBy +import org.signal.core.util.DimensionUnit +import org.signal.core.util.concurrent.LifecycleDisposable +import org.signal.core.util.concurrent.addTo +import org.thoughtcrime.securesms.R +import org.thoughtcrime.securesms.components.ComposeText +import org.thoughtcrime.securesms.conversation.ui.mentions.MentionsPickerFragmentV2 +import org.thoughtcrime.securesms.util.adapter.mapping.AnyMappingModel +import org.thoughtcrime.securesms.util.doOnEachLayout + +/** + * Controller for inline search results. + */ +class InlineQueryResultsControllerV2( + private val parentFragment: Fragment, + private val viewModel: InlineQueryViewModelV2, + private val anchor: View, + private val container: ViewGroup, + editText: ComposeText +) : InlineQueryResultsPopup.Callback { + + companion object { + private const val MENTION_TAG = "mention_fragment_tag" + } + + private val lifecycleDisposable: LifecycleDisposable = LifecycleDisposable() + private var emojiPopup: InlineQueryResultsPopup? = null + private var mentionFragment: MentionsPickerFragmentV2? = null + private var previousResults: InlineQueryViewModelV2.Results? = null + private var canShow: Boolean = false + private var isLandscape: Boolean = false + + init { + lifecycleDisposable.bindTo(parentFragment.viewLifecycleOwner) + + viewModel + .results + .subscribeBy { updateList(it) } + .addTo(lifecycleDisposable) + + parentFragment.viewLifecycleOwner.lifecycle.addObserver(object : DefaultLifecycleObserver { + override fun onDestroy(owner: LifecycleOwner) { + dismiss() + } + }) + + canShow = editText.hasFocus() + editText.addOnFocusChangeListener { _, hasFocus -> + canShow = hasFocus + updateList(previousResults ?: InlineQueryViewModelV2.None) + } + + anchor.doOnEachLayout { emojiPopup?.updateWithAnchor() } + } + + override fun onSelection(model: AnyMappingModel) { + viewModel.onSelection(model) + } + + override fun onDismiss() { + emojiPopup = null + } + + fun onOrientationChange(isLandscape: Boolean) { + this.isLandscape = isLandscape + + if (isLandscape) { + dismiss() + } else { + updateList(previousResults ?: InlineQueryViewModelV2.None) + } + } + + private fun updateList(results: InlineQueryViewModelV2.Results) { + previousResults = results + if (results is InlineQueryViewModelV2.None || !canShow || isLandscape) { + dismiss() + } else if (results is InlineQueryViewModelV2.EmojiResults) { + showEmojiPopup(results) + } else if (results is InlineQueryViewModelV2.MentionResults) { + showMentionsPickerFragment(results) + } + } + + private fun showEmojiPopup(results: InlineQueryViewModelV2.EmojiResults) { + if (emojiPopup != null) { + emojiPopup?.setResults(results.results) + } else { + emojiPopup = InlineQueryResultsPopup( + anchor = anchor, + container = container, + results = results.results, + baseOffsetX = DimensionUnit.DP.toPixels(16f).toInt(), + callback = this + ).show() + } + } + + private fun showMentionsPickerFragment(results: InlineQueryViewModelV2.MentionResults) { + if (mentionFragment == null) { + mentionFragment = parentFragment.childFragmentManager.findFragmentByTag(MENTION_TAG) as? MentionsPickerFragmentV2 + if (mentionFragment == null) { + mentionFragment = MentionsPickerFragmentV2() + parentFragment.childFragmentManager.commit { + replace(R.id.mention_fragment_container, mentionFragment!!) + runOnCommit { mentionFragment!!.updateList(results.results) } + } + } + } else { + parentFragment.childFragmentManager.commit { + show(mentionFragment!!) + } + } + } + + private fun dismiss() { + emojiPopup?.dismiss() + emojiPopup = null + + mentionFragment?.let { + parentFragment.childFragmentManager.commit { + hide(it) + } + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/ui/inlinequery/InlineQueryViewModelV2.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/ui/inlinequery/InlineQueryViewModelV2.kt new file mode 100644 index 0000000000..9024736b7a --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/ui/inlinequery/InlineQueryViewModelV2.kt @@ -0,0 +1,111 @@ +package org.thoughtcrime.securesms.conversation.ui.inlinequery + +import androidx.lifecycle.ViewModel +import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers +import io.reactivex.rxjava3.core.Observable +import io.reactivex.rxjava3.schedulers.Schedulers +import io.reactivex.rxjava3.subjects.BehaviorSubject +import io.reactivex.rxjava3.subjects.PublishSubject +import org.thoughtcrime.securesms.components.emoji.RecentEmojiPageModel +import org.thoughtcrime.securesms.conversation.ui.mentions.MentionViewState +import org.thoughtcrime.securesms.conversation.ui.mentions.MentionsPickerRepositoryV2 +import org.thoughtcrime.securesms.conversation.v2.ConversationRecipientRepository +import org.thoughtcrime.securesms.dependencies.ApplicationDependencies +import org.thoughtcrime.securesms.keyboard.emoji.search.EmojiSearchRepository +import org.thoughtcrime.securesms.keyvalue.SignalStore +import org.thoughtcrime.securesms.util.TextSecurePreferences +import org.thoughtcrime.securesms.util.adapter.mapping.AnyMappingModel + +/** + * Activity (at least) scope view model for managing inline queries. The view model needs to be larger scope so it can + * be shared between the fragment requesting the search and the fragment used for displaying the results. + */ +class InlineQueryViewModelV2( + private val recipientRepository: ConversationRecipientRepository, + private val mentionsPickerRepository: MentionsPickerRepositoryV2 = MentionsPickerRepositoryV2(), + private val emojiSearchRepository: EmojiSearchRepository = EmojiSearchRepository(ApplicationDependencies.getApplication()), + private val recentEmojis: RecentEmojiPageModel = RecentEmojiPageModel(ApplicationDependencies.getApplication(), TextSecurePreferences.RECENT_STORAGE_KEY) +) : ViewModel() { + + private val querySubject: PublishSubject = PublishSubject.create() + private val selectionSubject: PublishSubject = PublishSubject.create() + private val isMentionsShowingSubject: BehaviorSubject = BehaviorSubject.createDefault(false) + + val results: Observable + val selection: Observable = selectionSubject.observeOn(AndroidSchedulers.mainThread()) + val isMentionsShowing: Observable = isMentionsShowingSubject.observeOn(AndroidSchedulers.mainThread()) + + init { + results = querySubject.switchMap { query -> + when (query) { + is InlineQuery.Emoji -> queryEmoji(query) + is InlineQuery.Mention -> queryMentions(query) + InlineQuery.NoQuery -> Observable.just(None) + } + } + .subscribeOn(Schedulers.io()) + .observeOn(AndroidSchedulers.mainThread()) + } + + fun onQueryChange(inlineQuery: InlineQuery) { + querySubject.onNext(inlineQuery) + } + + private fun queryEmoji(query: InlineQuery.Emoji): Observable { + return emojiSearchRepository + .submitQuery(query.query) + .map { r -> if (r.isEmpty()) None else EmojiResults(toMappingModels(r, query.keywordSearch)) } + .toObservable() + } + + private fun queryMentions(query: InlineQuery.Mention): Observable { + return recipientRepository + .groupRecord + .take(1) + .switchMap { group -> + if (group.isPresent) { + mentionsPickerRepository.search(query.query, group.get().members) + .map { results -> if (results.isEmpty()) None else MentionResults(results.map { MentionViewState(it) }) } + .toObservable() + } else { + Observable.just(None) + } + } + } + + fun onSelection(model: AnyMappingModel) { + when (model) { + is InlineQueryEmojiResult.Model -> { + recentEmojis.onCodePointSelected(model.preferredEmoji) + selectionSubject.onNext(InlineQueryReplacement.Emoji(model.preferredEmoji, model.keywordSearch)) + } + is MentionViewState -> { + selectionSubject.onNext(InlineQueryReplacement.Mention(model.recipient, false)) + } + } + } + + fun setIsMentionsShowing(showing: Boolean) { + isMentionsShowingSubject.onNext(showing) + } + + companion object { + fun toMappingModels(emojiWithLabels: List, keywordSearch: Boolean): List { + val emojiValues = SignalStore.emojiValues() + return emojiWithLabels + .distinct() + .map { emoji -> + InlineQueryEmojiResult.Model( + canonicalEmoji = emoji, + preferredEmoji = emojiValues.getPreferredVariation(emoji), + keywordSearch = keywordSearch + ) + } + } + } + + sealed interface Results + object None : Results + data class EmojiResults(val results: List) : Results + data class MentionResults(val results: List) : Results +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/ui/mentions/MentionsPickerFragmentV2.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/ui/mentions/MentionsPickerFragmentV2.kt new file mode 100644 index 0000000000..e879c77803 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/ui/mentions/MentionsPickerFragmentV2.kt @@ -0,0 +1,126 @@ +package org.thoughtcrime.securesms.conversation.ui.mentions + +import android.os.Bundle +import android.os.Handler +import android.os.Looper +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.fragment.app.activityViewModels +import androidx.recyclerview.widget.LinearLayoutManager +import androidx.recyclerview.widget.RecyclerView +import com.google.android.material.bottomsheet.BottomSheetBehavior +import com.google.android.material.bottomsheet.BottomSheetBehavior.BottomSheetCallback +import io.reactivex.rxjava3.kotlin.subscribeBy +import org.signal.core.util.concurrent.LifecycleDisposable +import org.signal.core.util.concurrent.addTo +import org.thoughtcrime.securesms.LoggingFragment +import org.thoughtcrime.securesms.R +import org.thoughtcrime.securesms.conversation.ui.inlinequery.InlineQueryViewModelV2 +import org.thoughtcrime.securesms.recipients.Recipient +import org.thoughtcrime.securesms.util.VibrateUtil +import org.thoughtcrime.securesms.util.adapter.mapping.MappingModel +import org.thoughtcrime.securesms.util.viewholders.RecipientViewHolder + +/** + * Show inline query results for mentions in a group during message compose. + */ +class MentionsPickerFragmentV2 : LoggingFragment() { + + private val lifecycleDisposable: LifecycleDisposable = LifecycleDisposable() + private val viewModel: InlineQueryViewModelV2 by activityViewModels() + + private lateinit var adapter: MentionsPickerAdapter + private lateinit var list: RecyclerView + private lateinit var behavior: BottomSheetBehavior + + private val lockSheetAfterListUpdate = Runnable { behavior.setHideable(false) } + private val handler = Handler(Looper.getMainLooper()) + + override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? { + val view = inflater.inflate(R.layout.mentions_picker_fragment, container, false) + list = view.findViewById(R.id.mentions_picker_list) + behavior = BottomSheetBehavior.from(view.findViewById(R.id.mentions_picker_bottom_sheet)) + initializeBehavior() + return view + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + lifecycleDisposable.bindTo(viewLifecycleOwner) + + initializeList() + viewModel + .results + .subscribeBy { + if (it !is InlineQueryViewModelV2.MentionResults) { + updateList(emptyList()) + } else { + updateList(it.results) + } + } + .addTo( + lifecycleDisposable + ) + + viewModel + .isMentionsShowing + .subscribeBy { isShowing -> + if (isShowing && VibrateUtil.isHapticFeedbackEnabled(requireContext())) { + VibrateUtil.vibrateTick(requireContext()) + } + } + .addTo(lifecycleDisposable) + } + + private fun initializeBehavior() { + behavior.isHideable = true + behavior.state = BottomSheetBehavior.STATE_HIDDEN + behavior.addBottomSheetCallback(object : BottomSheetCallback() { + override fun onStateChanged(bottomSheet: View, newState: Int) { + if (newState == BottomSheetBehavior.STATE_HIDDEN) { + adapter.submitList(emptyList()) + } + } + + override fun onSlide(bottomSheet: View, slideOffset: Float) = Unit + }) + } + + private fun initializeList() { + adapter = MentionsPickerAdapter(MentionEventListener()) { updateBottomSheetBehavior(adapter.itemCount) } + + list.layoutManager = LinearLayoutManager(requireContext()) + list.adapter = adapter + list.itemAnimator = null + } + + fun updateList(mappingModels: List>) { + if (adapter.itemCount > 0 && mappingModels.isEmpty()) { + updateBottomSheetBehavior(0) + } else { + adapter.submitList(mappingModels) + } + } + + private fun updateBottomSheetBehavior(count: Int) { + val isShowing = count > 0 + if (isShowing) { + list.scrollToPosition(0) + behavior.state = BottomSheetBehavior.STATE_COLLAPSED + handler.post(lockSheetAfterListUpdate) + } else { + handler.removeCallbacks(lockSheetAfterListUpdate) + behavior.isHideable = true + behavior.state = BottomSheetBehavior.STATE_HIDDEN + } + viewModel.setIsMentionsShowing(isShowing) + } + + private inner class MentionEventListener : RecipientViewHolder.EventListener { + override fun onModelClick(model: MentionViewState) { + viewModel.onSelection(model) + } + + override fun onClick(recipient: Recipient) = Unit + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/ui/mentions/MentionsPickerRepositoryV2.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/ui/mentions/MentionsPickerRepositoryV2.kt new file mode 100644 index 0000000000..03fb210734 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/ui/mentions/MentionsPickerRepositoryV2.kt @@ -0,0 +1,25 @@ +package org.thoughtcrime.securesms.conversation.ui.mentions + +import io.reactivex.rxjava3.core.Single +import io.reactivex.rxjava3.schedulers.Schedulers +import org.thoughtcrime.securesms.database.RecipientTable +import org.thoughtcrime.securesms.database.SignalDatabase +import org.thoughtcrime.securesms.recipients.Recipient +import org.thoughtcrime.securesms.recipients.RecipientId + +/** + * Search for members that match the query for rendering in the mentions picker during message compose. + */ +class MentionsPickerRepositoryV2( + private val recipients: RecipientTable = SignalDatabase.recipients +) { + fun search(query: String, members: List): Single> { + return if (query.isBlank() || members.isEmpty()) { + Single.just(emptyList()) + } else { + Single + .fromCallable { recipients.queryRecipientsForMentions(query, members) } + .subscribeOn(Schedulers.io()) + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationFragment.kt index ffb92beb1e..56dbfd394e 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationFragment.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationFragment.kt @@ -165,8 +165,8 @@ import org.thoughtcrime.securesms.conversation.ui.error.EnableCallNotificationSe import org.thoughtcrime.securesms.conversation.ui.inlinequery.InlineQuery import org.thoughtcrime.securesms.conversation.ui.inlinequery.InlineQueryChangedListener import org.thoughtcrime.securesms.conversation.ui.inlinequery.InlineQueryReplacement -import org.thoughtcrime.securesms.conversation.ui.inlinequery.InlineQueryResultsController -import org.thoughtcrime.securesms.conversation.ui.inlinequery.InlineQueryViewModel +import org.thoughtcrime.securesms.conversation.ui.inlinequery.InlineQueryResultsControllerV2 +import org.thoughtcrime.securesms.conversation.ui.inlinequery.InlineQueryViewModelV2 import org.thoughtcrime.securesms.conversation.v2.groups.ConversationGroupCallViewModel import org.thoughtcrime.securesms.conversation.v2.groups.ConversationGroupViewModel import org.thoughtcrime.securesms.conversation.v2.keyboard.AttachmentKeyboardFragment @@ -277,6 +277,7 @@ import org.thoughtcrime.securesms.util.StorageUtil import org.thoughtcrime.securesms.util.TextSecurePreferences import org.thoughtcrime.securesms.util.ViewUtil import org.thoughtcrime.securesms.util.WindowUtil +import org.thoughtcrime.securesms.util.activityViewModel import org.thoughtcrime.securesms.util.concurrent.ListenableFuture import org.thoughtcrime.securesms.util.doAfterNextLayout import org.thoughtcrime.securesms.util.fragments.requireListener @@ -377,14 +378,17 @@ class ConversationFragment : StickerSuggestionsViewModel() } - private val inlineQueryViewModel: InlineQueryViewModel by activityViewModels() - private val inlineQueryController: InlineQueryResultsController by lazy { - InlineQueryResultsController( + private val inlineQueryViewModel: InlineQueryViewModelV2 by activityViewModel { + InlineQueryViewModelV2(recipientRepository = conversationRecipientRepository) + } + + private val inlineQueryController: InlineQueryResultsControllerV2 by lazy { + InlineQueryResultsControllerV2( + this, inlineQueryViewModel, inputPanel, (requireView() as ViewGroup), - composeText, - viewLifecycleOwner + composeText ) } @@ -531,6 +535,7 @@ class ConversationFragment : override fun onConfigurationChanged(newConfig: Configuration) { super.onConfigurationChanged(newConfig) ToolbarDependentMarginListener(binding.toolbar) + inlineQueryController.onOrientationChange(newConfig.orientation == Configuration.ORIENTATION_LANDSCAPE) } override fun onDestroyView() { diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/ViewModelFactory.kt b/app/src/main/java/org/thoughtcrime/securesms/util/ViewModelFactory.kt index 07344cd7d5..19e5e65590 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/util/ViewModelFactory.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/util/ViewModelFactory.kt @@ -2,6 +2,7 @@ package org.thoughtcrime.securesms.util import androidx.annotation.MainThread import androidx.fragment.app.Fragment +import androidx.fragment.app.activityViewModels import androidx.fragment.app.viewModels import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModelProvider @@ -35,3 +36,12 @@ inline fun Fragment.viewModel( factoryProducer = ViewModelFactory.factoryProducer(create) ) } + +@MainThread +inline fun Fragment.activityViewModel( + noinline create: () -> VM +): Lazy { + return activityViewModels( + factoryProducer = ViewModelFactory.factoryProducer(create) + ) +} diff --git a/app/src/main/res/layout/v2_conversation_fragment.xml b/app/src/main/res/layout/v2_conversation_fragment.xml index a53853ac45..340d09ca62 100644 --- a/app/src/main/res/layout/v2_conversation_fragment.xml +++ b/app/src/main/res/layout/v2_conversation_fragment.xml @@ -156,6 +156,15 @@ app:layout_constraintEnd_toEndOf="@id/parent_end_guideline" tools:visibility="visible" /> + +