Add mentions support to CFv2.

This commit is contained in:
Cody Henthorne 2023-06-28 11:43:26 -04:00 committed by Nicholas
parent 0e6a3dd408
commit 04a5e56da7
8 changed files with 446 additions and 7 deletions

View file

@ -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
}
}
}

View file

@ -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)
}
}
}
}

View file

@ -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<InlineQuery> = PublishSubject.create()
private val selectionSubject: PublishSubject<InlineQueryReplacement> = PublishSubject.create()
private val isMentionsShowingSubject: BehaviorSubject<Boolean> = BehaviorSubject.createDefault(false)
val results: Observable<Results>
val selection: Observable<InlineQueryReplacement> = selectionSubject.observeOn(AndroidSchedulers.mainThread())
val isMentionsShowing: Observable<Boolean> = 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<Results> {
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<Results> {
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<String>, keywordSearch: Boolean): List<AnyMappingModel> {
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<AnyMappingModel>) : Results
data class MentionResults(val results: List<AnyMappingModel>) : Results
}

View file

@ -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<View>
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<MappingModel<*>>) {
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<MentionViewState> {
override fun onModelClick(model: MentionViewState) {
viewModel.onSelection(model)
}
override fun onClick(recipient: Recipient) = Unit
}
}

View file

@ -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<RecipientId>): Single<List<Recipient>> {
return if (query.isBlank() || members.isEmpty()) {
Single.just(emptyList())
} else {
Single
.fromCallable { recipients.queryRecipientsForMentions(query, members) }
.subscribeOn(Schedulers.io())
}
}
}

View file

@ -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() {

View file

@ -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 <reified VM : ViewModel> Fragment.viewModel(
factoryProducer = ViewModelFactory.factoryProducer(create)
)
}
@MainThread
inline fun <reified VM : ViewModel> Fragment.activityViewModel(
noinline create: () -> VM
): Lazy<VM> {
return activityViewModels(
factoryProducer = ViewModelFactory.factoryProducer(create)
)
}

View file

@ -156,6 +156,15 @@
app:layout_constraintEnd_toEndOf="@id/parent_end_guideline"
tools:visibility="visible" />
<androidx.fragment.app.FragmentContainerView
android:id="@+id/mention_fragment_container"
android:layout_width="0dp"
android:layout_height="0dp"
app:layout_constraintStart_toStartOf="@id/parent_start_guideline"
app:layout_constraintTop_toBottomOf="@id/toolbar"
app:layout_constraintEnd_toEndOf="@id/parent_end_guideline"
app:layout_constraintBottom_toTopOf="@id/conversation_bottom_panel_barrier"/>
<androidx.constraintlayout.widget.Barrier
android:id="@+id/conversation_bottom_panel_barrier"
android:layout_width="wrap_content"