Updated the conversation to highlight the first unread message on open

This commit is contained in:
Morgan Pretty 2023-06-09 18:05:17 +10:00
parent 3bd2883707
commit da02d385d1
5 changed files with 129 additions and 29 deletions

View File

@ -176,6 +176,7 @@ import java.lang.ref.WeakReference
import java.util.Locale import java.util.Locale
import java.util.concurrent.ExecutionException import java.util.concurrent.ExecutionException
import java.util.concurrent.atomic.AtomicBoolean import java.util.concurrent.atomic.AtomicBoolean
import java.util.concurrent.atomic.AtomicInteger
import java.util.concurrent.atomic.AtomicLong import java.util.concurrent.atomic.AtomicLong
import java.util.concurrent.atomic.AtomicReference import java.util.concurrent.atomic.AtomicReference
import javax.inject.Inject import javax.inject.Inject
@ -341,6 +342,7 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
private val messageToScrollTimestamp = AtomicLong(-1) private val messageToScrollTimestamp = AtomicLong(-1)
private val messageToScrollAuthor = AtomicReference<Address?>(null) private val messageToScrollAuthor = AtomicReference<Address?>(null)
private val firstLoad = AtomicBoolean(true) private val firstLoad = AtomicBoolean(true)
private val forceHighlightNextLoad = AtomicInteger(-1)
private lateinit var reactionDelegate: ConversationReactionDelegate private lateinit var reactionDelegate: ConversationReactionDelegate
private val reactWithAnyEmojiStartPage = -1 private val reactWithAnyEmojiStartPage = -1
@ -419,15 +421,12 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
val weakActivity = WeakReference(this) val weakActivity = WeakReference(this)
lifecycleScope.launch(Dispatchers.IO) { lifecycleScope.launch(Dispatchers.IO) {
unreadCount = mmsSmsDb.getUnreadCount(viewModel.threadId)
// Note: We are accessing the `adapter` property because we want it to be loaded on // Note: We are accessing the `adapter` property because we want it to be loaded on
// the background thread to avoid blocking the UI thread and potentially hanging when // the background thread to avoid blocking the UI thread and potentially hanging when
// transitioning to the activity // transitioning to the activity
weakActivity.get()?.adapter ?: return@launch weakActivity.get()?.adapter ?: return@launch
withContext(Dispatchers.Main) { withContext(Dispatchers.Main) {
updateUnreadCountIndicator()
setUpRecyclerView() setUpRecyclerView()
setUpTypingObserver() setUpTypingObserver()
setUpRecipientObserver() setUpRecipientObserver()
@ -503,19 +502,41 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
val messageTimestamp = messageToScrollTimestamp.getAndSet(-1) val messageTimestamp = messageToScrollTimestamp.getAndSet(-1)
val author = messageToScrollAuthor.getAndSet(null) val author = messageToScrollAuthor.getAndSet(null)
// Update the unreadCount value to be loaded from the database since we got a new message
if (firstLoad.get() || oldCount != newCount) {
// Update the unreadCount value to be loaded from the database since we got a new message
unreadCount = mmsSmsDb.getUnreadCount(viewModel.threadId)
updateUnreadCountIndicator()
}
if (author != null && messageTimestamp >= 0) { if (author != null && messageTimestamp >= 0) {
jumpToMessage(author, messageTimestamp, null) jumpToMessage(author, messageTimestamp, null)
} }
else if (firstLoad.getAndSet(false)) { else if (firstLoad.getAndSet(false)) {
scrollToFirstUnreadMessageIfNeeded(true) // We can't actually just 'shouldHighlight = true' here because any unread messages will
// immediately be marked as ready triggering a reload of the cursor
val lastSeenItemPosition = scrollToFirstUnreadMessageIfNeeded(true)
handleRecyclerViewScrolled() handleRecyclerViewScrolled()
if (lastSeenItemPosition != null) {
forceHighlightNextLoad.set(lastSeenItemPosition)
}
} }
else if (oldCount != newCount) { else if (oldCount != newCount) {
// Update the unreadCount value to be loaded from the database since we got a new message
unreadCount = mmsSmsDb.getUnreadCount(viewModel.threadId)
updateUnreadCountIndicator()
handleRecyclerViewScrolled() handleRecyclerViewScrolled()
} }
else {
// Really annoying but if a message gets marked as read during the initial load it'll
// immediately result in a subsequent load of the cursor, if we trigger the highlight
// within the 'firstLoad' it generally ends up getting repositioned as the views get
// recycled and the wrong view is highlighted - by doing it on the subsequent load the
// correct view is highlighted
val forceHighlightPosition = forceHighlightNextLoad.getAndSet(-1)
if (forceHighlightPosition != -1) {
highlightViewAtPosition(forceHighlightPosition)
}
}
} }
updatePlaceholder() updatePlaceholder()
} }
@ -716,19 +737,29 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
} }
} }
private fun scrollToFirstUnreadMessageIfNeeded(isFirstLoad: Boolean = false) { private fun scrollToFirstUnreadMessageIfNeeded(isFirstLoad: Boolean = false, shouldHighlight: Boolean = false): Int? {
val lastSeenTimestamp = threadDb.getLastSeenAndHasSent(viewModel.threadId).first() val lastSeenTimestamp = threadDb.getLastSeenAndHasSent(viewModel.threadId).first()
val lastSeenItemPosition = adapter.findLastSeenItemPosition(lastSeenTimestamp) ?: return val lastSeenItemPosition = adapter.findLastSeenItemPosition(lastSeenTimestamp) ?: return -1
// If this is triggered when first opening a conversation then we want to position the top // If this is triggered when first opening a conversation then we want to position the top
// of the first unread message in the middle of the screen // of the first unread message in the middle of the screen
if (isFirstLoad && !reverseMessageList) { if (isFirstLoad && !reverseMessageList) {
layoutManager?.scrollToPositionWithOffset(lastSeenItemPosition, ((layoutManager?.height ?: 0) / 2)) layoutManager?.scrollToPositionWithOffset(lastSeenItemPosition, ((layoutManager?.height ?: 0) / 2))
return
if (shouldHighlight) { highlightViewAtPosition(lastSeenItemPosition) }
return lastSeenItemPosition
} }
if (lastSeenItemPosition <= 3) { return } if (lastSeenItemPosition <= 3) { return lastSeenItemPosition }
binding?.conversationRecyclerView?.scrollToPosition(lastSeenItemPosition) binding?.conversationRecyclerView?.scrollToPosition(lastSeenItemPosition)
return lastSeenItemPosition
}
private fun highlightViewAtPosition(position: Int) {
binding?.conversationRecyclerView?.post {
(layoutManager?.findViewByPosition(position) as? VisibleMessageView)?.playHighlight()
}
} }
override fun onPrepareOptionsMenu(menu: Menu): Boolean { override fun onPrepareOptionsMenu(menu: Menu): Boolean {

View File

@ -3,7 +3,6 @@ package org.thoughtcrime.securesms.conversation.v2.messages
import android.content.Context import android.content.Context
import android.graphics.Color import android.graphics.Color
import android.graphics.Rect import android.graphics.Rect
import android.graphics.drawable.Drawable
import android.text.Spannable import android.text.Spannable
import android.text.style.BackgroundColorSpan import android.text.style.BackgroundColorSpan
import android.text.style.ForegroundColorSpan import android.text.style.ForegroundColorSpan
@ -15,9 +14,7 @@ import android.view.View
import androidx.annotation.ColorInt import androidx.annotation.ColorInt
import androidx.appcompat.app.AppCompatActivity import androidx.appcompat.app.AppCompatActivity
import androidx.constraintlayout.widget.ConstraintLayout import androidx.constraintlayout.widget.ConstraintLayout
import androidx.core.content.res.ResourcesCompat import androidx.core.graphics.ColorUtils
import androidx.core.graphics.BlendModeColorFilterCompat
import androidx.core.graphics.BlendModeCompat
import androidx.core.text.getSpans import androidx.core.text.getSpans
import androidx.core.text.toSpannable import androidx.core.text.toSpannable
import androidx.core.view.children import androidx.core.view.children
@ -28,6 +25,7 @@ import okhttp3.HttpUrl
import org.session.libsession.messaging.MessagingModuleConfiguration import org.session.libsession.messaging.MessagingModuleConfiguration
import org.session.libsession.messaging.sending_receiving.attachments.AttachmentTransferProgress import org.session.libsession.messaging.sending_receiving.attachments.AttachmentTransferProgress
import org.session.libsession.messaging.sending_receiving.attachments.DatabaseAttachment import org.session.libsession.messaging.sending_receiving.attachments.DatabaseAttachment
import org.session.libsession.utilities.ThemeUtil
import org.session.libsession.utilities.getColorFromAttr import org.session.libsession.utilities.getColorFromAttr
import org.session.libsession.utilities.recipients.Recipient import org.session.libsession.utilities.recipients.Recipient
import org.thoughtcrime.securesms.conversation.v2.ConversationActivityV2 import org.thoughtcrime.securesms.conversation.v2.ConversationActivityV2
@ -39,6 +37,7 @@ import org.thoughtcrime.securesms.database.model.MessageRecord
import org.thoughtcrime.securesms.database.model.MmsMessageRecord import org.thoughtcrime.securesms.database.model.MmsMessageRecord
import org.thoughtcrime.securesms.database.model.SmsMessageRecord import org.thoughtcrime.securesms.database.model.SmsMessageRecord
import org.thoughtcrime.securesms.mms.GlideRequests import org.thoughtcrime.securesms.mms.GlideRequests
import org.thoughtcrime.securesms.util.GlowViewUtilities
import org.thoughtcrime.securesms.util.SearchUtil import org.thoughtcrime.securesms.util.SearchUtil
import org.thoughtcrime.securesms.util.getAccentColor import org.thoughtcrime.securesms.util.getAccentColor
import java.util.Locale import java.util.Locale
@ -69,12 +68,10 @@ class VisibleMessageContentView : ConstraintLayout {
onAttachmentNeedsDownload: (Long, Long) -> Unit onAttachmentNeedsDownload: (Long, Long) -> Unit
) { ) {
// Background // Background
val background = getBackground(message.isOutgoing)
val color = if (message.isOutgoing) context.getAccentColor() val color = if (message.isOutgoing) context.getAccentColor()
else context.getColorFromAttr(R.attr.message_received_background_color) else context.getColorFromAttr(R.attr.message_received_background_color)
val filter = BlendModeColorFilterCompat.createBlendModeColorFilterCompat(color, BlendModeCompat.SRC_IN) binding.contentParent.mainColor = color
background.colorFilter = filter binding.contentParent.cornerRadius = resources.getDimension(R.dimen.message_corner_radius)
binding.contentParent.background = background
val onlyBodyMessage = message is SmsMessageRecord val onlyBodyMessage = message is SmsMessageRecord
val mediaThumbnailMessage = contactIsTrusted && message is MmsMessageRecord && message.slideDeck.thumbnailSlide != null val mediaThumbnailMessage = contactIsTrusted && message is MmsMessageRecord && message.slideDeck.thumbnailSlide != null
@ -243,11 +240,6 @@ class VisibleMessageContentView : ConstraintLayout {
private fun ViewVisibleMessageContentBinding.barrierViewsGone(): Boolean = private fun ViewVisibleMessageContentBinding.barrierViewsGone(): Boolean =
listOf<View>(albumThumbnailView.root, linkPreviewView.root, voiceMessageView.root, quoteView.root).none { it.isVisible } listOf<View>(albumThumbnailView.root, linkPreviewView.root, voiceMessageView.root, quoteView.root).none { it.isVisible }
private fun getBackground(isOutgoing: Boolean): Drawable {
val backgroundID = if (isOutgoing) R.drawable.message_bubble_background_sent_alone else R.drawable.message_bubble_background_received_alone
return ResourcesCompat.getDrawable(resources, backgroundID, context.theme)!!
}
fun recycle() { fun recycle() {
arrayOf( arrayOf(
binding.deletedMessageView.root, binding.deletedMessageView.root,
@ -265,6 +257,15 @@ class VisibleMessageContentView : ConstraintLayout {
fun playVoiceMessage() { fun playVoiceMessage() {
binding.voiceMessageView.root.togglePlayback() binding.voiceMessageView.root.togglePlayback()
} }
fun playHighlight() {
// Show the highlight colour immediately then slowly fade out
val targetColor = if (ThemeUtil.isDarkTheme(context)) context.getAccentColor() else resources.getColor(R.color.black, context.theme)
val clearTargetColor = ColorUtils.setAlphaComponent(targetColor, 0)
binding.contentParent.numShadowRenders = if (ThemeUtil.isDarkTheme(context)) 3 else 1
binding.contentParent.sessionShadowColor = targetColor
GlowViewUtilities.animateShadowColorChange(binding.contentParent, targetColor, clearTargetColor, 1600)
}
// endregion // endregion
// region Convenience // region Convenience

View File

@ -111,6 +111,8 @@ class VisibleMessageView : LinearLayout {
private fun initialize() { private fun initialize() {
isHapticFeedbackEnabled = true isHapticFeedbackEnabled = true
setWillNotDraw(false) setWillNotDraw(false)
binding.root.disableClipping()
binding.mainContainer.disableClipping()
binding.messageInnerContainer.disableClipping() binding.messageInnerContainer.disableClipping()
binding.messageContentView.root.disableClipping() binding.messageContentView.root.disableClipping()
} }
@ -411,6 +413,10 @@ class VisibleMessageView : LinearLayout {
binding.profilePictureView.root.recycle() binding.profilePictureView.root.recycle()
binding.messageContentView.root.recycle() binding.messageContentView.root.recycle()
} }
fun playHighlight() {
binding.messageContentView.root.playHighlight()
}
// endregion // endregion
// region Interaction // region Interaction

View File

@ -7,6 +7,7 @@ import android.graphics.Canvas
import android.graphics.Paint import android.graphics.Paint
import android.util.AttributeSet import android.util.AttributeSet
import android.view.View import android.view.View
import android.view.animation.AccelerateDecelerateInterpolator
import android.widget.LinearLayout import android.widget.LinearLayout
import android.widget.RelativeLayout import android.widget.RelativeLayout
import androidx.annotation.ColorInt import androidx.annotation.ColorInt
@ -55,16 +56,21 @@ object GlowViewUtilities {
animation.start() animation.start()
} }
fun animateShadowColorChange(view: GlowView, @ColorInt startColor: Int, @ColorInt endColor: Int) { fun animateShadowColorChange(
view: GlowView,
@ColorInt startColor: Int,
@ColorInt endColor: Int,
duration: Long = 250
) {
val animation = ValueAnimator.ofObject(ArgbEvaluator(), startColor, endColor) val animation = ValueAnimator.ofObject(ArgbEvaluator(), startColor, endColor)
animation.duration = 250 animation.duration = duration
animation.interpolator = AccelerateDecelerateInterpolator()
animation.addUpdateListener { animator -> animation.addUpdateListener { animator ->
val color = animator.animatedValue as Int val color = animator.animatedValue as Int
view.sessionShadowColor = color view.sessionShadowColor = color
} }
animation.start() animation.start()
} }
} }
class PNModeView : LinearLayout, GlowView { class PNModeView : LinearLayout, GlowView {
@ -223,3 +229,59 @@ class InputBarButtonImageViewContainer : RelativeLayout, GlowView {
} }
// endregion // endregion
} }
class MessageBubbleView : androidx.constraintlayout.widget.ConstraintLayout, GlowView {
@ColorInt override var mainColor: Int = 0
set(newValue) { field = newValue; paint.color = newValue }
@ColorInt override var sessionShadowColor: Int = 0
set(newValue) {
field = newValue
shadowPaint.setShadowLayer(toPx(10, resources).toFloat(), 0.0f, 0.0f, newValue)
if (numShadowRenders == 0) {
numShadowRenders = 1
}
invalidate()
}
var cornerRadius: Float = 0f
var numShadowRenders: Int = 0
private val paint: Paint by lazy {
val result = Paint()
result.style = Paint.Style.FILL
result.isAntiAlias = true
result
}
private val shadowPaint: Paint by lazy {
val result = Paint()
result.style = Paint.Style.FILL
result.isAntiAlias = true
result
}
// region Lifecycle
constructor(context: Context) : super(context) { }
constructor(context: Context, attrs: AttributeSet) : super(context, attrs) { }
constructor(context: Context, attrs: AttributeSet, defStyleAttr: Int) : super(context, attrs, defStyleAttr) { }
init {
setWillNotDraw(false)
}
// endregion
// region Updating
override fun onDraw(c: Canvas) {
val w = width.toFloat()
val h = height.toFloat()
(0 until numShadowRenders).forEach {
c.drawRoundRect(0f, 0f, w, h, cornerRadius, cornerRadius, shadowPaint)
}
c.drawRoundRect(0f, 0f, w, h, cornerRadius, cornerRadius, paint)
super.onDraw(c)
}
// endregion
}

View File

@ -7,7 +7,7 @@
android:id="@+id/mainContainerConstraint" android:id="@+id/mainContainerConstraint"
xmlns:app="http://schemas.android.com/apk/res-auto"> xmlns:app="http://schemas.android.com/apk/res-auto">
<androidx.constraintlayout.widget.ConstraintLayout <org.thoughtcrime.securesms.util.MessageBubbleView
android:id="@+id/contentParent" android:id="@+id/contentParent"
android:layout_width="wrap_content" android:layout_width="wrap_content"
android:layout_height="wrap_content" android:layout_height="wrap_content"
@ -111,7 +111,7 @@
android:id="@+id/bodyTextView" android:id="@+id/bodyTextView"
android:layout_width="wrap_content" android:layout_width="wrap_content"
android:layout_height="wrap_content"/> android:layout_height="wrap_content"/>
</androidx.constraintlayout.widget.ConstraintLayout> </org.thoughtcrime.securesms.util.MessageBubbleView>
<include layout="@layout/album_thumbnail_view" <include layout="@layout/album_thumbnail_view"
android:visibility="gone" android:visibility="gone"