diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/mutiselect/MultiselectItemDecoration.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/mutiselect/MultiselectItemDecoration.kt index de26a72033..eff82c2591 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/mutiselect/MultiselectItemDecoration.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/mutiselect/MultiselectItemDecoration.kt @@ -27,6 +27,7 @@ import androidx.core.view.children import androidx.core.view.forEach import androidx.lifecycle.DefaultLifecycleObserver import androidx.lifecycle.LifecycleOwner +import androidx.recyclerview.widget.ConcatAdapter import androidx.recyclerview.widget.RecyclerView import com.airbnb.lottie.SimpleColorFilter import com.google.android.material.animation.ArgbEvaluatorCompat @@ -124,7 +125,15 @@ class MultiselectItemDecoration( } private fun getCurrentSelection(parent: RecyclerView): Set { - return (parent.adapter as ConversationAdapterBridge).selectedItems + return parent.findAdapterBridge().selectedItems + } + + private fun RecyclerView.findAdapterBridge(): ConversationAdapterBridge { + return when (val parentAdapter = adapter!!) { + is ConversationAdapterBridge -> parentAdapter + is ConcatAdapter -> (parentAdapter.adapters[1] as ConversationAdapterBridge) + else -> error("Unexpected adapter configuration") + } } override fun getItemOffsets(outRect: Rect, view: View, parent: RecyclerView, state: RecyclerView.State) { @@ -156,14 +165,14 @@ class MultiselectItemDecoration( outRect.setEmpty() updateChildOffsets(parent, view) - consumePulseRequest(parent.adapter as ConversationAdapterBridge) + consumePulseRequest(parent.findAdapterBridge()) } /** * Draws the background shade. */ override fun onDraw(canvas: Canvas, parent: RecyclerView, state: RecyclerView.State) { - val adapter = parent.adapter as ConversationAdapterBridge + val adapter = parent.findAdapterBridge() if (adapter.selectedItems.isEmpty()) { drawFocusShadeUnderIfNecessary(canvas, parent) @@ -231,7 +240,7 @@ class MultiselectItemDecoration( * Draws the selected check or empty circle. */ override fun onDrawOver(canvas: Canvas, parent: RecyclerView, state: RecyclerView.State) { - val adapter = parent.adapter as ConversationAdapterBridge + val adapter = parent.findAdapterBridge() if (adapter.selectedItems.isEmpty()) { drawFocusShadeOverIfNecessary(canvas, parent) } @@ -347,7 +356,7 @@ class MultiselectItemDecoration( * called in getItemOffsets to ensure the gutter goes away when multiselect mode ends. */ private fun updateChildOffsets(parent: RecyclerView, child: View) { - val adapter = parent.adapter as ConversationAdapterBridge + val adapter = parent.findAdapterBridge() val isLtr = ViewUtil.isLtr(child) val multiselectable: Multiselectable = resolveMultiselectable(parent, child) ?: return 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 be01f9695b..5110dd276b 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 @@ -62,6 +62,7 @@ import androidx.lifecycle.DefaultLifecycleObserver import androidx.lifecycle.Lifecycle import androidx.lifecycle.LifecycleOwner import androidx.lifecycle.Observer +import androidx.recyclerview.widget.ConcatAdapter import androidx.recyclerview.widget.ConversationLayoutManager import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.RecyclerView @@ -468,6 +469,7 @@ class ConversationFragment : private lateinit var conversationActivityResultContracts: ConversationActivityResultContracts private lateinit var scrollToPositionDelegate: ScrollToPositionDelegate private lateinit var adapter: ConversationAdapterV2 + private lateinit var typingIndicatorAdapter: ConversationTypingIndicatorAdapter private lateinit var recyclerViewColorizer: RecyclerViewColorizer private lateinit var attachmentManager: AttachmentManager private lateinit var multiselectItemDecoration: MultiselectItemDecoration @@ -475,7 +477,6 @@ class ConversationFragment : private lateinit var threadHeaderMarginDecoration: ThreadHeaderMarginDecoration private lateinit var conversationItemDecorations: ConversationItemDecorations private lateinit var optionsMenuCallback: ConversationOptionsMenuCallback - private lateinit var typingIndicatorDecoration: TypingIndicatorDecoration private lateinit var backPressedCallback: BackPressedDelegate private var animationsAllowed = false @@ -1086,18 +1087,24 @@ class ConversationFragment : } private fun presentTypingIndicator() { - typingIndicatorDecoration = TypingIndicatorDecoration(requireContext(), binding.conversationItemRecycler) - binding.conversationItemRecycler.addItemDecoration(typingIndicatorDecoration) + typingIndicatorAdapter.registerAdapterDataObserver(object : RecyclerView.AdapterDataObserver() { + override fun onItemRangeInserted(positionStart: Int, itemCount: Int) { + if (positionStart == 0 && itemCount == 1 && layoutManager.findFirstCompletelyVisibleItemPosition() == 0) { + scrollToPositionDelegate.resetScrollPosition() + } + } + }) ApplicationDependencies.getTypingStatusRepository().getTypists(args.threadId).observe(viewLifecycleOwner) { val recipient = viewModel.recipientSnapshot ?: return@observe - typingIndicatorDecoration.setTypists( - GlideApp.with(this), - it.typists, - recipient.isGroup, - recipient.hasWallpaper(), - it.isReplacedByIncomingMessage + typingIndicatorAdapter.setState( + ConversationTypingIndicatorAdapter.State( + typists = it.typists, + isGroupThread = recipient.isGroup, + hasWallpaper = recipient.hasWallpaper(), + isReplacedByIncomingMessage = it.isReplacedByIncomingMessage + ) ) } } @@ -1531,6 +1538,8 @@ class ConversationFragment : startExpirationTimeout = viewModel::startExpirationTimeout ) + typingIndicatorAdapter = ConversationTypingIndicatorAdapter(GlideApp.with(this)) + scrollToPositionDelegate = ScrollToPositionDelegate( recyclerView = binding.conversationItemRecycler, canJumpToPosition = adapter::canJumpToPosition @@ -1541,7 +1550,7 @@ class ConversationFragment : recyclerViewColorizer = RecyclerViewColorizer(binding.conversationItemRecycler) recyclerViewColorizer.setChatColors(args.chatColors) - binding.conversationItemRecycler.adapter = adapter + binding.conversationItemRecycler.adapter = ConcatAdapter(typingIndicatorAdapter, adapter) multiselectItemDecoration = MultiselectItemDecoration( requireContext() ) { viewModel.wallpaperSnapshot } diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationItemDecorations.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationItemDecorations.kt index 5c8db86ec4..4d2b2ae853 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationItemDecorations.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationItemDecorations.kt @@ -55,7 +55,14 @@ class ConversationItemDecorations(hasWallpaper: Boolean = false, private val sch } override fun getItemOffsets(outRect: Rect, view: View, parent: RecyclerView, state: RecyclerView.State) { - val position = parent.getChildAdapterPosition(view) + val viewHolder = parent.getChildViewHolder(view) + + if (viewHolder is ConversationTypingIndicatorAdapter.ViewHolder) { + outRect.set(0, 0, 0, 0) + return + } + + val position = viewHolder.bindingAdapterPosition val unreadHeight = if (isFirstUnread(position)) { getUnreadViewHolder(parent).height @@ -76,9 +83,15 @@ class ConversationItemDecorations(hasWallpaper: Boolean = false, private val sch val count = parent.childCount for (layoutPosition in 0 until count) { val child = parent.getChildAt(count - 1 - layoutPosition) - val position = parent.getChildAdapterPosition(child) + val viewHolder = parent.getChildViewHolder(child) - val unreadOffset = if (isFirstUnread(position)) { + if (viewHolder is ConversationTypingIndicatorAdapter.ViewHolder) { + continue + } + + val bindingAdapterPosition = viewHolder.bindingAdapterPosition + + val unreadOffset = if (isFirstUnread(bindingAdapterPosition)) { val unread = getUnreadViewHolder(parent) unread.itemView.drawAsTopItemDecoration(c, parent, child) unread.height @@ -86,8 +99,8 @@ class ConversationItemDecorations(hasWallpaper: Boolean = false, private val sch 0 } - if (hasHeader(position)) { - val headerView = getHeader(parent, currentItems[position] as ConversationMessageElement).itemView + if (hasHeader(bindingAdapterPosition)) { + val headerView = getHeader(parent, currentItems[bindingAdapterPosition] as ConversationMessageElement).itemView headerView.drawAsTopItemDecoration(c, parent, child, unreadOffset) } } @@ -136,18 +149,18 @@ class ConversationItemDecorations(hasWallpaper: Boolean = false, private val sch } } - private fun isFirstUnread(position: Int): Boolean { + private fun isFirstUnread(bindingAdapterPosition: Int): Boolean { val state = unreadState return state is UnreadState.CompleteUnreadState && state.firstUnreadTimestamp != null && - position in currentItems.indices && - (currentItems[position] as? ConversationMessageElement)?.timestamp() == state.firstUnreadTimestamp + bindingAdapterPosition in currentItems.indices && + (currentItems[bindingAdapterPosition] as? ConversationMessageElement)?.timestamp() == state.firstUnreadTimestamp } - private fun hasHeader(position: Int): Boolean { - val model = if (position in currentItems.indices) { - currentItems[position] + private fun hasHeader(bindingAdapterPosition: Int): Boolean { + val model = if (bindingAdapterPosition in currentItems.indices) { + currentItems[bindingAdapterPosition] } else { null } @@ -156,7 +169,7 @@ class ConversationItemDecorations(hasWallpaper: Boolean = false, private val sch return false } - val previousPosition = position + 1 + val previousPosition = bindingAdapterPosition + 1 val previousDay: Long if (previousPosition in currentItems.indices) { val previousModel = currentItems[previousPosition] diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationTypingIndicatorAdapter.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationTypingIndicatorAdapter.kt new file mode 100644 index 0000000000..30b3c258a2 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationTypingIndicatorAdapter.kt @@ -0,0 +1,68 @@ +/* + * Copyright 2023 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.thoughtcrime.securesms.conversation.v2 + +import android.view.LayoutInflater +import android.view.ViewGroup +import androidx.recyclerview.widget.RecyclerView +import org.signal.core.util.toInt +import org.thoughtcrime.securesms.R +import org.thoughtcrime.securesms.components.ConversationTypingView +import org.thoughtcrime.securesms.mms.GlideRequests +import org.thoughtcrime.securesms.recipients.Recipient + +class ConversationTypingIndicatorAdapter( + private val glideRequests: GlideRequests +) : RecyclerView.Adapter() { + + private var state: State = State() + + fun setState(state: State) { + val isInsert = this.state.typists.isEmpty() && state.typists.isNotEmpty() + val isRemoval = state.typists.isEmpty() && this.state.typists.isNotEmpty() + val isChange = state.typists.isNotEmpty() && this.state.typists.isNotEmpty() + + this.state = state + + when { + isInsert -> notifyItemInserted(0) + isRemoval -> notifyItemRemoved(0) + isChange -> notifyItemChanged(0) + else -> Unit + } + } + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder { + return ViewHolder(LayoutInflater.from(parent.context).inflate(R.layout.conversation_typing_view, parent, false) as ConversationTypingView) + } + + override fun getItemCount(): Int = state.typists.isNotEmpty().toInt() + + override fun onBindViewHolder(holder: ViewHolder, position: Int) { + holder.bind(glideRequests, state) + } + + class ViewHolder(private val conversationTypingView: ConversationTypingView) : RecyclerView.ViewHolder(conversationTypingView) { + fun bind( + glideRequests: GlideRequests, + state: State + ) { + conversationTypingView.setTypists( + glideRequests, + state.typists, + state.isGroupThread, + state.hasWallpaper + ) + } + } + + data class State( + val typists: List = emptyList(), + val hasWallpaper: Boolean = false, + val isGroupThread: Boolean = false, + val isReplacedByIncomingMessage: Boolean = false // TODO + ) +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/TypingIndicatorDecoration.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/TypingIndicatorDecoration.kt deleted file mode 100644 index 408810a56b..0000000000 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/TypingIndicatorDecoration.kt +++ /dev/null @@ -1,149 +0,0 @@ -/* - * Copyright 2023 Signal Messenger, LLC - * SPDX-License-Identifier: AGPL-3.0-only - */ - -package org.thoughtcrime.securesms.conversation.v2 - -import android.animation.ValueAnimator -import android.content.Context -import android.graphics.Canvas -import android.graphics.Rect -import android.view.LayoutInflater -import android.view.View -import android.view.View.MeasureSpec -import androidx.core.graphics.withTranslation -import androidx.core.view.children -import androidx.recyclerview.widget.RecyclerView -import androidx.recyclerview.widget.RecyclerView.ItemDecoration -import org.signal.core.util.logging.Log -import org.thoughtcrime.securesms.R -import org.thoughtcrime.securesms.components.ConversationTypingView -import org.thoughtcrime.securesms.mms.GlideRequests -import org.thoughtcrime.securesms.recipients.Recipient - -/** - * Displays a typing indicator as a part of the very last (first) item in the adapter. - */ -class TypingIndicatorDecoration( - private val context: Context, - private val rootView: RecyclerView -) : ItemDecoration() { - - companion object { - private val TAG = Log.tag(TypingIndicatorDecoration::class.java) - } - - private val typingView: ConversationTypingView by lazy(LazyThreadSafetyMode.NONE) { - LayoutInflater.from(context).inflate(R.layout.conversation_typing_view, rootView, false) as ConversationTypingView - } - - private var displayIndicator = false - private var animationFraction = 0f - private var offsetAnimator: ValueAnimator? = null - - init { - rootView.addOnLayoutChangeListener { _, left, top, right, bottom, oldLeft, oldTop, oldRight, oldBottom -> - if (left != oldLeft || top != oldTop || right != oldRight || bottom != oldBottom) { - remeasureTypingView() - rootView.invalidateItemDecorations() - } - } - } - - override fun getItemOffsets(outRect: Rect, view: View, parent: RecyclerView, state: RecyclerView.State) { - if (!displayIndicator && animationFraction == 0f) { - return outRect.set(0, 0, 0, 0) - } - - if (parent.getChildAdapterPosition(view) == 0) { - remeasureTypingView() - outRect.set(0, 0, 0, (typingView.measuredHeight * animationFraction).toInt()) - } else { - outRect.set(0, 0, 0, 0) - } - } - - override fun onDrawOver(c: Canvas, parent: RecyclerView, state: RecyclerView.State) { - if (!displayIndicator && offsetAnimator?.isRunning != true) { - return - } - - val firstChild = parent.children.firstOrNull() ?: return - - if (parent.getChildAdapterPosition(firstChild) == 0) { - c.withTranslation( - x = firstChild.left.toFloat(), - y = firstChild.bottom.toFloat() - ) { - typingView.draw(this) - } - - if (typingView.isActive) { - rootView.post { rootView.invalidateItemDecorations() } - } - } - } - - fun setTypists( - glideRequests: GlideRequests, - typists: List, - isGroupThread: Boolean, - hasWallpaper: Boolean, - isReplacedByIncomingMessage: Boolean - ) { - Log.d(TAG, "setTypists: Updating typists: ${typists.size} $isGroupThread $hasWallpaper $isReplacedByIncomingMessage") - - val isEdge = displayIndicator != typists.isNotEmpty() - displayIndicator = typists.isNotEmpty() - - typingView.setTypists( - glideRequests, - typists, - isGroupThread, - hasWallpaper - ) - remeasureTypingView() - rootView.invalidateItemDecorations() - - if (isReplacedByIncomingMessage) { - offsetAnimator?.cancel() - animationFraction = 0f - } else if (isEdge) { - animateOffset() - } - } - - private fun animateOffset() { - offsetAnimator?.cancel() - - val (start, end) = if (displayIndicator) { - animationFraction to 1f - } else { - animationFraction to 0f - } - - offsetAnimator = ValueAnimator.ofFloat(start, end).apply { - addUpdateListener { - animationFraction = it.animatedValue as Float - rootView.invalidateItemDecorations() - } - start() - } - } - - private fun remeasureTypingView() { - with(typingView) { - measure( - MeasureSpec.makeMeasureSpec(0, MeasureSpec.UNSPECIFIED), - MeasureSpec.makeMeasureSpec(0, MeasureSpec.UNSPECIFIED) - ) - layout( - 0, - 0, - measuredWidth, - measuredHeight - ) - } - } -}