diff --git a/app/src/androidTest/java/org/thoughtcrime/securesms/conversation/v2/items/V2ConversationItemShapeTest.kt b/app/src/androidTest/java/org/thoughtcrime/securesms/conversation/v2/items/V2ConversationItemShapeTest.kt new file mode 100644 index 0000000000..b4fc41234a --- /dev/null +++ b/app/src/androidTest/java/org/thoughtcrime/securesms/conversation/v2/items/V2ConversationItemShapeTest.kt @@ -0,0 +1,325 @@ +/* + * Copyright 2023 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.thoughtcrime.securesms.conversation.v2.items + +import android.net.Uri +import android.view.View +import androidx.lifecycle.Observer +import org.junit.Assert.assertEquals +import org.junit.Rule +import org.junit.Test +import org.signal.ringrtc.CallLinkRootKey +import org.thoughtcrime.securesms.components.voice.VoiceNotePlaybackState +import org.thoughtcrime.securesms.contactshare.Contact +import org.thoughtcrime.securesms.conversation.ConversationAdapter +import org.thoughtcrime.securesms.conversation.ConversationItem +import org.thoughtcrime.securesms.conversation.ConversationItemDisplayMode +import org.thoughtcrime.securesms.conversation.ConversationMessage +import org.thoughtcrime.securesms.conversation.colors.Colorizer +import org.thoughtcrime.securesms.conversation.mutiselect.MultiselectPart +import org.thoughtcrime.securesms.database.FakeMessageRecords +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.groups.GroupId +import org.thoughtcrime.securesms.groups.GroupMigrationMembershipChange +import org.thoughtcrime.securesms.linkpreview.LinkPreview +import org.thoughtcrime.securesms.mediapreview.MediaIntentFactory +import org.thoughtcrime.securesms.recipients.Recipient +import org.thoughtcrime.securesms.recipients.RecipientId +import org.thoughtcrime.securesms.stickers.StickerLocator +import org.thoughtcrime.securesms.testing.SignalActivityRule +import kotlin.time.Duration.Companion.minutes + +class V2ConversationItemShapeTest { + + @get:Rule + val harness = SignalActivityRule(othersCount = 10) + + @Test + fun givenNextAndPreviousMessageDoNotExist_whenISetMessageShape_thenIExpectSingle() { + val testSubject = V2ConversationItemShape(FakeConversationContext()) + + val expected = V2ConversationItemShape.MessageShape.SINGLE + val actual = testSubject.setMessageShape( + isLtr = true, + currentMessage = getMessageRecord(), + isGroupThread = false, + adapterPosition = 5 + ) + + assertEquals(expected, actual) + } + + @Test + fun givenPreviousWithinTimeoutAndNoNext_whenISetMessageShape_thenIExpectEnd() { + val now = System.currentTimeMillis() + val prev = now - 2.minutes.inWholeMilliseconds + + val testSubject = V2ConversationItemShape( + FakeConversationContext( + previousMessage = getMessageRecord(prev) + ) + ) + + val expected = V2ConversationItemShape.MessageShape.END + val actual = testSubject.setMessageShape( + isLtr = true, + currentMessage = getMessageRecord(now), + isGroupThread = false, + adapterPosition = 5 + ) + + assertEquals(expected, actual) + } + + @Test + fun givenNextWithinTimeoutAndNoPrevious_whenISetMessageShape_thenIExpectStart() { + val now = System.currentTimeMillis() + val prev = now - 2.minutes.inWholeMilliseconds + + val testSubject = V2ConversationItemShape( + FakeConversationContext( + nextMessage = getMessageRecord(now) + ) + ) + + val expected = V2ConversationItemShape.MessageShape.START + val actual = testSubject.setMessageShape( + isLtr = true, + currentMessage = getMessageRecord(prev), + isGroupThread = false, + adapterPosition = 5 + ) + + assertEquals(expected, actual) + } + + @Test + fun givenPreviousAndNextWithinTimeout_whenISetMessageShape_thenIExpectMiddle() { + val now = System.currentTimeMillis() + val prev = now - 2.minutes.inWholeMilliseconds + val next = now + 2.minutes.inWholeMilliseconds + + val testSubject = V2ConversationItemShape( + FakeConversationContext( + previousMessage = getMessageRecord(prev), + nextMessage = getMessageRecord(next) + ) + ) + + val expected = V2ConversationItemShape.MessageShape.MIDDLE + val actual = testSubject.setMessageShape( + isLtr = true, + currentMessage = getMessageRecord(now), + isGroupThread = false, + adapterPosition = 5 + ) + + assertEquals(expected, actual) + } + + @Test + fun givenPreviousOutsideTimeoutAndNoNext_whenISetMessageShape_thenIExpectSingle() { + val now = System.currentTimeMillis() + val prev = now - 4.minutes.inWholeMilliseconds + + val testSubject = V2ConversationItemShape( + FakeConversationContext( + previousMessage = getMessageRecord(prev) + ) + ) + + val expected = V2ConversationItemShape.MessageShape.SINGLE + val actual = testSubject.setMessageShape( + isLtr = true, + currentMessage = getMessageRecord(now), + isGroupThread = false, + adapterPosition = 5 + ) + + assertEquals(expected, actual) + } + + @Test + fun givenNextOutsideTimeoutAndNoPrevious_whenISetMessageShape_thenIExpectSingle() { + val now = System.currentTimeMillis() + val prev = now - 4.minutes.inWholeMilliseconds + + val testSubject = V2ConversationItemShape( + FakeConversationContext( + nextMessage = getMessageRecord(now) + ) + ) + + val expected = V2ConversationItemShape.MessageShape.SINGLE + val actual = testSubject.setMessageShape( + isLtr = true, + currentMessage = getMessageRecord(prev), + isGroupThread = false, + adapterPosition = 5 + ) + + assertEquals(expected, actual) + } + + @Test + fun givenPreviousAndNextOutsideTimeout_whenISetMessageShape_thenIExpectSingle() { + val now = System.currentTimeMillis() + val prev = now - 4.minutes.inWholeMilliseconds + val next = now + 4.minutes.inWholeMilliseconds + + val testSubject = V2ConversationItemShape( + FakeConversationContext( + previousMessage = getMessageRecord(prev), + nextMessage = getMessageRecord(next) + ) + ) + + val expected = V2ConversationItemShape.MessageShape.SINGLE + val actual = testSubject.setMessageShape( + isLtr = true, + currentMessage = getMessageRecord(now), + isGroupThread = false, + adapterPosition = 5 + ) + + assertEquals(expected, actual) + } + + private fun getMessageRecord( + timestamp: Long = System.currentTimeMillis() + ): MessageRecord { + return FakeMessageRecords.buildMediaMmsMessageRecord( + dateReceived = timestamp, + dateSent = timestamp, + dateServer = timestamp + ) + } + + private class FakeConversationContext( + private val hasWallpaper: Boolean = false, + private val previousMessage: MessageRecord? = null, + private val nextMessage: MessageRecord? = null + ) : V2ConversationContext { + + private val colorizer = Colorizer() + + override val displayMode: ConversationItemDisplayMode = ConversationItemDisplayMode.STANDARD + + override val clickListener: ConversationAdapter.ItemClickListener = FakeConversationItemClickListener + + override fun onStartExpirationTimeout(messageRecord: MessageRecord) = Unit + + override fun hasWallpaper(): Boolean = hasWallpaper + + override fun getColorizer(): Colorizer = colorizer + + override fun getNextMessage(adapterPosition: Int): MessageRecord? = nextMessage + + override fun getPreviousMessage(adapterPosition: Int): MessageRecord? = previousMessage + } + + private object FakeConversationItemClickListener : ConversationAdapter.ItemClickListener { + override fun onQuoteClicked(messageRecord: MmsMessageRecord?) = Unit + + override fun onLinkPreviewClicked(linkPreview: LinkPreview) = Unit + + override fun onQuotedIndicatorClicked(messageRecord: MessageRecord) = Unit + + override fun onMoreTextClicked(conversationRecipientId: RecipientId, messageId: Long, isMms: Boolean) = Unit + + override fun onStickerClicked(stickerLocator: StickerLocator) = Unit + + override fun onViewOnceMessageClicked(messageRecord: MmsMessageRecord) = Unit + + override fun onSharedContactDetailsClicked(contact: Contact, avatarTransitionView: View) = Unit + + override fun onAddToContactsClicked(contact: Contact) = Unit + + override fun onMessageSharedContactClicked(choices: MutableList) = Unit + + override fun onInviteSharedContactClicked(choices: MutableList) = Unit + + override fun onReactionClicked(multiselectPart: MultiselectPart, messageId: Long, isMms: Boolean) = Unit + + override fun onGroupMemberClicked(recipientId: RecipientId, groupId: GroupId) = Unit + + override fun onMessageWithErrorClicked(messageRecord: MessageRecord) = Unit + + override fun onMessageWithRecaptchaNeededClicked(messageRecord: MessageRecord) = Unit + + override fun onIncomingIdentityMismatchClicked(recipientId: RecipientId) = Unit + + override fun onRegisterVoiceNoteCallbacks(onPlaybackStartObserver: Observer) = Unit + + override fun onUnregisterVoiceNoteCallbacks(onPlaybackStartObserver: Observer) = Unit + + override fun onVoiceNotePause(uri: Uri) = Unit + + override fun onVoiceNotePlay(uri: Uri, messageId: Long, position: Double) = Unit + + override fun onVoiceNoteSeekTo(uri: Uri, position: Double) = Unit + + override fun onVoiceNotePlaybackSpeedChanged(uri: Uri, speed: Float) = Unit + + override fun onGroupMigrationLearnMoreClicked(membershipChange: GroupMigrationMembershipChange) = Unit + + override fun onChatSessionRefreshLearnMoreClicked() = Unit + + override fun onBadDecryptLearnMoreClicked(author: RecipientId) = Unit + + override fun onSafetyNumberLearnMoreClicked(recipient: Recipient) = Unit + + override fun onJoinGroupCallClicked() = Unit + + override fun onInviteFriendsToGroupClicked(groupId: GroupId.V2) = Unit + + override fun onEnableCallNotificationsClicked() = Unit + + override fun onPlayInlineContent(conversationMessage: ConversationMessage?) = Unit + + override fun onInMemoryMessageClicked(messageRecord: InMemoryMessageRecord) = Unit + + override fun onViewGroupDescriptionChange(groupId: GroupId?, description: String, isMessageRequestAccepted: Boolean) = Unit + + override fun onChangeNumberUpdateContact(recipient: Recipient) = Unit + + override fun onCallToAction(action: String) = Unit + + override fun onDonateClicked() = Unit + + override fun onBlockJoinRequest(recipient: Recipient) = Unit + + override fun onRecipientNameClicked(target: RecipientId) = Unit + + override fun onInviteToSignalClicked() = Unit + + override fun onActivatePaymentsClicked() = Unit + + override fun onSendPaymentClicked(recipientId: RecipientId) = Unit + + override fun onScheduledIndicatorClicked(view: View, conversationMessage: ConversationMessage) = Unit + + override fun onUrlClicked(url: String): Boolean = false + + override fun onViewGiftBadgeClicked(messageRecord: MessageRecord) = Unit + + override fun onGiftBadgeRevealed(messageRecord: MessageRecord) = Unit + + override fun goToMediaPreview(parent: ConversationItem?, sharedElement: View?, args: MediaIntentFactory.MediaPreviewArgs?) = Unit + + override fun onEditedIndicatorClicked(messageRecord: MessageRecord) = Unit + + override fun onShowGroupDescriptionClicked(groupName: String, description: String, shouldLinkifyWebLinks: Boolean) = Unit + + override fun onJoinCallLink(callLinkRootKey: CallLinkRootKey) = Unit + + override fun onItemClick(item: MultiselectPart?) = Unit + + override fun onItemLongClick(itemView: View?, item: MultiselectPart?) = Unit + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/items/V2ConversationItemShape.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/items/V2ConversationItemShape.kt index 48eb43c683..a63a0afce2 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/items/V2ConversationItemShape.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/items/V2ConversationItemShape.kt @@ -8,13 +8,12 @@ package org.thoughtcrime.securesms.conversation.v2.items import com.google.android.material.shape.MaterialShapeDrawable import com.google.android.material.shape.ShapeAppearanceModel import org.signal.core.util.dp -import org.thoughtcrime.securesms.conversation.ConversationMessage import org.thoughtcrime.securesms.database.model.MessageRecord import org.thoughtcrime.securesms.util.DateUtils import org.thoughtcrime.securesms.util.Projection import org.thoughtcrime.securesms.util.isScheduled -import java.util.concurrent.TimeUnit import kotlin.math.abs +import kotlin.time.Duration.Companion.minutes /** * Determines the shape for a conversation item based off of the appearance context @@ -30,6 +29,8 @@ class V2ConversationItemShape( private var collapsedSpacing: Float = 1f.dp private var defaultSpacing: Float = 8f.dp + + private val clusterTimeout = 3.minutes } var corners: Projection.Corners = Projection.Corners(bigRadius) @@ -49,13 +50,12 @@ class V2ConversationItemShape( */ fun setMessageShape( isLtr: Boolean, - conversationMessage: ConversationMessage, + currentMessage: MessageRecord, + isGroupThread: Boolean, adapterPosition: Int ): MessageShape { - val currentMessage: MessageRecord = conversationMessage.messageRecord val nextMessage: MessageRecord? = conversationContext.getNextMessage(adapterPosition) val previousMessage: MessageRecord? = conversationContext.getPreviousMessage(adapterPosition) - val isGroupThread: Boolean = conversationMessage.threadRecipient.isGroup if (isSingularMessage(currentMessage, previousMessage, nextMessage, isGroupThread)) { setBodyBubbleCorners(isLtr, bigRadius, bigRadius, bigRadius, bigRadius) @@ -153,7 +153,7 @@ class V2ConversationItemShape( } private fun isWithinClusteringTime(currentMessage: MessageRecord, previousMessage: MessageRecord): Boolean { - return abs(currentMessage.dateSent - previousMessage.dateSent) <= TimeUnit.MINUTES.toMillis(3) + return abs(currentMessage.dateSent - previousMessage.dateSent) <= clusterTimeout.inWholeMilliseconds } enum class MessageShape { diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/items/V2ConversationItemViewHolder.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/items/V2ConversationItemViewHolder.kt index d559e1f5e7..7992dff6a1 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/items/V2ConversationItemViewHolder.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/items/V2ConversationItemViewHolder.kt @@ -97,9 +97,10 @@ class V2TextOnlyViewHolder>( } val shape = shapeDelegate.setMessageShape( - itemView.layoutDirection == View.LAYOUT_DIRECTION_LTR, - conversationMessage, - bindingAdapterPosition + isLtr = itemView.layoutDirection == View.LAYOUT_DIRECTION_LTR, + currentMessage = conversationMessage.messageRecord, + isGroupThread = conversationMessage.threadRecipient.isGroup, + adapterPosition = bindingAdapterPosition ) binding.conversationItemBody.setTextColor(themeDelegate.getBodyTextColor(conversationMessage)) diff --git a/app/src/test/java/org/thoughtcrime/securesms/database/FakeMessageRecords.kt b/app/src/testShared/org/thoughtcrime/securesms/database/FakeMessageRecords.kt similarity index 100% rename from app/src/test/java/org/thoughtcrime/securesms/database/FakeMessageRecords.kt rename to app/src/testShared/org/thoughtcrime/securesms/database/FakeMessageRecords.kt diff --git a/gradle/verification-metadata.xml b/gradle/verification-metadata.xml index fd54175dbd..f49fd98629 100644 --- a/gradle/verification-metadata.xml +++ b/gradle/verification-metadata.xml @@ -1843,6 +1843,11 @@ https://docs.gradle.org/current/userguide/dependency_verification.html + + + + + @@ -1961,6 +1966,14 @@ https://docs.gradle.org/current/userguide/dependency_verification.html + + + + + + + + @@ -1974,6 +1987,14 @@ https://docs.gradle.org/current/userguide/dependency_verification.html + + + + + + + + @@ -1987,6 +2008,14 @@ https://docs.gradle.org/current/userguide/dependency_verification.html + + + + + + + + @@ -2000,6 +2029,14 @@ https://docs.gradle.org/current/userguide/dependency_verification.html + + + + + + + + @@ -2013,11 +2050,24 @@ https://docs.gradle.org/current/userguide/dependency_verification.html + + + + + + + + + + + + + @@ -2026,6 +2076,14 @@ https://docs.gradle.org/current/userguide/dependency_verification.html + + + + + + + + @@ -2039,6 +2097,14 @@ https://docs.gradle.org/current/userguide/dependency_verification.html + + + + + + + + @@ -2052,6 +2118,14 @@ https://docs.gradle.org/current/userguide/dependency_verification.html + + + + + + + +