diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationData.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationData.kt index 0364a640e5..4a4d7b7205 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationData.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationData.kt @@ -14,7 +14,8 @@ data class ConversationData( val jumpToPosition: Int, val threadSize: Int, val messageRequestData: MessageRequestData, - @get:JvmName("showUniversalExpireTimerMessage") val showUniversalExpireTimerMessage: Boolean + @get:JvmName("showUniversalExpireTimerMessage") val showUniversalExpireTimerMessage: Boolean, + val unreadCount: Int ) { fun shouldJumpToMessage(): Boolean { diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationRepository.java b/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationRepository.java index ba3078af86..a9de722b63 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationRepository.java +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationRepository.java @@ -119,7 +119,7 @@ public class ConversationRepository { showUniversalExpireTimerUpdate = true; } - return new ConversationData(conversationRecipient, threadId, lastSeen, lastSeenPosition, lastScrolledPosition, jumpToPosition, threadSize, messageRequestData, showUniversalExpireTimerUpdate); + return new ConversationData(conversationRecipient, threadId, lastSeen, lastSeenPosition, lastScrolledPosition, jumpToPosition, threadSize, messageRequestData, showUniversalExpireTimerUpdate, metadata.getUnreadCount()); } public void markGiftBadgeRevealed(long messageId) { 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 cf3ee93eb5..9555a7f6d9 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 @@ -427,6 +427,7 @@ class ConversationFragment : private lateinit var openableGiftItemDecoration: OpenableGiftItemDecoration private lateinit var threadHeaderMarginDecoration: ThreadHeaderMarginDecoration private lateinit var dateHeaderDecoration: DateHeaderDecoration + private lateinit var unreadLineDecoration: UnreadLineDecoration private lateinit var optionsMenuCallback: ConversationOptionsMenuCallback private lateinit var typingIndicatorDecoration: TypingIndicatorDecoration @@ -563,8 +564,10 @@ class ConversationFragment : inputPanel.onPause() - // todo [cfv2] setLastSeen(System.currentTimeMillis()) - // todo [cfv2] markLastSeen() + unreadLineDecoration.unreadCount = viewModel.unreadCount + binding.conversationItemRecycler.invalidateItemDecorations() + + viewModel.markLastSeen() motionEventRelay.setDrain(null) EventBus.getDefault().unregister(this) @@ -719,6 +722,7 @@ class ConversationFragment : binding.conversationItemRecycler.height ) } + unreadLineDecoration.unreadCount = state.meta.unreadCount } .flatMapObservable { it.items.data } .observeOn(AndroidSchedulers.mainThread()) @@ -748,7 +752,7 @@ class ConversationFragment : attachmentManager = AttachmentManager(requireContext(), requireView(), AttachmentManagerListener()) EventBus.getDefault().registerForLifecycle(groupCallViewModel, viewLifecycleOwner) - viewLifecycleOwner.lifecycle.addObserver(LastSeenPositionUpdater(adapter, layoutManager, viewModel)) + viewLifecycleOwner.lifecycle.addObserver(LastScrolledPositionUpdater(adapter, layoutManager, viewModel)) disposables += viewModel.recipient .observeOn(AndroidSchedulers.mainThread()) @@ -1147,6 +1151,7 @@ class ConversationFragment : adapter.onHasWallpaperChanged(wallpaperEnabled) dateHeaderDecoration.hasWallpaper = wallpaperEnabled + unreadLineDecoration.hasWallpaper = wallpaperEnabled val navColor = if (wallpaperEnabled) { R.color.conversation_navigation_wallpaper @@ -1371,6 +1376,9 @@ class ConversationFragment : dateHeaderDecoration = DateHeaderDecoration(hasWallpaper = args.wallpaper != null) binding.conversationItemRecycler.addItemDecoration(dateHeaderDecoration, 0) + + unreadLineDecoration = UnreadLineDecoration(hasWallpaper = args.wallpaper != null) + binding.conversationItemRecycler.addItemDecoration(unreadLineDecoration) } private fun initializeGiphyMp4(): GiphyMp4ProjectionRecycler { @@ -1631,7 +1639,7 @@ class ConversationFragment : return } - // todo [cfv2] fragment.setLastSeen(0); + unreadLineDecoration.unreadCount = 0 scrollToPositionDelegate.resetScrollPosition() attachmentManager.cleanup() @@ -3011,7 +3019,7 @@ class ConversationFragment : //endregion - private class LastSeenPositionUpdater( + private class LastScrolledPositionUpdater( val adapter: ConversationAdapterV2, val layoutManager: LinearLayoutManager, val viewModel: ConversationViewModel 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 bbc260a5e5..c549612868 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 @@ -258,7 +258,7 @@ class ConversationRepository( } fun setLastVisibleMessageTimestamp(threadId: Long, lastVisibleMessageTimestamp: Long) { - SignalExecutors.BOUNDED.submit { SignalDatabase.threads.setLastScrolled(threadId, lastVisibleMessageTimestamp) } + SignalExecutors.BOUNDED_IO.execute { SignalDatabase.threads.setLastScrolled(threadId, lastVisibleMessageTimestamp) } } fun markGiftBadgeRevealed(messageId: Long) { @@ -575,6 +575,12 @@ class ConversationRepository( } } + fun markLastSeen(threadId: Long) { + SignalExecutors.BOUNDED_IO.execute { + SignalDatabase.threads.setLastSeen(threadId) + } + } + /** * Glide target for a contact photo which expects an error drawable, and publishes * the result to the given emitter. 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 ed7c65c467..78bfbad28b 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 @@ -88,6 +88,8 @@ class ConversationViewModel( .observeOn(AndroidSchedulers.mainThread()) val showScrollButtonsSnapshot: Boolean get() = scrollButtonStateStore.state.showScrollButtons + val unreadCount: Int + get() = scrollButtonStateStore.state.unreadCount val recipient: Observable = recipientRepository.conversationRecipient @@ -395,4 +397,8 @@ class ConversationViewModel( .getScheduledMessageCount(threadId) .observeOn(AndroidSchedulers.mainThread()) } + + fun markLastSeen() { + repository.markLastSeen(threadId) + } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/DateHeaderDecoration.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/DateHeaderDecoration.kt index dc3f2fec93..ff0765aa47 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/DateHeaderDecoration.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/DateHeaderDecoration.kt @@ -9,7 +9,6 @@ import android.graphics.Canvas import android.graphics.Rect import android.view.LayoutInflater import android.view.View -import android.view.ViewGroup import android.widget.TextView import androidx.core.content.ContextCompat import androidx.recyclerview.widget.RecyclerView @@ -18,6 +17,8 @@ import org.thoughtcrime.securesms.conversation.v2.data.ConversationMessageElemen import org.thoughtcrime.securesms.database.model.MediaMmsMessageRecord import org.thoughtcrime.securesms.util.DateUtils import org.thoughtcrime.securesms.util.adapter.mapping.MappingModel +import org.thoughtcrime.securesms.util.drawAsItemDecoration +import org.thoughtcrime.securesms.util.layoutIn import org.thoughtcrime.securesms.util.toLocalDate import java.util.Locale @@ -61,12 +62,7 @@ class DateHeaderDecoration(hasWallpaper: Boolean = false, private val scheduleMe if (hasHeader(position)) { val headerView = getHeader(parent, currentItems[position] as ConversationMessageElement).itemView - c.save() - val left = parent.left - val top = child.y.toInt() - headerView.height - c.translate(left.toFloat(), top.toFloat()) - headerView.draw(c) - c.restore() + headerView.drawAsItemDecoration(c, parent, child) } } } @@ -102,13 +98,8 @@ class DateHeaderDecoration(hasWallpaper: Boolean = false, private val scheduleMe holder } - val headerView = headerHolder.itemView - val widthSpec = View.MeasureSpec.makeMeasureSpec(parent.width, View.MeasureSpec.EXACTLY) - val heightSpec = View.MeasureSpec.makeMeasureSpec(parent.height, View.MeasureSpec.UNSPECIFIED) - val childWidth = ViewGroup.getChildMeasureSpec(widthSpec, parent.paddingLeft + parent.paddingRight, headerView.layoutParams.width) - val childHeight = ViewGroup.getChildMeasureSpec(heightSpec, parent.paddingTop + parent.paddingBottom, headerView.layoutParams.height) - headerView.measure(childWidth, childHeight) - headerView.layout(0, 0, headerView.measuredWidth, headerView.measuredHeight) + headerHolder.itemView.layoutIn(parent) + return headerHolder } diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/UnreadLineDecoration.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/UnreadLineDecoration.kt new file mode 100644 index 0000000000..567a225471 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/UnreadLineDecoration.kt @@ -0,0 +1,112 @@ +/* + * Copyright 2023 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.thoughtcrime.securesms.conversation.v2 + +import android.graphics.Canvas +import android.graphics.Rect +import android.view.LayoutInflater +import android.view.View +import android.widget.TextView +import androidx.core.content.ContextCompat +import androidx.recyclerview.widget.RecyclerView +import org.thoughtcrime.securesms.R +import org.thoughtcrime.securesms.util.drawAsItemDecoration +import org.thoughtcrime.securesms.util.layoutIn + +/** + * Renders the unread divider in a conversation list based on the unread count. + */ +class UnreadLineDecoration(hasWallpaper: Boolean) : RecyclerView.ItemDecoration() { + + private var unreadViewHolder: UnreadViewHolder? = null + + var unreadCount: Int = 0 + set(value) { + field = value + unreadViewHolder?.bind() + } + + private val firstUnreadPosition: Int + get() = unreadCount - 1 + + var hasWallpaper: Boolean = hasWallpaper + set(value) { + field = value + unreadViewHolder?.updateForWallpaper() + } + + override fun getItemOffsets(outRect: Rect, view: View, parent: RecyclerView, state: RecyclerView.State) { + if (unreadCount == 0) { + super.getItemOffsets(outRect, view, parent, state) + return + } + + val position = parent.getChildAdapterPosition(view) + + val height = if (position == firstUnreadPosition) { + getUnreadViewHolder(parent).height + } else { + 0 + } + + outRect.set(0, height, 0, 0) + } + + override fun onDrawOver(c: Canvas, parent: RecyclerView, state: RecyclerView.State) { + for (layoutPosition in 0 until parent.childCount) { + val child = parent.getChildAt(layoutPosition) + val position = parent.getChildAdapterPosition(child) + + if (position == firstUnreadPosition) { + getUnreadViewHolder(parent).itemView.drawAsItemDecoration(c, parent, child) + break + } + } + } + + private fun getUnreadViewHolder(parent: RecyclerView): UnreadViewHolder { + if (unreadViewHolder != null) { + return unreadViewHolder!! + } + + unreadViewHolder = UnreadViewHolder(parent) + return unreadViewHolder!! + } + + private inner class UnreadViewHolder(parent: RecyclerView) { + val itemView: View + + private val unreadText: TextView + private val unreadDivider: View + + val height: Int + get() = itemView.height + + init { + itemView = LayoutInflater.from(parent.context).inflate(R.layout.conversation_item_last_seen, parent, false) + unreadText = itemView.findViewById(R.id.text) + unreadDivider = itemView.findViewById(R.id.last_seen_divider) + + bind() + itemView.layoutIn(parent) + } + + fun bind() { + unreadText.text = itemView.context.resources.getQuantityString(R.plurals.ConversationAdapter_n_unread_messages, unreadCount, unreadCount) + updateForWallpaper() + } + + fun updateForWallpaper() { + if (hasWallpaper) { + unreadText.setBackgroundResource(R.drawable.wallpaper_bubble_background_18) + unreadDivider.setBackgroundColor(ContextCompat.getColor(itemView.context, R.color.transparent_black_80)) + } else { + unreadText.background = null + unreadDivider.setBackgroundColor(ContextCompat.getColor(itemView.context, R.color.core_grey_45)) + } + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/ThreadTable.kt b/app/src/main/java/org/thoughtcrime/securesms/database/ThreadTable.kt index 4e2c071132..9e14f66150 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/ThreadTable.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/database/ThreadTable.kt @@ -1014,16 +1014,13 @@ class ThreadTable(context: Context, databaseHelper: SignalDatabase) : DatabaseTa } fun setLastSeen(threadId: Long) { - setLastSeenSilently(threadId) - notifyConversationListListeners() - } - - fun setLastSeenSilently(threadId: Long) { writableDatabase .update(TABLE_NAME) .values(LAST_SEEN to System.currentTimeMillis()) .where("$ID = ?", threadId) .run() + + notifyConversationListListeners() } fun setLastScrolled(threadId: Long, lastScrolledTimestamp: Long) { @@ -1036,7 +1033,7 @@ class ThreadTable(context: Context, databaseHelper: SignalDatabase) : DatabaseTa fun getConversationMetadata(threadId: Long): ConversationMetadata { return readableDatabase - .select(LAST_SEEN, HAS_SENT, LAST_SCROLLED) + .select(UNREAD_COUNT, LAST_SEEN, HAS_SENT, LAST_SCROLLED) .from(TABLE_NAME) .where("$ID = ?", threadId) .run() @@ -1045,13 +1042,15 @@ class ThreadTable(context: Context, databaseHelper: SignalDatabase) : DatabaseTa ConversationMetadata( lastSeen = cursor.requireLong(LAST_SEEN), hasSent = cursor.requireBoolean(HAS_SENT), - lastScrolled = cursor.requireLong(LAST_SCROLLED) + lastScrolled = cursor.requireLong(LAST_SCROLLED), + unreadCount = cursor.requireInt(UNREAD_COUNT) ) } else { ConversationMetadata( lastSeen = -1L, hasSent = false, - lastScrolled = -1 + lastScrolled = -1, + unreadCount = 0 ) } } @@ -2033,7 +2032,8 @@ class ThreadTable(context: Context, databaseHelper: SignalDatabase) : DatabaseTa val lastSeen: Long, @get:JvmName("hasSent") val hasSent: Boolean, - val lastScrolled: Long + val lastScrolled: Long, + val unreadCount: Int ) data class MergeResult(val threadId: Long, val previousThreadId: Long, val neededMerge: Boolean) diff --git a/app/src/main/java/org/thoughtcrime/securesms/messages/MessageContentProcessorV2.kt b/app/src/main/java/org/thoughtcrime/securesms/messages/MessageContentProcessorV2.kt index 204231f0d8..734fe638cf 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/messages/MessageContentProcessorV2.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/messages/MessageContentProcessorV2.kt @@ -196,7 +196,7 @@ open class MessageContentProcessorV2(private val context: Context) { val threadId = SignalDatabase.threads.getThreadIdFor(destination.id) if (threadId != null) { - val (lastSeen) = SignalDatabase.threads.getConversationMetadata(threadId) + val lastSeen = SignalDatabase.threads.getConversationMetadata(threadId).lastSeen val visibleThread = ApplicationDependencies.getMessageNotifier().visibleThread.map(ConversationId::threadId).orElse(-1L) if (threadId != visibleThread && lastSeen > 0 && lastSeen < pending.receivedTimestamp) { diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/ViewExtensions.kt b/app/src/main/java/org/thoughtcrime/securesms/util/ViewExtensions.kt index 1a0cf5f5dc..d684e32410 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/util/ViewExtensions.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/util/ViewExtensions.kt @@ -1,6 +1,8 @@ package org.thoughtcrime.securesms.util +import android.graphics.Canvas import android.view.View +import android.view.ViewGroup import android.widget.TextView import androidx.annotation.DrawableRes import androidx.constraintlayout.widget.ConstraintLayout @@ -71,3 +73,21 @@ fun View.getLifecycle(): Lifecycle? { ViewUtil.getActivityLifecycle(this) } } + +fun View.layoutIn(parent: View) { + val widthSpec = View.MeasureSpec.makeMeasureSpec(parent.width, View.MeasureSpec.EXACTLY) + val heightSpec = View.MeasureSpec.makeMeasureSpec(parent.height, View.MeasureSpec.UNSPECIFIED) + val childWidth = ViewGroup.getChildMeasureSpec(widthSpec, parent.paddingLeft + parent.paddingRight, layoutParams.width) + val childHeight = ViewGroup.getChildMeasureSpec(heightSpec, parent.paddingTop + parent.paddingBottom, layoutParams.height) + measure(childWidth, childHeight) + layout(0, 0, measuredWidth, measuredHeight) +} + +fun View.drawAsItemDecoration(canvas: Canvas, parent: View, child: View) { + canvas.save() + val left = parent.left + val top = child.y.toInt() - height + canvas.translate(left.toFloat(), top.toFloat()) + draw(canvas) + canvas.restore() +}