Add search and arbitrary jump support to CFV2.
This commit is contained in:
parent
290b0fe46f
commit
f3a0a059ea
8 changed files with 221 additions and 22 deletions
|
@ -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;
|
||||
|
|
|
@ -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?) {
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
|
|
|
@ -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"
|
||||
|
|
|
@ -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"
|
||||
|
|
|
@ -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)
|
||||
}
|
Loading…
Add table
Reference in a new issue