From f3a0a059ea947204c930b13b04442f32c8003b1c Mon Sep 17 00:00:00 2001 From: Alex Hart Date: Tue, 13 Jun 2023 11:37:18 -0300 Subject: [PATCH] Add search and arbitrary jump support to CFV2. --- .../ConversationSearchViewModel.java | 16 +- .../conversation/v2/ConversationAdapterV2.kt | 12 +- .../conversation/v2/ConversationFragment.kt | 162 ++++++++++++++++-- .../conversation/v2/ConversationRepository.kt | 7 + .../conversation/v2/ConversationViewModel.kt | 12 ++ .../res/layout/conversation_search_nav.xml | 1 - .../res/layout/v2_conversation_fragment.xml | 10 ++ .../org/signal/core/util/ToolbarExtensions.kt | 23 +++ 8 files changed, 221 insertions(+), 22 deletions(-) create mode 100644 core-util/src/main/java/org/signal/core/util/ToolbarExtensions.kt diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationSearchViewModel.java b/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationSearchViewModel.java index 6f1060fee2..6c50998bfb 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationSearchViewModel.java +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationSearchViewModel.java @@ -31,11 +31,11 @@ public class ConversationSearchViewModel extends ViewModel { searchRepository = new SearchRepository(noteToSelfTitle); } - LiveData getSearchResults() { + public @NonNull LiveData 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 results; private final int position; diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationAdapterV2.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationAdapterV2.kt index 69bde9aeef..d35aacd2c9 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationAdapterV2.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationAdapterV2.kt @@ -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?) { 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 170b21d143..8ab6e19637 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 @@ -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 { diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationRepository.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationRepository.kt index d78f6314ca..45368b2949 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationRepository.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationRepository.kt @@ -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 { + return Single.fromCallable { + SignalDatabase.messages.getMessagePositionInConversation(threadId, messageResult.receivedTimestampMs) + 1 + }.subscribeOn(Schedulers.io()) + } + fun getNextMentionPosition(threadId: Long): Single { return Single.fromCallable { val details = SignalDatabase.messages.getOldestUnreadMentionDetails(threadId) diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationViewModel.kt index 7a9be49d5f..d0486c6d20 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationViewModel.kt @@ -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 = PublishSubject.create() val identityRecords: Observable + private val _searchQuery = BehaviorSubject.createDefault("") + val searchQuery: Observable = _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 { + return repository.getMessageResultPosition(threadId, messageResult) + } + fun getNextMentionPosition(): Single { return repository.getNextMentionPosition(threadId) } diff --git a/app/src/main/res/layout/conversation_search_nav.xml b/app/src/main/res/layout/conversation_search_nav.xml index ee778ea0a6..2a4fcd1730 100644 --- a/app/src/main/res/layout/conversation_search_nav.xml +++ b/app/src/main/res/layout/conversation_search_nav.xml @@ -1,7 +1,6 @@ + +