Add search and arbitrary jump support to CFV2.

This commit is contained in:
Alex Hart 2023-06-13 11:37:18 -03:00 committed by Cody Henthorne
parent 290b0fe46f
commit f3a0a059ea
8 changed files with 221 additions and 22 deletions

View file

@ -31,11 +31,11 @@ public class ConversationSearchViewModel extends ViewModel {
searchRepository = new SearchRepository(noteToSelfTitle);
}
LiveData<SearchResult> getSearchResults() {
public @NonNull LiveData<SearchResult> getSearchResults() {
return result;
}
void onQueryUpdated(@NonNull String query, long threadId, boolean forced) {
public void onQueryUpdated(@NonNull String query, long threadId, boolean forced) {
if (firstSearch && query.length() < 2) {
result.postValue(new SearchResult(Collections.emptyList(), 0));
return;
@ -48,13 +48,13 @@ public class ConversationSearchViewModel extends ViewModel {
updateQuery(query, threadId);
}
void onMissingResult() {
public void onMissingResult() {
if (activeQuery != null) {
updateQuery(activeQuery, activeThreadId);
}
}
void onMoveUp() {
public void onMoveUp() {
if (result.getValue() == null) {
return;
}
@ -67,7 +67,7 @@ public class ConversationSearchViewModel extends ViewModel {
result.setValue(new SearchResult(messages, position));
}
void onMoveDown() {
public void onMoveDown() {
if (result.getValue() == null) {
return;
}
@ -81,12 +81,12 @@ public class ConversationSearchViewModel extends ViewModel {
}
void onSearchOpened() {
public void onSearchOpened() {
searchOpen = true;
firstSearch = true;
}
void onSearchClosed() {
public void onSearchClosed() {
searchOpen = false;
debouncer.clear();
}
@ -108,7 +108,7 @@ public class ConversationSearchViewModel extends ViewModel {
});
}
static class SearchResult {
public static class SearchResult {
private final List<MessageResult> results;
private final int position;

View file

@ -124,6 +124,11 @@ class ConversationAdapterV2(
}
}
fun updateSearchQuery(searchQuery: String?) {
this.searchQuery = searchQuery
notifyItemRangeChanged(0, itemCount)
}
/** [messagePosition] is one-based index and adapter is zero-based. */
fun getAdapterPositionForMessagePosition(messagePosition: Int): Int {
return messagePosition - 1
@ -151,7 +156,12 @@ class ConversationAdapterV2(
return false
}
return isRangeAvailable(position - 10, position + 5)
if (!isRangeAvailable(position - 10, position + 5)) {
getItem(absolutePosition)
return false
}
return true
}
fun playInlineContent(conversationMessage: ConversationMessage?) {

View file

@ -40,6 +40,7 @@ import androidx.activity.result.ActivityResultLauncher
import androidx.annotation.StringRes
import androidx.appcompat.app.AppCompatActivity
import androidx.appcompat.view.ActionMode
import androidx.appcompat.widget.SearchView
import androidx.appcompat.widget.Toolbar
import androidx.core.app.ActivityCompat
import androidx.core.app.ActivityOptionsCompat
@ -79,6 +80,7 @@ import org.signal.core.util.concurrent.addTo
import org.signal.core.util.dp
import org.signal.core.util.logging.Log
import org.signal.core.util.orNull
import org.signal.core.util.setActionItemTint
import org.signal.libsignal.protocol.InvalidMessageException
import org.signal.ringrtc.CallLinkRootKey
import org.thoughtcrime.securesms.BlockUnblockDialog
@ -94,6 +96,7 @@ import org.thoughtcrime.securesms.badges.gifts.viewgift.received.ViewReceivedGif
import org.thoughtcrime.securesms.badges.gifts.viewgift.sent.ViewSentGiftBottomSheet
import org.thoughtcrime.securesms.components.AnimatingToggle
import org.thoughtcrime.securesms.components.ComposeText
import org.thoughtcrime.securesms.components.ConversationSearchBottomBar
import org.thoughtcrime.securesms.components.HidingLinearLayout
import org.thoughtcrime.securesms.components.InputAwareConstraintLayout
import org.thoughtcrime.securesms.components.InputPanel
@ -129,6 +132,7 @@ import org.thoughtcrime.securesms.conversation.ConversationReactionDelegate
import org.thoughtcrime.securesms.conversation.ConversationReactionOverlay
import org.thoughtcrime.securesms.conversation.ConversationReactionOverlay.OnActionSelectedListener
import org.thoughtcrime.securesms.conversation.ConversationReactionOverlay.OnHideListener
import org.thoughtcrime.securesms.conversation.ConversationSearchViewModel
import org.thoughtcrime.securesms.conversation.MarkReadHelper
import org.thoughtcrime.securesms.conversation.MenuState
import org.thoughtcrime.securesms.conversation.MessageSendType
@ -267,6 +271,7 @@ class ConversationFragment :
companion object {
private val TAG = Log.tag(ConversationFragment::class.java)
private const val ACTION_PINNED_SHORTCUT = "action_pinned_shortcut"
private const val SAVED_STATE_IS_SEARCH_REQUESTED = "is_search_requested"
}
private val args: ConversationIntents.Args by lazy {
@ -313,6 +318,10 @@ class ConversationFragment :
DraftViewModel(threadId = args.threadId, repository = DraftRepository(conversationArguments = args))
}
private val searchViewModel: ConversationSearchViewModel by viewModel {
ConversationSearchViewModel(getString(R.string.note_to_self))
}
private val conversationTooltips = ConversationTooltips(this)
private val colorizer = Colorizer()
private val textDraftSaveDebouncer = Debouncer(500)
@ -335,6 +344,8 @@ class ConversationFragment :
private var animationsAllowed = false
private var actionMode: ActionMode? = null
private var pinnedShortcutReceiver: BroadcastReceiver? = null
private var searchMenuItem: MenuItem? = null
private var isSearchRequested: Boolean = false
private val jumpAndPulseScrollStrategy = object : ScrollToPositionDelegate.ScrollStrategy {
override fun performScroll(recyclerView: RecyclerView, layoutManager: LinearLayoutManager, position: Int, smooth: Boolean) {
@ -365,6 +376,9 @@ class ConversationFragment :
private val bottomActionBar: SignalBottomActionBar
get() = binding.conversationBottomActionBar
private val searchNav: ConversationSearchBottomBar
get() = binding.conversationSearchBottomBar.root
private lateinit var reactionDelegate: ConversationReactionDelegate
override fun onCreate(savedInstanceState: Bundle?) {
@ -408,6 +422,18 @@ class ConversationFragment :
ToolbarDependentMarginListener(binding.toolbar)
}
override fun onViewStateRestored(savedInstanceState: Bundle?) {
super.onViewStateRestored(savedInstanceState)
isSearchRequested = savedInstanceState?.getBoolean(SAVED_STATE_IS_SEARCH_REQUESTED, false) ?: false
}
override fun onSaveInstanceState(outState: Bundle) {
super.onSaveInstanceState(outState)
outState.putBoolean(SAVED_STATE_IS_SEARCH_REQUESTED, isSearchRequested)
}
override fun onResume() {
super.onResume()
@ -630,6 +656,8 @@ class ConversationFragment :
}
}
.addTo(disposables)
initializeSearch()
}
private fun presentInputReadyState(inputReadyState: InputReadyState) {
@ -736,8 +764,9 @@ class ConversationFragment :
}
private fun invalidateOptionsMenu() {
// TODO [cfv2] -- Handle search... is there a better way to manage this state? Maybe an event system?
conversationOptionsMenuProvider.onCreateMenu(binding.toolbar.menu, requireActivity().menuInflater)
if (!isSearchRequested && activity != null) {
conversationOptionsMenuProvider.onCreateMenu(binding.toolbar.menu, requireActivity().menuInflater)
}
}
private fun presentActionBarMenu() {
@ -796,6 +825,18 @@ class ConversationFragment :
binding.conversationWallpaperDim.visible = false
}
val toolbarTint = ContextCompat.getColor(
requireContext(),
if (chatWallpaper != null) {
R.color.signal_colorNeutralInverse
} else {
R.color.signal_colorOnSurface
}
)
binding.toolbar.setTitleTextColor(toolbarTint)
binding.toolbar.setActionItemTint(toolbarTint)
val wallpaperEnabled = chatWallpaper != null
binding.conversationWallpaper.visible = wallpaperEnabled
binding.scrollToBottom.setWallpaperEnabled(wallpaperEnabled)
@ -953,6 +994,32 @@ class ConversationFragment :
return callback
}
private fun initializeSearch() {
searchViewModel.searchResults.observe(viewLifecycleOwner) { result ->
if (result == null) {
return@observe
}
if (result.results.isNotEmpty()) {
val messageResult = result.results[result.position]
disposables += viewModel
.moveToSearchResult(messageResult)
.observeOn(AndroidSchedulers.mainThread())
.subscribeBy {
moveToPosition(it)
}
}
searchNav.setData(result.position, result.results.size)
}
searchNav.setEventListener(SearchEventListener())
disposables += viewModel.searchQuery.subscribeBy {
adapter.updateSearchQuery(it)
}
}
private fun updateToggleButtonState() {
val buttonToggle: AnimatingToggle = binding.conversationInputPanel.buttonToggle
val quickAttachment: HidingLinearLayout = binding.conversationInputPanel.quickAttachmentToggle
@ -1290,12 +1357,9 @@ class ConversationFragment :
//region Message action handling
private fun handleReplyToMessage(conversationMessage: ConversationMessage) {
/*
TODO [cfv2]
if (isSearchRequested) {
searchViewItem.collapseActionView();
searchMenuItem?.collapseActionView()
}
*/
if (inputPanel.inEditMessageMode()) {
inputPanel.exitEditMessageMode()
@ -1321,12 +1385,9 @@ class ConversationFragment :
return
}
/*
TODO [cfv2]
if (isSearchRequested) {
searchViewItem.collapseActionView();
if (isSearchRequested) {
searchMenuItem?.collapseActionView()
}
*/
viewModel.resolveMessageToEdit(conversationMessage)
.subscribeBy { updatedMessage ->
@ -1982,6 +2043,7 @@ class ConversationFragment :
}
private inner class ConversationOptionsMenuCallback : ConversationOptionsMenu.Callback {
override fun getSnapshot(): ConversationOptionsMenu.Snapshot {
val recipient: Recipient? = viewModel.recipientSnapshot
return ConversationOptionsMenu.Snapshot(
@ -2000,7 +2062,73 @@ class ConversationFragment :
}
override fun onOptionsMenuCreated(menu: Menu) {
// TODO [cfv2]
searchMenuItem = menu.findItem(R.id.menu_search)
val searchView: SearchView = searchMenuItem!!.actionView as SearchView
val queryListener: SearchView.OnQueryTextListener = object : SearchView.OnQueryTextListener {
override fun onQueryTextSubmit(query: String): Boolean {
searchViewModel.onQueryUpdated(query, args.threadId, true)
searchNav.showLoading()
viewModel.setSearchQuery(query)
return true
}
override fun onQueryTextChange(newText: String): Boolean {
searchViewModel.onQueryUpdated(newText, args.threadId, false)
searchNav.showLoading()
viewModel.setSearchQuery(newText)
return true
}
}
searchMenuItem!!.setOnActionExpandListener(object : MenuItem.OnActionExpandListener {
override fun onMenuItemActionExpand(item: MenuItem): Boolean {
searchView.setOnQueryTextListener(queryListener)
isSearchRequested = true
searchViewModel.onSearchOpened()
searchNav.visible = true
searchNav.setData(0, 0)
inputPanel.setHideForSearch(true)
(0 until menu.size()).forEach {
if (menu.getItem(it) != searchMenuItem) {
menu.getItem(it).isVisible = false
}
}
return true
}
override fun onMenuItemActionCollapse(item: MenuItem): Boolean {
searchView.setOnQueryTextListener(null)
isSearchRequested = false
searchViewModel.onSearchClosed()
searchNav.visible = false
inputPanel.setHideForSearch(false)
viewModel.setSearchQuery(null)
invalidateOptionsMenu()
return true
}
})
searchView.maxWidth = Integer.MAX_VALUE
if (isSearchRequested) {
if (searchMenuItem!!.expandActionView()) {
searchViewModel.onSearchOpened()
}
}
val toolbarTextAndIconColor = ContextCompat.getColor(
requireContext(),
if (viewModel.wallpaperSnapshot != null) {
R.color.signal_colorNeutralInverse
} else {
R.color.signal_colorOnSurface
}
)
binding.toolbar.setActionItemTint(toolbarTextAndIconColor)
}
override fun handleVideo() {
@ -2704,6 +2832,16 @@ class ConversationFragment :
//endregion
private inner class SearchEventListener : ConversationSearchBottomBar.EventListener {
override fun onSearchMoveUpPressed() {
searchViewModel.onMoveUp()
}
override fun onSearchMoveDownPressed() {
searchViewModel.onMoveDown()
}
}
private inner class ToolbarDependentMarginListener(private val toolbar: Toolbar) : ViewTreeObserver.OnGlobalLayoutListener {
init {

View file

@ -83,6 +83,7 @@ import org.thoughtcrime.securesms.providers.BlobProvider
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.recipients.RecipientFormattingException
import org.thoughtcrime.securesms.recipients.RecipientId
import org.thoughtcrime.securesms.search.MessageResult
import org.thoughtcrime.securesms.sms.MessageSender
import org.thoughtcrime.securesms.util.BitmapUtil
import org.thoughtcrime.securesms.util.DrawableUtil
@ -250,6 +251,12 @@ class ConversationRepository(
}.subscribeOn(Schedulers.io())
}
fun getMessageResultPosition(threadId: Long, messageResult: MessageResult): Single<Int> {
return Single.fromCallable {
SignalDatabase.messages.getMessagePositionInConversation(threadId, messageResult.receivedTimestampMs) + 1
}.subscribeOn(Schedulers.io())
}
fun getNextMentionPosition(threadId: Long): Single<Int> {
return Single.fromCallable {
val details = SignalDatabase.messages.getOldestUnreadMentionDetails(threadId)

View file

@ -53,6 +53,7 @@ import org.thoughtcrime.securesms.mms.QuoteModel
import org.thoughtcrime.securesms.mms.SlideDeck
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.recipients.RecipientId
import org.thoughtcrime.securesms.search.MessageResult
import org.thoughtcrime.securesms.util.TextSecurePreferences
import org.thoughtcrime.securesms.util.hasGiftBadge
import org.thoughtcrime.securesms.util.rx.RxStore
@ -114,6 +115,9 @@ class ConversationViewModel(
private val refreshIdentityRecords: Subject<Unit> = PublishSubject.create()
val identityRecords: Observable<IdentityRecordsState>
private val _searchQuery = BehaviorSubject.createDefault("")
val searchQuery: Observable<String> = _searchQuery
init {
disposables += recipient
.subscribeBy {
@ -204,6 +208,10 @@ class ConversationViewModel(
.distinctUntilChanged()
}
fun setSearchQuery(query: String?) {
_searchQuery.onNext(query ?: "")
}
override fun onCleared() {
disposables.clear()
}
@ -218,6 +226,10 @@ class ConversationViewModel(
return repository.getQuotedMessagePosition(threadId, quote)
}
fun moveToSearchResult(messageResult: MessageResult): Single<Int> {
return repository.getMessageResultPosition(threadId, messageResult)
}
fun getNextMentionPosition(): Single<Int> {
return repository.getNextMentionPosition(threadId)
}

View file

@ -1,7 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<org.thoughtcrime.securesms.components.ConversationSearchBottomBar
xmlns:android="http://schemas.android.com/apk/res/android"
tools:viewBindingIgnore="true"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:id="@+id/conversation_search_nav"

View file

@ -190,6 +190,16 @@
app:layout_constraintEnd_toEndOf="@id/parent_end_guideline"
app:layout_constraintStart_toStartOf="@id/parent_start_guideline" />
<include
android:id="@+id/conversation_search_bottom_bar"
layout="@layout/conversation_search_nav"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:visibility="gone"
app:layout_constraintBottom_toTopOf="@id/keyboard_guideline"
app:layout_constraintEnd_toEndOf="@id/parent_end_guideline"
app:layout_constraintStart_toStartOf="@id/parent_start_guideline" />
<org.thoughtcrime.securesms.conversation.v2.DisabledInputView
android:id="@+id/conversation_disabled_input"
android:layout_width="0dp"

View file

@ -0,0 +1,23 @@
/*
* Copyright 2023 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.signal.core.util
import android.content.res.ColorStateList
import android.graphics.PorterDuff
import android.graphics.PorterDuffColorFilter
import androidx.annotation.ColorInt
import androidx.appcompat.widget.Toolbar
import androidx.core.view.MenuItemCompat
import androidx.core.view.forEach
fun Toolbar.setActionItemTint(@ColorInt tint: Int) {
menu.forEach {
MenuItemCompat.setIconTintList(it, ColorStateList.valueOf(tint))
}
navigationIcon?.colorFilter = PorterDuffColorFilter(tint, PorterDuff.Mode.SRC_ATOP)
overflowIcon?.colorFilter = PorterDuffColorFilter(tint, PorterDuff.Mode.SRC_ATOP)
}