diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationActivityV2.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationActivityV2.kt index 26b17e19f..8765d89cd 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationActivityV2.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationActivityV2.kt @@ -176,6 +176,7 @@ import java.lang.ref.WeakReference import java.util.Locale import java.util.concurrent.ExecutionException import java.util.concurrent.atomic.AtomicBoolean +import java.util.concurrent.atomic.AtomicInteger import java.util.concurrent.atomic.AtomicLong import java.util.concurrent.atomic.AtomicReference import javax.inject.Inject @@ -341,6 +342,7 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe private val messageToScrollTimestamp = AtomicLong(-1) private val messageToScrollAuthor = AtomicReference(null) private val firstLoad = AtomicBoolean(true) + private val forceHighlightNextLoad = AtomicInteger(-1) private lateinit var reactionDelegate: ConversationReactionDelegate private val reactWithAnyEmojiStartPage = -1 @@ -419,15 +421,12 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe val weakActivity = WeakReference(this) lifecycleScope.launch(Dispatchers.IO) { - unreadCount = mmsSmsDb.getUnreadCount(viewModel.threadId) - // 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 // transitioning to the activity weakActivity.get()?.adapter ?: return@launch withContext(Dispatchers.Main) { - updateUnreadCountIndicator() setUpRecyclerView() setUpTypingObserver() setUpRecipientObserver() @@ -503,19 +502,41 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe val messageTimestamp = messageToScrollTimestamp.getAndSet(-1) 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) { jumpToMessage(author, messageTimestamp, null) } 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() + + if (lastSeenItemPosition != null) { + forceHighlightNextLoad.set(lastSeenItemPosition) + } } 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() } + 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() } @@ -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 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 // of the first unread message in the middle of the screen if (isFirstLoad && !reverseMessageList) { 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) + return lastSeenItemPosition + } + + private fun highlightViewAtPosition(position: Int) { + binding?.conversationRecyclerView?.post { + (layoutManager?.findViewByPosition(position) as? VisibleMessageView)?.playHighlight() + } } override fun onPrepareOptionsMenu(menu: Menu): Boolean { diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/messages/VisibleMessageContentView.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/messages/VisibleMessageContentView.kt index 6e0946105..e7214cfa7 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/messages/VisibleMessageContentView.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/messages/VisibleMessageContentView.kt @@ -3,7 +3,6 @@ package org.thoughtcrime.securesms.conversation.v2.messages import android.content.Context import android.graphics.Color import android.graphics.Rect -import android.graphics.drawable.Drawable import android.text.Spannable import android.text.style.BackgroundColorSpan import android.text.style.ForegroundColorSpan @@ -15,9 +14,7 @@ import android.view.View import androidx.annotation.ColorInt import androidx.appcompat.app.AppCompatActivity import androidx.constraintlayout.widget.ConstraintLayout -import androidx.core.content.res.ResourcesCompat -import androidx.core.graphics.BlendModeColorFilterCompat -import androidx.core.graphics.BlendModeCompat +import androidx.core.graphics.ColorUtils import androidx.core.text.getSpans import androidx.core.text.toSpannable import androidx.core.view.children @@ -28,6 +25,7 @@ import okhttp3.HttpUrl import org.session.libsession.messaging.MessagingModuleConfiguration import org.session.libsession.messaging.sending_receiving.attachments.AttachmentTransferProgress 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.recipients.Recipient 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.SmsMessageRecord import org.thoughtcrime.securesms.mms.GlideRequests +import org.thoughtcrime.securesms.util.GlowViewUtilities import org.thoughtcrime.securesms.util.SearchUtil import org.thoughtcrime.securesms.util.getAccentColor import java.util.Locale @@ -69,12 +68,10 @@ class VisibleMessageContentView : ConstraintLayout { onAttachmentNeedsDownload: (Long, Long) -> Unit ) { // Background - val background = getBackground(message.isOutgoing) val color = if (message.isOutgoing) context.getAccentColor() else context.getColorFromAttr(R.attr.message_received_background_color) - val filter = BlendModeColorFilterCompat.createBlendModeColorFilterCompat(color, BlendModeCompat.SRC_IN) - background.colorFilter = filter - binding.contentParent.background = background + binding.contentParent.mainColor = color + binding.contentParent.cornerRadius = resources.getDimension(R.dimen.message_corner_radius) val onlyBodyMessage = message is SmsMessageRecord val mediaThumbnailMessage = contactIsTrusted && message is MmsMessageRecord && message.slideDeck.thumbnailSlide != null @@ -243,11 +240,6 @@ class VisibleMessageContentView : ConstraintLayout { private fun ViewVisibleMessageContentBinding.barrierViewsGone(): Boolean = listOf(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() { arrayOf( binding.deletedMessageView.root, @@ -265,6 +257,15 @@ class VisibleMessageContentView : ConstraintLayout { fun playVoiceMessage() { 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 // region Convenience diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/messages/VisibleMessageView.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/messages/VisibleMessageView.kt index ce6019e4c..3afa75774 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/messages/VisibleMessageView.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/messages/VisibleMessageView.kt @@ -111,6 +111,8 @@ class VisibleMessageView : LinearLayout { private fun initialize() { isHapticFeedbackEnabled = true setWillNotDraw(false) + binding.root.disableClipping() + binding.mainContainer.disableClipping() binding.messageInnerContainer.disableClipping() binding.messageContentView.root.disableClipping() } @@ -411,6 +413,10 @@ class VisibleMessageView : LinearLayout { binding.profilePictureView.root.recycle() binding.messageContentView.root.recycle() } + + fun playHighlight() { + binding.messageContentView.root.playHighlight() + } // endregion // region Interaction diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/GlowView.kt b/app/src/main/java/org/thoughtcrime/securesms/util/GlowView.kt index 08b81e5cb..c7d53c1fe 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/util/GlowView.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/util/GlowView.kt @@ -7,6 +7,7 @@ import android.graphics.Canvas import android.graphics.Paint import android.util.AttributeSet import android.view.View +import android.view.animation.AccelerateDecelerateInterpolator import android.widget.LinearLayout import android.widget.RelativeLayout import androidx.annotation.ColorInt @@ -55,16 +56,21 @@ object GlowViewUtilities { 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) - animation.duration = 250 + animation.duration = duration + animation.interpolator = AccelerateDecelerateInterpolator() animation.addUpdateListener { animator -> val color = animator.animatedValue as Int view.sessionShadowColor = color } animation.start() } - } class PNModeView : LinearLayout, GlowView { @@ -223,3 +229,59 @@ class InputBarButtonImageViewContainer : RelativeLayout, GlowView { } // 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 +} diff --git a/app/src/main/res/layout/view_visible_message_content.xml b/app/src/main/res/layout/view_visible_message_content.xml index 9f1f57634..e897bcea5 100644 --- a/app/src/main/res/layout/view_visible_message_content.xml +++ b/app/src/main/res/layout/view_visible_message_content.xml @@ -7,7 +7,7 @@ android:id="@+id/mainContainerConstraint" xmlns:app="http://schemas.android.com/apk/res-auto"> - - +