Add support for jumping to quoted messages in CFV2.
This commit is contained in:
parent
5ddd7cdb9e
commit
dc153ff4e6
4 changed files with 193 additions and 31 deletions
|
@ -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<Unit>()
|
||||
|
@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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<Intent>
|
||||
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) {
|
||||
|
|
|
@ -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<Int> {
|
||||
return Single.fromCallable {
|
||||
SignalDatabase.messages.getQuotedMessagePosition(threadId, quote.id, quote.author)
|
||||
}.subscribeOn(Schedulers.io())
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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<Int> {
|
||||
return repository.getQuotedMessagePosition(threadId, quote)
|
||||
}
|
||||
|
||||
fun setLastScrolled(lastScrolledTimestamp: Long) {
|
||||
repository.setLastVisibleMessageTimestamp(
|
||||
threadId,
|
||||
|
|
Loading…
Add table
Reference in a new issue