From dc153ff4e66e83c1827bf407a721388e8a938f24 Mon Sep 17 00:00:00 2001 From: Alex Hart Date: Wed, 19 Apr 2023 14:35:06 -0300 Subject: [PATCH] Add support for jumping to quoted messages in CFV2. --- .../components/ScrollToPositionDelegate.kt | 118 +++++++++++++++--- .../conversation/v2/ConversationFragment.kt | 94 ++++++++++++-- .../conversation/v2/ConversationRepository.kt | 7 ++ .../conversation/v2/ConversationViewModel.kt | 5 + 4 files changed, 193 insertions(+), 31 deletions(-) diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/ScrollToPositionDelegate.kt b/app/src/main/java/org/thoughtcrime/securesms/components/ScrollToPositionDelegate.kt index 8ab75294f7..7d9c60b79d 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/ScrollToPositionDelegate.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/ScrollToPositionDelegate.kt @@ -1,5 +1,6 @@ package org.thoughtcrime.securesms.components +import android.view.View import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.RecyclerView import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers @@ -11,6 +12,7 @@ import io.reactivex.rxjava3.kotlin.subscribeBy import io.reactivex.rxjava3.subjects.BehaviorSubject import org.signal.core.util.logging.Log import org.thoughtcrime.securesms.util.doAfterNextLayout +import kotlin.math.abs import kotlin.math.max /** @@ -29,8 +31,14 @@ class ScrollToPositionDelegate private constructor( companion object { private val TAG = Log.tag(ScrollToPositionDelegate::class.java) const val NO_POSITION = -1 - private val EMPTY = ScrollToPositionRequest(NO_POSITION, true) private const val SMOOTH_SCROLL_THRESHOLD = 25 + private const val SCROLL_ANIMATION_THRESHOLD = 50 + private val EMPTY = ScrollToPositionRequest( + position = NO_POSITION, + smooth = true, + awaitLayout = true, + scrollStrategy = DefaultScrollStrategy + ) } private val listCommitted = BehaviorSubject.create() @@ -49,8 +57,14 @@ class ScrollToPositionDelegate private constructor( .filter { it.position >= 0 && canJumpToPosition(it.position) } .map { it.copy(position = mapToTruePosition(it.position)) } .subscribeBy(onNext = { position -> - recyclerView.doAfterNextLayout { - handleScrollPositionRequest(position, recyclerView) + if (position.awaitLayout) { + recyclerView.doAfterNextLayout { + handleScrollPositionRequest(position, recyclerView) + } + } else { + recyclerView.post { + handleScrollPositionRequest(position, recyclerView) + } } if (!(recyclerView.isLayoutRequested || recyclerView.isInLayout)) { @@ -61,9 +75,19 @@ class ScrollToPositionDelegate private constructor( /** * Entry point for requesting a specific scroll position. + * + * @param position The desired position to jump to. -1 to clear the current request. + * @param smooth Whether a smooth scroll will be attempted. Only done if we are within a certain distance. + * @param awaitLayout Whether this scroll should await for the next layout to complete before being attempted. + * @param scrollStrategy See [ScrollStrategy] */ - fun requestScrollPosition(position: Int, smooth: Boolean = true) { - scrollPositionRequested.onNext(ScrollToPositionRequest(position, smooth)) + fun requestScrollPosition( + position: Int, + smooth: Boolean = true, + awaitLayout: Boolean = true, + scrollStrategy: ScrollStrategy = DefaultScrollStrategy + ) { + scrollPositionRequested.onNext(ScrollToPositionRequest(position, smooth, awaitLayout, scrollStrategy)) } /** @@ -98,24 +122,80 @@ class ScrollToPositionDelegate private constructor( return } - val position = max(0, request.position - 1) - val offset = when { - position == 0 -> 0 - layoutManager.reverseLayout -> recyclerView.height - else -> 0 - } + val position = max(0, request.position) - Log.d(TAG, "Scrolling to position $position with offset $offset.") - - if (request.smooth && position == 0 && layoutManager.findFirstVisibleItemPosition() < SMOOTH_SCROLL_THRESHOLD) { - recyclerView.smoothScrollToPosition(position) - } else { - layoutManager.scrollToPositionWithOffset(position, offset) - } + request.scrollStrategy.performScroll( + recyclerView, + layoutManager, + position, + request.smooth + ) } private data class ScrollToPositionRequest( val position: Int, - val smooth: Boolean + val smooth: Boolean, + val awaitLayout: Boolean, + val scrollStrategy: ScrollStrategy ) + + /** + * Jumps to the desired position, pinning it to the "top" of the recycler. + */ + object DefaultScrollStrategy : ScrollStrategy { + override fun performScroll( + recyclerView: RecyclerView, + layoutManager: LinearLayoutManager, + position: Int, + smooth: Boolean + ) { + val offset = when { + position == 0 -> 0 + layoutManager.reverseLayout -> recyclerView.height + else -> 0 + } + + Log.d(TAG, "Scrolling to $position") + + if (smooth && position == 0 && layoutManager.findFirstVisibleItemPosition() < SMOOTH_SCROLL_THRESHOLD) { + recyclerView.smoothScrollToPosition(position) + } else { + layoutManager.scrollToPositionWithOffset(position, offset) + } + } + } + + /** + * Jumps to the given position but tries to ensure that the contents are completely visible on screen. + */ + object JumpToPositionStrategy : ScrollStrategy { + override fun performScroll(recyclerView: RecyclerView, layoutManager: LinearLayoutManager, position: Int, smooth: Boolean) { + if (abs(layoutManager.findFirstVisibleItemPosition() - position) < SCROLL_ANIMATION_THRESHOLD) { + val child: View? = layoutManager.findViewByPosition(position) + if (child == null || !layoutManager.isViewPartiallyVisible(child, true, false)) { + layoutManager.scrollToPositionWithOffset(position, recyclerView.height / 4) + } + } else { + layoutManager.scrollToPositionWithOffset(position, recyclerView.height / 4) + } + } + } + + /** + * Performs the actual scrolling for a given request. + */ + interface ScrollStrategy { + /** + * @param recyclerView The recycler view which is to be scrolled + * @param layoutManager The typed layout manager attached to the recycler view + * @param position The position we should scroll to. + * @param smooth Whether or not a smooth scroll should be attempted + */ + fun performScroll( + recyclerView: RecyclerView, + layoutManager: LinearLayoutManager, + position: Int, + smooth: Boolean + ) + } } 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 a5c4653e59..14fd1254cd 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 @@ -9,6 +9,7 @@ import android.view.MenuItem import android.view.View import android.widget.Toast import androidx.activity.result.ActivityResultLauncher +import androidx.annotation.StringRes import androidx.core.app.ActivityCompat import androidx.core.app.ActivityOptionsCompat import androidx.core.content.ContextCompat @@ -17,6 +18,7 @@ import androidx.fragment.app.viewModels import androidx.lifecycle.DefaultLifecycleObserver import androidx.lifecycle.LifecycleOwner import androidx.lifecycle.Observer +import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.RecyclerView import com.google.android.material.dialog.MaterialAlertDialogBuilder import com.google.android.material.transition.platform.MaterialContainerTransformSharedElementCallback @@ -25,6 +27,7 @@ import io.reactivex.rxjava3.core.Observable import io.reactivex.rxjava3.core.Single import io.reactivex.rxjava3.kotlin.subscribeBy import org.greenrobot.eventbus.EventBus +import org.signal.core.util.ThreadUtil import org.signal.core.util.concurrent.LifecycleDisposable import org.signal.core.util.logging.Log import org.thoughtcrime.securesms.LoggingFragment @@ -57,6 +60,7 @@ import org.thoughtcrime.securesms.conversation.v2.groups.ConversationGroupViewMo import org.thoughtcrime.securesms.database.model.InMemoryMessageRecord import org.thoughtcrime.securesms.database.model.MessageRecord import org.thoughtcrime.securesms.database.model.MmsMessageRecord +import org.thoughtcrime.securesms.database.model.Quote import org.thoughtcrime.securesms.databinding.V2ConversationFragmentBinding import org.thoughtcrime.securesms.dependencies.ApplicationDependencies import org.thoughtcrime.securesms.giph.mp4.GiphyMp4ItemDecoration @@ -86,13 +90,14 @@ import org.thoughtcrime.securesms.recipients.ui.bottomsheet.RecipientBottomSheet import org.thoughtcrime.securesms.safety.SafetyNumberBottomSheet import org.thoughtcrime.securesms.stickers.StickerLocator import org.thoughtcrime.securesms.stickers.StickerPackPreviewActivity +import org.thoughtcrime.securesms.stories.StoryViewerArgs +import org.thoughtcrime.securesms.stories.viewer.StoryViewerActivity import org.thoughtcrime.securesms.util.BottomSheetUtil import org.thoughtcrime.securesms.util.CommunicationActions import org.thoughtcrime.securesms.util.ContextUtil import org.thoughtcrime.securesms.util.DrawableUtil import org.thoughtcrime.securesms.util.FullscreenHelper import org.thoughtcrime.securesms.util.WindowUtil -import org.thoughtcrime.securesms.util.doAfterNextLayout import org.thoughtcrime.securesms.util.fragments.requireListener import org.thoughtcrime.securesms.util.hasGiftBadge import org.thoughtcrime.securesms.util.visible @@ -139,6 +144,15 @@ class ConversationFragment : LoggingFragment(R.layout.v2_conversation_fragment) private lateinit var markReadHelper: MarkReadHelper private lateinit var giphyMp4ProjectionRecycler: GiphyMp4ProjectionRecycler private lateinit var addToContactsLauncher: ActivityResultLauncher + private lateinit var scrollToPositionDelegate: ScrollToPositionDelegate + private lateinit var adapter: ConversationAdapter + + private val jumpAndPulseScrollStrategy = object : ScrollToPositionDelegate.ScrollStrategy { + override fun performScroll(recyclerView: RecyclerView, layoutManager: LinearLayoutManager, position: Int, smooth: Boolean) { + ScrollToPositionDelegate.JumpToPositionStrategy.performScroll(recyclerView, layoutManager, position, smooth) + adapter.pulseAtPosition(position) + } + } override fun onViewCreated(view: View, savedInstanceState: Bundle?) { registerForResults() @@ -215,7 +229,7 @@ class ConversationFragment : LoggingFragment(R.layout.v2_conversation_fragment) Log.d(TAG, "onFirstRecipientLoad") val colorizer = Colorizer() - val adapter = ConversationAdapter( + adapter = ConversationAdapter( requireContext(), viewLifecycleOwner, GlideApp.with(this), @@ -225,7 +239,7 @@ class ConversationFragment : LoggingFragment(R.layout.v2_conversation_fragment) colorizer ) - val scrollToPositionDelegate = ScrollToPositionDelegate( + scrollToPositionDelegate = ScrollToPositionDelegate( binding.conversationItemRecycler, adapter::canJumpToPosition, adapter::getAdapterPositionForMessagePosition @@ -255,19 +269,20 @@ class ConversationFragment : LoggingFragment(R.layout.v2_conversation_fragment) binding.conversationItemRecycler.addItemDecoration(multiselectItemDecoration) viewLifecycleOwner.lifecycle.addObserver(multiselectItemDecoration) - disposables += viewModel.conversationThreadState.subscribeBy { - scrollToPositionDelegate.requestScrollPosition(it.meta.getStartPosition(), false) - } - disposables += viewModel .conversationThreadState + .doOnSuccess { + scrollToPositionDelegate.requestScrollPosition( + position = it.meta.getStartPosition(), + smooth = false, + awaitLayout = false + ) + } .flatMapObservable { it.items.data } .observeOn(AndroidSchedulers.mainThread()) .subscribeBy(onNext = { adapter.submitList(it) { - binding.conversationItemRecycler.doAfterNextLayout { - scrollToPositionDelegate.notifyListCommitted() - } + scrollToPositionDelegate.notifyListCommitted() } }) @@ -408,6 +423,27 @@ class ConversationFragment : LoggingFragment(R.layout.v2_conversation_fragment) return callback } + private fun toast(@StringRes toastTextId: Int, toastDuration: Int) { + ThreadUtil.runOnMain { + if (context != null) { + Toast.makeText(context, toastTextId, toastDuration).show() + } else { + Log.w(TAG, "Dropping toast without context.") + } + } + } + + /** + * Requests a jump to the desired position, and ensures that the position desired will be visible on the screen. + */ + private fun moveToPosition(position: Int) { + scrollToPositionDelegate.requestScrollPosition( + position = position, + smooth = true, + scrollStrategy = jumpAndPulseScrollStrategy + ) + } + private inner class DataObserver( private val scrollToPositionDelegate: ScrollToPositionDelegate ) : RecyclerView.AdapterDataObserver() { @@ -421,8 +457,42 @@ class ConversationFragment : LoggingFragment(R.layout.v2_conversation_fragment) } private inner class ConversationItemClickListener : ConversationAdapter.ItemClickListener { - override fun onQuoteClicked(messageRecord: MmsMessageRecord?) { - // TODO [alex] - ("Not yet implemented") + override fun onQuoteClicked(messageRecord: MmsMessageRecord) { + val quote: Quote? = messageRecord.quote + if (quote == null) { + Log.w(TAG, "onQuoteClicked: Received an event but there is no quote.") + return + } + + if (quote.isOriginalMissing) { + Log.i(TAG, "onQuoteClicked: Original message is missing.") + toast(R.string.ConversationFragment_quoted_message_not_found, Toast.LENGTH_SHORT) + return + } + + val parentStoryId = messageRecord.parentStoryId + if (parentStoryId != null) { + startActivity( + StoryViewerActivity.createIntent( + requireContext(), + StoryViewerArgs.Builder(quote.author, Recipient.resolved(quote.author).shouldHideStory()) + .withStoryId(parentStoryId.asMessageId().id) + .isFromQuote(true) + .build() + ) + ) + + return + } + + disposables += viewModel.getQuotedMessagePosition(quote) + .subscribeBy { + if (it >= 0) { + moveToPosition(it) + } else { + toast(R.string.ConversationFragment_quoted_message_no_longer_available, Toast.LENGTH_SHORT) + } + } } override fun onLinkPreviewClicked(linkPreview: LinkPreview) { 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 1d0297eafc..33c72b1633 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 @@ -13,6 +13,7 @@ import org.thoughtcrime.securesms.conversation.colors.GroupAuthorNameColorHelper import org.thoughtcrime.securesms.conversation.colors.NameColor import org.thoughtcrime.securesms.database.SignalDatabase import org.thoughtcrime.securesms.database.SignalDatabase.Companion.threads +import org.thoughtcrime.securesms.database.model.Quote import org.thoughtcrime.securesms.recipients.Recipient import org.thoughtcrime.securesms.recipients.RecipientId import kotlin.math.max @@ -97,4 +98,10 @@ class ConversationRepository(context: Context) { fun markGiftBadgeRevealed(messageId: Long) { oldConversationRepository.markGiftBadgeRevealed(messageId) } + + fun getQuotedMessagePosition(threadId: Long, quote: Quote): Single { + return Single.fromCallable { + SignalDatabase.messages.getQuotedMessagePosition(threadId, quote.id, quote.author) + }.subscribeOn(Schedulers.io()) + } } 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 1c803123bc..7c5d0c5931 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 @@ -18,6 +18,7 @@ import org.thoughtcrime.securesms.conversation.colors.NameColor import org.thoughtcrime.securesms.database.DatabaseObserver import org.thoughtcrime.securesms.database.model.MessageId import org.thoughtcrime.securesms.database.model.MessageRecord +import org.thoughtcrime.securesms.database.model.Quote import org.thoughtcrime.securesms.dependencies.ApplicationDependencies import org.thoughtcrime.securesms.recipients.Recipient import org.thoughtcrime.securesms.recipients.RecipientId @@ -97,6 +98,10 @@ class ConversationViewModel( disposables.clear() } + fun getQuotedMessagePosition(quote: Quote): Single { + return repository.getQuotedMessagePosition(threadId, quote) + } + fun setLastScrolled(lastScrolledTimestamp: Long) { repository.setLastVisibleMessageTimestamp( threadId,