diff --git a/.idea/vcs.xml b/.idea/vcs.xml index 4d760f2f2..e0146fae7 100644 --- a/.idea/vcs.xml +++ b/.idea/vcs.xml @@ -12,7 +12,6 @@ - \ No newline at end of file diff --git a/app/src/sharedTest/kotlin/com/android/messaging/ui/conversation/messages/ui/message/rendering/BaseConversationMessageRenderingTest.kt b/app/src/sharedTest/kotlin/com/android/messaging/ui/conversation/messages/ui/message/rendering/BaseConversationMessageRenderingTest.kt index d238dc2cf..b9742c3ec 100644 --- a/app/src/sharedTest/kotlin/com/android/messaging/ui/conversation/messages/ui/message/rendering/BaseConversationMessageRenderingTest.kt +++ b/app/src/sharedTest/kotlin/com/android/messaging/ui/conversation/messages/ui/message/rendering/BaseConversationMessageRenderingTest.kt @@ -38,6 +38,7 @@ internal abstract class BaseConversationMessageRenderingTest { protected val onExternalUriClick = mockk<(String) -> Unit>(relaxed = true) protected val onMessageClick = mockk<() -> Unit>(relaxed = true) protected val onMessageLongClick = mockk<() -> Unit>(relaxed = true) + protected val onPhoneNumberCopy = mockk<(String) -> Unit>(relaxed = true) protected val onResendClick = mockk<() -> Unit>(relaxed = true) protected val onSimSelectorClick = mockk<() -> Unit>(relaxed = true) @@ -67,6 +68,7 @@ internal abstract class BaseConversationMessageRenderingTest { onMessageAvatarClick = onAvatarClick, onMessageDownloadClick = onDownloadClick, onMessageLongClick = onMessageLongClick, + onPhoneNumberCopy = onPhoneNumberCopy, onMessageResendClick = onResendClick, onSimSelectorClick = onSimSelectorClick, ) diff --git a/app/src/test/kotlin/com/android/messaging/ui/common/components/participant/ParticipantQuickActionsPopupTest.kt b/app/src/test/kotlin/com/android/messaging/ui/common/components/participant/ParticipantQuickActionsPopupTest.kt new file mode 100644 index 000000000..973546401 --- /dev/null +++ b/app/src/test/kotlin/com/android/messaging/ui/common/components/participant/ParticipantQuickActionsPopupTest.kt @@ -0,0 +1,134 @@ +package com.android.messaging.ui.common.components.participant + +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Person +import androidx.compose.ui.semantics.SemanticsActions +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.compose.ui.test.onNodeWithContentDescription +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import androidx.compose.ui.test.performSemanticsAction +import com.android.common.test.helpers.targetContext +import com.android.messaging.R +import com.android.messaging.ui.core.AppTheme +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.persistentListOf +import org.junit.Assert.assertEquals +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +@RunWith(RobolectricTestRunner::class) +internal class ParticipantQuickActionsPopupTest { + + @get:Rule + val composeTestRule = createComposeRule() + + @Test + fun copyAreaClick_withOneTarget_copiesPhoneNumber() { + var copiedPhoneNumber: String? = null + setPopup( + targets = persistentListOf(target("Ada", "+1 555 0001")), + onPhoneNumberCopy = { copiedPhoneNumber = it }, + ) + + composeTestRule + .onNodeWithTag(PARTICIPANT_QUICK_ACTIONS_COPY_AREA_TEST_TAG) + .performClick() + + assertEquals("+1 555 0001", copiedPhoneNumber) + } + + @Test + fun copyAreaLongClick_withOneTarget_copiesPhoneNumber() { + var copiedPhoneNumber: String? = null + setPopup( + targets = persistentListOf(target("Ada", "+1 555 0001")), + onPhoneNumberCopy = { copiedPhoneNumber = it }, + ) + + composeTestRule + .onNodeWithTag(PARTICIPANT_QUICK_ACTIONS_COPY_AREA_TEST_TAG) + .performSemanticsAction(SemanticsActions.OnLongClick) + + assertEquals("+1 555 0001", copiedPhoneNumber) + } + + @Test + fun copyAreaClick_withMultipleTargets_showsChooserAndCopiesSelection() { + var copiedPhoneNumber: String? = null + setPopup( + targets = persistentListOf( + target("Ada", "+1 555 0001"), + target("Grace", "+1 555 0002"), + ), + onPhoneNumberCopy = { copiedPhoneNumber = it }, + ) + + composeTestRule + .onNodeWithTag(PARTICIPANT_QUICK_ACTIONS_COPY_AREA_TEST_TAG) + .performClick() + composeTestRule + .onNodeWithText("Grace") + .assertIsDisplayed() + .performClick() + + assertEquals("+1 555 0002", copiedPhoneNumber) + } + + @Test + fun actionButtonClick_doesNotCopyPhoneNumber() { + var messageClicks = 0 + var copyClicks = 0 + setPopup( + targets = persistentListOf(target("Ada", "+1 555 0001")), + onMessageClick = { messageClicks++ }, + onPhoneNumberCopy = { copyClicks++ }, + ) + + composeTestRule + .onNodeWithContentDescription( + targetContext.getString(R.string.action_send_message), + ) + .performClick() + + assertEquals(1, messageClicks) + assertEquals(0, copyClicks) + } + + private fun setPopup( + targets: ImmutableList, + onMessageClick: () -> Unit = {}, + onPhoneNumberCopy: (String) -> Unit, + ) { + composeTestRule.setContent { + AppTheme { + ParticipantQuickActionsPopup( + visible = true, + avatarUri = null, + displayName = "Conversation", + subtitle = null, + fallbackIcon = Icons.Default.Person, + fallbackLabel = "C", + onDismiss = {}, + onMessageClick = onMessageClick, + onCallClick = {}, + onContactClick = {}, + onInfoClick = {}, + colorSeedCode = null, + phoneNumberCopyTargets = targets, + onPhoneNumberCopy = onPhoneNumberCopy, + ) + } + } + composeTestRule.waitForIdle() + } + + private fun target(displayName: String, phoneNumber: String) = PhoneNumberCopyTarget( + displayName = displayName, + phoneNumber = phoneNumber, + ) +} diff --git a/app/src/test/kotlin/com/android/messaging/ui/conversation/messages/ui/message/rendering/ConversationMessageBubbleInteractionTest.kt b/app/src/test/kotlin/com/android/messaging/ui/conversation/messages/ui/message/rendering/ConversationMessageBubbleInteractionTest.kt index 0ecc46c6f..b06d0dd8b 100644 --- a/app/src/test/kotlin/com/android/messaging/ui/conversation/messages/ui/message/rendering/ConversationMessageBubbleInteractionTest.kt +++ b/app/src/test/kotlin/com/android/messaging/ui/conversation/messages/ui/message/rendering/ConversationMessageBubbleInteractionTest.kt @@ -1,9 +1,11 @@ package com.android.messaging.ui.conversation.messages.ui.message.rendering +import androidx.compose.ui.semantics.SemanticsActions import androidx.compose.ui.test.assertIsSelected import androidx.compose.ui.test.onNodeWithTag import androidx.compose.ui.test.onNodeWithText import androidx.compose.ui.test.performClick +import androidx.compose.ui.test.performSemanticsAction import com.android.messaging.ui.conversation.conversationMessageSelectionRowTestTag import com.android.messaging.ui.conversation.messages.model.message.ConversationMessageUiModel import com.android.messaging.ui.conversation.messages.model.message.MmsDownloadUiModel @@ -222,6 +224,54 @@ internal class ConversationMessageBubbleInteractionTest : } } + @Test + fun incomingSenderLongClick_copiesPhoneNumberWithoutSelectingMessage() { + setConversationMessageContent( + message = message( + status = ConversationMessageUiModel.Status.Incoming.Complete, + isIncoming = true, + senderDisplayName = "Nora", + senderNormalizedDestination = "+15550123", + ), + ) + + composeTestRule + .onNodeWithText(text = "Nora") + .performSemanticsAction(SemanticsActions.OnLongClick) + + composeTestRule.runOnIdle { + verify(exactly = 1) { + onPhoneNumberCopy.invoke("+15550123") + } + verify(exactly = 0) { + onMessageLongClick.invoke() + } + } + } + + @Test + fun incomingMessageBodyLongClick_keepsMessageSelectionBehavior() { + setConversationMessageContent( + message = message( + status = ConversationMessageUiModel.Status.Incoming.Complete, + isIncoming = true, + senderDisplayName = "Nora", + senderNormalizedDestination = "+15550123", + ), + ) + + longClickBubble() + + composeTestRule.runOnIdle { + verify(exactly = 1) { + onMessageLongClick.invoke() + } + verify(exactly = 0) { + onPhoneNumberCopy.invoke(any()) + } + } + } + @Test fun incomingAvatarClick_forwardsAvatarClick() { setConversationMessageContent( diff --git a/app/src/test/kotlin/com/android/messaging/ui/conversation/metadata/delegate/ConversationMetadataDelegateImplTest.kt b/app/src/test/kotlin/com/android/messaging/ui/conversation/metadata/delegate/ConversationMetadataDelegateImplTest.kt index ee63ef653..5ed0f08ec 100644 --- a/app/src/test/kotlin/com/android/messaging/ui/conversation/metadata/delegate/ConversationMetadataDelegateImplTest.kt +++ b/app/src/test/kotlin/com/android/messaging/ui/conversation/metadata/delegate/ConversationMetadataDelegateImplTest.kt @@ -4,6 +4,8 @@ import app.cash.turbine.test import com.android.messaging.data.blockedparticipants.repository.BlockedParticipantsRepository import com.android.messaging.data.conversation.model.metadata.ConversationComposerAvailability import com.android.messaging.data.conversation.model.metadata.ConversationMetadata +import com.android.messaging.data.conversation.model.recipient.ConversationRecipient +import com.android.messaging.data.conversation.repository.ConversationParticipantsRepository import com.android.messaging.data.conversation.repository.ConversationsRepository import com.android.messaging.domain.conversation.usecase.action.ConversationActionRequirementsResult import com.android.messaging.testutil.MainDispatcherRule @@ -15,6 +17,8 @@ import io.mockk.coVerify import io.mockk.every import io.mockk.mockk import io.mockk.verify +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.persistentListOf import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.cancel import kotlinx.coroutines.flow.MutableStateFlow @@ -130,6 +134,43 @@ class ConversationMetadataDelegateImplTest { } } + @Test + fun participants_areExposedAsPhoneNumberCopyTargets_andEmailIsExcluded() { + runTest(context = mainDispatcherRule.testDispatcher) { + val harness = createHarness(conversationId = "conversation-42") + + try { + harness.participantsFlow.value = persistentListOf( + ConversationRecipient( + id = "participant-1", + displayName = "Alice", + destination = "+15550001", + ), + ConversationRecipient( + id = "participant-2", + displayName = "Email only", + destination = "person@example.com", + ), + ) + harness.setPresentState(otherParticipantPhoneNumber = null) + advanceUntilIdle() + + val state = harness.delegate.state.value as ConversationMetadataUiState.Present + assertEquals( + persistentListOf( + ConversationMetadataUiState.PhoneNumberCopyTarget( + displayName = "Alice", + phoneNumber = "+15550001", + ), + ), + state.phoneNumberCopyTargets, + ) + } finally { + harness.cancel() + } + } + } + @Test fun onDeleteConversationClick_togglesConfirmationVisibility() { runTest(context = mainDispatcherRule.testDispatcher) { @@ -241,6 +282,12 @@ class ConversationMetadataDelegateImplTest { val dispatcher = mainDispatcherRule.testDispatcher val scope = TestScope(dispatcher) val conversationsRepository = mockk(relaxed = true) + val participantsFlow = MutableStateFlow>( + persistentListOf(), + ) + val conversationParticipantsRepository = mockk() { + every { getParticipants(any()) } returns participantsFlow + } val mapper = mockk() val conversationIdFlow = MutableStateFlow(conversationId) val metadataFlow = MutableStateFlow(value = null) @@ -275,6 +322,7 @@ class ConversationMetadataDelegateImplTest { ConversationActionRequirementsResult.Ready }, conversationsRepository = conversationsRepository, + conversationParticipantsRepository = conversationParticipantsRepository, conversationMetadataUiStateMapper = mapper, blockedParticipantsRepository = mockk(relaxed = true), defaultDispatcher = dispatcher, @@ -288,6 +336,7 @@ class ConversationMetadataDelegateImplTest { delegate = delegate, conversationsRepository = conversationsRepository, metadataFlow = metadataFlow, + participantsFlow = participantsFlow, scope = scope, ) } @@ -304,6 +353,7 @@ class ConversationMetadataDelegateImplTest { val delegate: ConversationMetadataDelegateImpl, val conversationsRepository: ConversationsRepository, val metadataFlow: MutableStateFlow, + val participantsFlow: MutableStateFlow>, val scope: TestScope, ) { fun cancel() { diff --git a/app/src/test/kotlin/com/android/messaging/ui/conversation/metadata/ui/ConversationTopAppBarTest.kt b/app/src/test/kotlin/com/android/messaging/ui/conversation/metadata/ui/ConversationTopAppBarTest.kt index 5286c997d..8ca4fe3e2 100644 --- a/app/src/test/kotlin/com/android/messaging/ui/conversation/metadata/ui/ConversationTopAppBarTest.kt +++ b/app/src/test/kotlin/com/android/messaging/ui/conversation/metadata/ui/ConversationTopAppBarTest.kt @@ -1,11 +1,13 @@ package com.android.messaging.ui.conversation.metadata.ui +import androidx.compose.ui.semantics.SemanticsActions import androidx.compose.ui.test.assertIsDisplayed import androidx.compose.ui.test.junit4.v2.createComposeRule import androidx.compose.ui.test.onNodeWithContentDescription import androidx.compose.ui.test.onNodeWithTag import androidx.compose.ui.test.onNodeWithText import androidx.compose.ui.test.performClick +import androidx.compose.ui.test.performSemanticsAction import com.android.common.test.helpers.targetContext import com.android.messaging.R import com.android.messaging.data.conversation.model.metadata.ConversationComposerAvailability @@ -20,6 +22,7 @@ import com.android.messaging.ui.conversation.CONVERSATION_TOP_APP_BAR_TITLE_TEST import com.android.messaging.ui.conversation.CONVERSATION_UNARCHIVE_BUTTON_TEST_TAG import com.android.messaging.ui.conversation.metadata.model.ConversationMetadataUiState import com.android.messaging.ui.core.AppTheme +import kotlinx.collections.immutable.persistentListOf import org.junit.Assert.assertEquals import org.junit.Rule import org.junit.Test @@ -65,6 +68,65 @@ class ConversationTopAppBarTest { } } + @Test + fun titleLongClick_withOnePhoneNumber_copiesItDirectly() { + var copiedPhoneNumber: String? = null + + setContent( + metadata = presentMetadata.copy( + phoneNumberCopyTargets = persistentListOf( + ConversationMetadataUiState.PhoneNumberCopyTarget( + displayName = "Carol", + phoneNumber = "+37254400024", + ), + ), + ), + onPhoneNumberCopy = { copiedPhoneNumber = it }, + ) + + composeTestRule + .onNodeWithTag(testTag = CONVERSATION_TOP_APP_BAR_TITLE_TEST_TAG) + .performSemanticsAction(SemanticsActions.OnLongClick) + + composeTestRule.runOnIdle { + assertEquals("+37254400024", copiedPhoneNumber) + } + } + + @Test + fun titleLongClick_withMultiplePhoneNumbers_showsChooserAndCopiesSelection() { + var copiedPhoneNumber: String? = null + + setContent( + metadata = presentMetadata.copy( + participantCount = 2, + phoneNumberCopyTargets = persistentListOf( + ConversationMetadataUiState.PhoneNumberCopyTarget( + displayName = "Alice", + phoneNumber = "+15550001", + ), + ConversationMetadataUiState.PhoneNumberCopyTarget( + displayName = "Bob", + phoneNumber = "+15550002", + ), + ), + ), + onPhoneNumberCopy = { copiedPhoneNumber = it }, + ) + + composeTestRule + .onNodeWithTag(testTag = CONVERSATION_TOP_APP_BAR_TITLE_TEST_TAG) + .performSemanticsAction(SemanticsActions.OnLongClick) + + composeTestRule.onNodeWithText("Alice").assertIsDisplayed() + composeTestRule.onNodeWithText("Bob").assertIsDisplayed().performClick() + + composeTestRule.runOnIdle { + assertEquals("+15550002", copiedPhoneNumber) + } + composeTestRule.onNodeWithText("Alice").assertDoesNotExist() + } + @Test fun title_whenTitleIsBlank_fallsBackToAppName() { val appName = targetContext.getString(R.string.app_name) @@ -330,6 +392,7 @@ class ConversationTopAppBarTest { onDeleteConversationClick: () -> Unit = {}, onShowSubjectFieldClick: () -> Unit = {}, onTitleClick: () -> Unit = {}, + onPhoneNumberCopy: (String) -> Unit = {}, onNavigateBack: () -> Unit = {}, ) { composeTestRule.setContent { @@ -351,6 +414,7 @@ class ConversationTopAppBarTest { onDeleteConversationClick = onDeleteConversationClick, onShowSubjectFieldClick = onShowSubjectFieldClick, onTitleClick = onTitleClick, + onPhoneNumberCopy = onPhoneNumberCopy, onNavigateBack = onNavigateBack, ) } diff --git a/app/src/test/kotlin/com/android/messaging/ui/conversationlist/chats/ConversationListViewModelTest.kt b/app/src/test/kotlin/com/android/messaging/ui/conversationlist/chats/ConversationListViewModelTest.kt index 337b05497..b1df4c28e 100644 --- a/app/src/test/kotlin/com/android/messaging/ui/conversationlist/chats/ConversationListViewModelTest.kt +++ b/app/src/test/kotlin/com/android/messaging/ui/conversationlist/chats/ConversationListViewModelTest.kt @@ -1,6 +1,8 @@ package com.android.messaging.ui.conversationlist.chats import app.cash.turbine.test +import com.android.messaging.data.conversation.model.recipient.ConversationRecipient +import com.android.messaging.data.conversation.repository.ConversationParticipantsRepository import com.android.messaging.data.conversationlist.model.ConversationListSnapshot import com.android.messaging.data.conversationlist.repository.ConversationListRepository import com.android.messaging.data.debug.DebugFeaturesProvider @@ -8,6 +10,7 @@ import com.android.messaging.testutil.MainDispatcherRule import com.android.messaging.ui.conversationlist.chats.mapper.ConversationListUiStateMapper import com.android.messaging.ui.conversationlist.chats.model.ConversationListAction as Action import com.android.messaging.ui.conversationlist.chats.model.ConversationListEffect as Effect +import com.android.messaging.ui.conversationlist.chats.model.ConversationListUiState as State import com.android.messaging.ui.conversationlist.conversationItem import com.android.messaging.ui.conversationlist.delegate.ConversationListActionsDelegate import com.android.messaging.ui.conversationlist.delegate.ConversationListOptimisticSnapshotDelegate @@ -24,6 +27,7 @@ import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.persistentListOf import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.test.advanceUntilIdle import kotlinx.coroutines.test.runTest import org.junit.Assert.assertEquals @@ -42,6 +46,7 @@ class ConversationListViewModelTest { private val actionsDelegate = mockk() private val optimisticSnapshotDelegate = mockk() private val debugFeaturesProvider = mockk() + private val conversationParticipantsRepository = mockk() private val snapshotFlow = MutableStateFlow(null) private val selectedIdsFlow = MutableStateFlow>(persistentListOf()) @@ -163,6 +168,44 @@ class ConversationListViewModelTest { } } + @Test + fun avatarQuickActionsOpened_loadsCopyablePhoneNumbers() = runTest( + context = mainDispatcherRule.testDispatcher, + ) { + snapshotFlow.value = snapshotOf(conversationItem("a")) + every { + uiStateMapper.map(any(), any(), any(), any()) + } returns State() + every { + conversationParticipantsRepository.getParticipants("a") + } returns flowOf( + persistentListOf( + ConversationRecipient( + id = "1", + displayName = "Ada", + destination = "+1 555 0001", + ), + ConversationRecipient( + id = "2", + displayName = "Email only", + destination = "ada@example.com", + ), + ), + ) + + val viewModel = createViewModel() + viewModel.uiState.test { + awaitItem() + viewModel.onAction(Action.AvatarQuickActionsOpened("a")) + + val targets = awaitItem().phoneNumberCopyTargets.getValue("a") + assertEquals(1, targets.size) + assertEquals("Ada", targets.single().displayName) + assertEquals("+1 555 0001", targets.single().phoneNumber) + cancelAndIgnoreRemainingEvents() + } + } + @Test fun archiveSnackbarDismissed_discardsArchivedItems() { val viewModel = createViewModel() @@ -244,6 +287,7 @@ class ConversationListViewModelTest { actionsDelegate = actionsDelegate, optimisticSnapshotDelegate = optimisticSnapshotDelegate, debugFeaturesProvider = debugFeaturesProvider, + conversationParticipantsRepository = conversationParticipantsRepository, ) } } diff --git a/src/com/android/messaging/ui/common/components/participant/ParticipantQuickActionsPopup.kt b/src/com/android/messaging/ui/common/components/participant/ParticipantQuickActionsPopup.kt index 66df5c06c..1eb70080b 100644 --- a/src/com/android/messaging/ui/common/components/participant/ParticipantQuickActionsPopup.kt +++ b/src/com/android/messaging/ui/common/components/participant/ParticipantQuickActionsPopup.kt @@ -10,6 +10,7 @@ import androidx.compose.animation.fadeOut import androidx.compose.animation.scaleIn import androidx.compose.animation.scaleOut import androidx.compose.foundation.background +import androidx.compose.foundation.combinedClickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -28,13 +29,17 @@ import androidx.compose.material.icons.filled.Call import androidx.compose.material.icons.filled.Info import androidx.compose.material.icons.filled.Person import androidx.compose.material.icons.filled.PersonAdd +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Brush @@ -42,8 +47,12 @@ import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.RectangleShape import androidx.compose.ui.graphics.TransformOrigin import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.hapticfeedback.HapticFeedbackType import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.platform.LocalHapticFeedback +import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.Role import androidx.compose.ui.tooling.preview.PreviewLightDark import androidx.compose.ui.unit.dp import androidx.compose.ui.window.Popup @@ -52,6 +61,8 @@ import com.android.messaging.R import com.android.messaging.ui.common.components.AnchorRelativePositionProvider import com.android.messaging.ui.common.components.MarqueeText import com.android.messaging.ui.core.MessagingPreviewColumn +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.persistentListOf private val PopupWidth = 192.dp private val ActionRowHeight = 40.dp @@ -65,6 +76,9 @@ private val AvatarFallbackSize = 60.dp private val IconInsidePadding = 2.dp private val ShadowPadding = 16.dp +internal const val PARTICIPANT_QUICK_ACTIONS_COPY_AREA_TEST_TAG = + "participant-quick-actions-copy-area" + @Composable internal fun ParticipantQuickActionsPopup( visible: Boolean, @@ -80,6 +94,8 @@ internal fun ParticipantQuickActionsPopup( onInfoClick: (() -> Unit)?, colorSeedCode: String?, isContactSaved: Boolean = true, + phoneNumberCopyTargets: ImmutableList = persistentListOf(), + onPhoneNumberCopy: ((String) -> Unit)? = null, ) { val transitionState = remember { MutableTransitionState(false) } transitionState.targetState = visible @@ -136,6 +152,8 @@ internal fun ParticipantQuickActionsPopup( onContactClick = onContactClick, onInfoClick = onInfoClick, isContactSaved = isContactSaved, + phoneNumberCopyTargets = phoneNumberCopyTargets, + onPhoneNumberCopy = onPhoneNumberCopy, ) } } @@ -154,6 +172,8 @@ private fun QuickActionsCard( onContactClick: (() -> Unit)?, onInfoClick: (() -> Unit)?, isContactSaved: Boolean, + phoneNumberCopyTargets: ImmutableList, + onPhoneNumberCopy: ((String) -> Unit)?, ) { val cardShape = MaterialTheme.shapes.medium val cardColor = MaterialTheme.colorScheme.surfaceContainerHigh @@ -168,33 +188,38 @@ private fun QuickActionsCard( shadowElevation = 4.dp, ) { Column(modifier = Modifier.fillMaxWidth()) { - AvatarHeader( - avatarUri = avatarUri, - fallbackIcon = fallbackIcon, - fallbackLabel = fallbackLabel, - colorSeedCode = colorSeedCode, - fadeColor = cardColor, - ) - - Spacer(modifier = Modifier.height(ContentTopPadding)) + PhoneNumberCopyArea( + targets = phoneNumberCopyTargets, + onPhoneNumberCopy = onPhoneNumberCopy, + ) { + AvatarHeader( + avatarUri = avatarUri, + fallbackIcon = fallbackIcon, + fallbackLabel = fallbackLabel, + colorSeedCode = colorSeedCode, + fadeColor = cardColor, + ) - MarqueeText( - text = displayName, - style = MaterialTheme.typography.titleSmall, - color = MaterialTheme.colorScheme.onSurface, - fadeEdgeWidth = ActionRowPadding, - ) + Spacer(modifier = Modifier.height(ContentTopPadding)) - if (!subtitle.isNullOrBlank()) { MarqueeText( - text = subtitle, - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, + text = displayName, + style = MaterialTheme.typography.titleSmall, + color = MaterialTheme.colorScheme.onSurface, fadeEdgeWidth = ActionRowPadding, ) - } - Spacer(modifier = Modifier.height(AvatarSubtitleSpacing)) + if (!subtitle.isNullOrBlank()) { + MarqueeText( + text = subtitle, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + fadeEdgeWidth = ActionRowPadding, + ) + } + + Spacer(modifier = Modifier.height(AvatarSubtitleSpacing)) + } QuickActionsRow( onMessageClick = onMessageClick, @@ -209,6 +234,78 @@ private fun QuickActionsCard( } } +@Composable +private fun PhoneNumberCopyArea( + targets: ImmutableList, + onPhoneNumberCopy: ((String) -> Unit)?, + content: @Composable () -> Unit, +) { + var showTargetMenu by remember { mutableStateOf(false) } + val hapticFeedback = LocalHapticFeedback.current + val copyLabel = stringResource(R.string.copy_to_clipboard) + val canCopy = targets.isNotEmpty() && onPhoneNumberCopy != null + val performAction: () -> Unit = { + when (targets.size) { + 0 -> Unit + 1 -> onPhoneNumberCopy?.invoke(targets.single().phoneNumber) + else -> showTargetMenu = true + } + } + val interactionModifier = when { + !canCopy -> Modifier + else -> Modifier.combinedClickable( + role = Role.Button, + onClickLabel = copyLabel, + onClick = performAction, + onLongClickLabel = copyLabel, + onLongClick = { + hapticFeedback.performHapticFeedback(HapticFeedbackType.LongPress) + performAction() + }, + ) + } + + Box(modifier = Modifier.fillMaxWidth()) { + Column( + modifier = Modifier + .fillMaxWidth() + .testTag(PARTICIPANT_QUICK_ACTIONS_COPY_AREA_TEST_TAG) + .then(interactionModifier), + ) { + content() + } + + DropdownMenu( + expanded = showTargetMenu, + onDismissRequest = { showTargetMenu = false }, + ) { + targets.forEach { target -> + DropdownMenuItem( + text = { + Column { + Text( + text = target.displayName, + style = MaterialTheme.typography.bodyMedium, + ) + if (target.displayName != target.phoneNumber) { + Text( + text = target.phoneNumber, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + }, + onClick = { + showTargetMenu = false + onPhoneNumberCopy?.invoke(target.phoneNumber) + }, + ) + } + } + } +} + @Composable private fun AvatarHeader( avatarUri: String?, @@ -352,6 +449,13 @@ private fun ParticipantQuickActionsPopupPreview() { onContactClick = {}, onInfoClick = {}, isContactSaved = false, + phoneNumberCopyTargets = persistentListOf( + PhoneNumberCopyTarget( + displayName = "Best friend", + phoneNumber = "+1 555 000 0000000", + ), + ), + onPhoneNumberCopy = {}, ) } } diff --git a/src/com/android/messaging/ui/common/components/participant/PhoneNumberCopyTarget.kt b/src/com/android/messaging/ui/common/components/participant/PhoneNumberCopyTarget.kt new file mode 100644 index 000000000..1d10df867 --- /dev/null +++ b/src/com/android/messaging/ui/common/components/participant/PhoneNumberCopyTarget.kt @@ -0,0 +1,9 @@ +package com.android.messaging.ui.common.components.participant + +import androidx.compose.runtime.Immutable + +@Immutable +internal data class PhoneNumberCopyTarget( + val displayName: String, + val phoneNumber: String, +) diff --git a/src/com/android/messaging/ui/conversation/messages/ui/ConversationMessages.kt b/src/com/android/messaging/ui/conversation/messages/ui/ConversationMessages.kt index f8c12ecca..e8f4642b5 100644 --- a/src/com/android/messaging/ui/conversation/messages/ui/ConversationMessages.kt +++ b/src/com/android/messaging/ui/conversation/messages/ui/ConversationMessages.kt @@ -86,6 +86,7 @@ internal fun ConversationMessages( onMessageLongClick: (String) -> Unit, onMessageResendClick: (String) -> Unit, onSimSelectorClick: () -> Unit = {}, + onPhoneNumberCopy: (String) -> Unit = {}, ) { val configuration = LocalConfiguration.current val displayMessages = remember(messages) { @@ -134,6 +135,7 @@ internal fun ConversationMessages( onMessageDownloadClick = onMessageDownloadClick, onMessageLongClick = onMessageLongClick, onMessageResendClick = onMessageResendClick, + onPhoneNumberCopy = onPhoneNumberCopy, onSimSelectorClick = onSimSelectorClick, ) } @@ -154,6 +156,7 @@ private fun LazyListScope.conversationMessageItems( onMessageDownloadClick: (String) -> Unit, onMessageLongClick: (String) -> Unit, onMessageResendClick: (String) -> Unit, + onPhoneNumberCopy: (String) -> Unit, onSimSelectorClick: () -> Unit, ) { itemsIndexed( @@ -185,6 +188,7 @@ private fun LazyListScope.conversationMessageItems( onMessageDownloadClick = onMessageDownloadClick, onMessageLongClick = onMessageLongClick, onMessageResendClick = onMessageResendClick, + onPhoneNumberCopy = onPhoneNumberCopy, onSimSelectorClick = onSimSelectorClick, ) } @@ -306,6 +310,7 @@ private fun ConversationMessagesItem( onMessageDownloadClick: (String) -> Unit, onMessageLongClick: (String) -> Unit, onMessageResendClick: (String) -> Unit, + onPhoneNumberCopy: (String) -> Unit, onSimSelectorClick: () -> Unit, ) { val presentation = rememberConversationMessagesItemPresentation( @@ -353,6 +358,7 @@ private fun ConversationMessagesItem( onMessageResendClick = { onMessageResendClick(message.messageId) }, + onPhoneNumberCopy = onPhoneNumberCopy, onSimSelectorClick = onSimSelectorClick, ) } diff --git a/src/com/android/messaging/ui/conversation/messages/ui/message/ConversationMessage.kt b/src/com/android/messaging/ui/conversation/messages/ui/message/ConversationMessage.kt index 0819e9ab0..9325f0bb7 100644 --- a/src/com/android/messaging/ui/conversation/messages/ui/message/ConversationMessage.kt +++ b/src/com/android/messaging/ui/conversation/messages/ui/message/ConversationMessage.kt @@ -59,6 +59,7 @@ internal fun ConversationMessage( onMessageLongClick: () -> Unit = {}, onMessageResendClick: () -> Unit = {}, onSimSelectorClick: () -> Unit = {}, + onPhoneNumberCopy: (String) -> Unit = {}, ) { BoxWithConstraints( modifier = modifier @@ -103,6 +104,7 @@ internal fun ConversationMessage( onMessageDownloadClick = onMessageDownloadClick, onMessageLongClick = onMessageLongClick, onMessageResendClick = onMessageResendClick, + onPhoneNumberCopy = onPhoneNumberCopy, onSimSelectorClick = onSimSelectorClick, ) } @@ -289,6 +291,7 @@ private fun ConversationMessageContent( onMessageDownloadClick: () -> Unit, onMessageLongClick: () -> Unit, onMessageResendClick: () -> Unit, + onPhoneNumberCopy: (String) -> Unit, onSimSelectorClick: () -> Unit, ) { Column( @@ -308,6 +311,7 @@ private fun ConversationMessageContent( onMessageDownloadClick = onMessageDownloadClick, onMessageLongClick = onMessageLongClick, onMessageResendClick = onMessageResendClick, + onPhoneNumberCopy = onPhoneNumberCopy, ) ConversationMessageMetadataRow( diff --git a/src/com/android/messaging/ui/conversation/messages/ui/message/ConversationMessageAttachmentBubble.kt b/src/com/android/messaging/ui/conversation/messages/ui/message/ConversationMessageAttachmentBubble.kt index 6e33eebb3..0efd72827 100644 --- a/src/com/android/messaging/ui/conversation/messages/ui/message/ConversationMessageAttachmentBubble.kt +++ b/src/com/android/messaging/ui/conversation/messages/ui/message/ConversationMessageAttachmentBubble.kt @@ -16,6 +16,7 @@ import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp +import com.android.messaging.sms.MmsSmsUtils import com.android.messaging.ui.conversation.messages.model.message.ConversationMessageContent import com.android.messaging.ui.conversation.messages.model.message.ConversationMessageUiModel import com.android.messaging.ui.conversation.messages.ui.attachment.ConversationMessageAttachments @@ -36,6 +37,7 @@ internal fun ConversationMessageAttachmentOnlyBubble( onAttachmentClick: OnConversationAttachmentClick, onExternalUriClick: (String) -> Unit, onMessageLongClick: () -> Unit, + onPhoneNumberCopy: (String) -> Unit = {}, ) { ConversationMessageAttachmentOnlyContainer( modifier = modifier, @@ -52,6 +54,7 @@ internal fun ConversationMessageAttachmentOnlyBubble( onAttachmentClick = onAttachmentClick, onExternalUriClick = onExternalUriClick, onMessageLongClick = onMessageLongClick, + onPhoneNumberCopy = onPhoneNumberCopy, ) } } @@ -66,6 +69,7 @@ internal fun ConversationMessageAttachmentSurfaceBubble( onAttachmentClick: OnConversationAttachmentClick, onExternalUriClick: (String) -> Unit, onMessageLongClick: () -> Unit, + onPhoneNumberCopy: (String) -> Unit = {}, ) { ConversationMessageBubbleSurface( modifier = modifier, @@ -81,6 +85,7 @@ internal fun ConversationMessageAttachmentSurfaceBubble( onAttachmentClick = onAttachmentClick, onExternalUriClick = onExternalUriClick, onMessageLongClick = onMessageLongClick, + onPhoneNumberCopy = onPhoneNumberCopy, ) } } @@ -133,6 +138,7 @@ private fun ConversationMessageAttachmentBubbleContent( onAttachmentClick: OnConversationAttachmentClick, onExternalUriClick: (String) -> Unit, onMessageLongClick: () -> Unit, + onPhoneNumberCopy: (String) -> Unit = {}, ) { val content = layout.content val hasHeader = layout.showSender || !content.subjectText.isNullOrBlank() @@ -154,6 +160,10 @@ private fun ConversationMessageAttachmentBubbleContent( ), senderDisplayName = message.senderDisplayName, showSender = layout.showSender, + phoneNumber = message.senderNormalizedDestination + ?.takeUnless { isSelectionMode } + ?.takeIf(MmsSmsUtils::isPhoneNumber), + onPhoneNumberCopy = onPhoneNumberCopy, ) ConversationMessageSubject( diff --git a/src/com/android/messaging/ui/conversation/messages/ui/message/ConversationMessageBubble.kt b/src/com/android/messaging/ui/conversation/messages/ui/message/ConversationMessageBubble.kt index 384bf5292..fcdf94210 100644 --- a/src/com/android/messaging/ui/conversation/messages/ui/message/ConversationMessageBubble.kt +++ b/src/com/android/messaging/ui/conversation/messages/ui/message/ConversationMessageBubble.kt @@ -1,5 +1,6 @@ package com.android.messaging.ui.conversation.messages.ui.message +import androidx.compose.foundation.combinedClickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.padding @@ -11,10 +12,15 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color +import androidx.compose.ui.hapticfeedback.HapticFeedbackType +import androidx.compose.ui.platform.LocalHapticFeedback +import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.tooling.preview.PreviewLightDark import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp +import com.android.messaging.R +import com.android.messaging.sms.MmsSmsUtils import com.android.messaging.ui.conversation.messages.model.message.ConversationMessageContent import com.android.messaging.ui.conversation.messages.model.message.ConversationMessageUiModel import com.android.messaging.ui.conversation.messages.model.message.ConversationMessageUiModel.Status @@ -47,6 +53,7 @@ internal fun ConversationMessageBubble( onAttachmentClick: OnConversationAttachmentClick, onExternalUriClick: (String) -> Unit, onMessageLongClick: () -> Unit, + onPhoneNumberCopy: (String) -> Unit = {}, ) { val bubbleModifier = Modifier .widthIn(max = maxBubbleWidth) @@ -63,6 +70,7 @@ internal fun ConversationMessageBubble( onAttachmentClick = onAttachmentClick, onExternalUriClick = onExternalUriClick, onMessageLongClick = onMessageLongClick, + onPhoneNumberCopy = onPhoneNumberCopy, ) } @@ -76,6 +84,7 @@ internal fun ConversationMessageBubble( onAttachmentClick = onAttachmentClick, onExternalUriClick = onExternalUriClick, onMessageLongClick = onMessageLongClick, + onPhoneNumberCopy = onPhoneNumberCopy, ) } @@ -90,6 +99,7 @@ internal fun ConversationMessageBubble( onAttachmentClick = onAttachmentClick, onExternalUriClick = onExternalUriClick, onMessageLongClick = onMessageLongClick, + onPhoneNumberCopy = onPhoneNumberCopy, ) } } @@ -106,6 +116,7 @@ private fun ConversationMessageTextSurfaceBubble( onAttachmentClick: OnConversationAttachmentClick, onExternalUriClick: (String) -> Unit, onMessageLongClick: () -> Unit, + onPhoneNumberCopy: (String) -> Unit, ) { ConversationMessageBubbleSurface( modifier = modifier, @@ -122,6 +133,7 @@ private fun ConversationMessageTextSurfaceBubble( onAttachmentClick = onAttachmentClick, onExternalUriClick = onExternalUriClick, onMessageLongClick = onMessageLongClick, + onPhoneNumberCopy = onPhoneNumberCopy, ) } } @@ -166,6 +178,7 @@ private fun ConversationMessageTextBubbleContent( onAttachmentClick: OnConversationAttachmentClick, onExternalUriClick: (String) -> Unit, onMessageLongClick: () -> Unit, + onPhoneNumberCopy: (String) -> Unit, ) { Column( modifier = Modifier.padding( @@ -181,6 +194,10 @@ private fun ConversationMessageTextBubbleContent( ), senderDisplayName = message.senderDisplayName, showSender = layout.showSender, + phoneNumber = message.senderNormalizedDestination + ?.takeUnless { isSelectionMode } + ?.takeIf(MmsSmsUtils::isPhoneNumber), + onPhoneNumberCopy = onPhoneNumberCopy, ) when { @@ -268,13 +285,29 @@ internal fun ConversationMessageSender( color: Color, senderDisplayName: String?, showSender: Boolean, + phoneNumber: String? = null, + onPhoneNumberCopy: (String) -> Unit = {}, ) { if (!showSender || senderDisplayName == null) { return } + val hapticFeedback = LocalHapticFeedback.current + val copyLabel = stringResource(id = R.string.copy_to_clipboard) + val copyModifier = when (phoneNumber) { + null -> Modifier + else -> Modifier.combinedClickable( + onClick = {}, + onLongClickLabel = copyLabel, + onLongClick = { + hapticFeedback.performHapticFeedback(HapticFeedbackType.LongPress) + onPhoneNumberCopy(phoneNumber) + }, + ) + } + Text( - modifier = modifier, + modifier = modifier.then(copyModifier), text = senderDisplayName, style = MaterialTheme.typography.labelMedium, color = color, diff --git a/src/com/android/messaging/ui/conversation/messages/ui/message/ConversationMessageRows.kt b/src/com/android/messaging/ui/conversation/messages/ui/message/ConversationMessageRows.kt index 9b2419a36..9f7236f61 100644 --- a/src/com/android/messaging/ui/conversation/messages/ui/message/ConversationMessageRows.kt +++ b/src/com/android/messaging/ui/conversation/messages/ui/message/ConversationMessageRows.kt @@ -56,6 +56,7 @@ internal fun ConversationMessageBubbleRow( onMessageDownloadClick: () -> Unit, onMessageLongClick: () -> Unit, onMessageResendClick: () -> Unit, + onPhoneNumberCopy: (String) -> Unit = {}, ) { ConversationMessageBubbleRowContainer( message = message, @@ -98,6 +99,7 @@ internal fun ConversationMessageBubbleRow( } }, onMessageLongClick = onMessageLongClick, + onPhoneNumberCopy = onPhoneNumberCopy, ) } } diff --git a/src/com/android/messaging/ui/conversation/metadata/delegate/ConversationMetadataDelegate.kt b/src/com/android/messaging/ui/conversation/metadata/delegate/ConversationMetadataDelegate.kt index 9d5ad4b0a..a4a08be16 100644 --- a/src/com/android/messaging/ui/conversation/metadata/delegate/ConversationMetadataDelegate.kt +++ b/src/com/android/messaging/ui/conversation/metadata/delegate/ConversationMetadataDelegate.kt @@ -3,15 +3,21 @@ package com.android.messaging.ui.conversation.metadata.delegate import com.android.messaging.R import com.android.messaging.data.blockedparticipants.repository.BlockedParticipantsRepository import com.android.messaging.data.conversation.model.metadata.ConversationMetadata +import com.android.messaging.data.conversation.model.recipient.ConversationRecipient +import com.android.messaging.data.conversation.repository.ConversationParticipantsRepository import com.android.messaging.data.conversation.repository.ConversationsRepository import com.android.messaging.di.core.DefaultDispatcher import com.android.messaging.domain.conversation.usecase.action.CheckConversationActionRequirements import com.android.messaging.domain.conversation.usecase.action.ConversationActionRequirementsResult +import com.android.messaging.sms.MmsSmsUtils import com.android.messaging.ui.conversation.common.ConversationScreenDelegate import com.android.messaging.ui.conversation.metadata.mapper.ConversationMetadataUiStateMapper import com.android.messaging.ui.conversation.metadata.model.ConversationMetadataUiState import com.android.messaging.ui.conversation.screen.model.ConversationScreenEffect import javax.inject.Inject +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.persistentListOf +import kotlinx.collections.immutable.toImmutableList import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.flow.Flow @@ -21,9 +27,8 @@ import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asSharedFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.flowOn -import kotlinx.coroutines.flow.map -import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.launch internal interface ConversationMetadataDelegate : @@ -43,6 +48,7 @@ internal interface ConversationMetadataDelegate : internal class ConversationMetadataDelegateImpl @Inject constructor( private val checkConversationActionRequirements: CheckConversationActionRequirements, private val conversationsRepository: ConversationsRepository, + private val conversationParticipantsRepository: ConversationParticipantsRepository, private val conversationMetadataUiStateMapper: ConversationMetadataUiStateMapper, private val blockedParticipantsRepository: BlockedParticipantsRepository, @param:DefaultDispatcher @@ -87,17 +93,25 @@ internal class ConversationMetadataDelegateImpl @Inject constructor( return@collectLatest } - conversationsRepository - .getConversationMetadata(conversationId = conversationId) - .onEach { metadata -> latestMetadata = metadata } - .map { metadata -> - when { - metadata != null -> { - conversationMetadataUiStateMapper.map(metadata = metadata) - } - else -> ConversationMetadataUiState.Unavailable + combine( + conversationsRepository.getConversationMetadata( + conversationId = conversationId, + ), + conversationParticipantsRepository.getParticipants( + conversationId = conversationId, + ), + ) { metadata, participants -> + latestMetadata = metadata + + when { + metadata != null -> { + conversationMetadataUiStateMapper + .map(metadata = metadata) + .withPhoneNumberCopyTargets(participants = participants) } + else -> ConversationMetadataUiState.Unavailable } + } .flowOn(defaultDispatcher) .collect { currentMetadataState -> _state.value = currentMetadataState @@ -220,3 +234,44 @@ internal class ConversationMetadataDelegateImpl @Inject constructor( ?.takeIf { it.isNotBlank() } } } + +private fun ConversationMetadataUiState.withPhoneNumberCopyTargets( + participants: ImmutableList, +): ConversationMetadataUiState { + if (this !is ConversationMetadataUiState.Present) { + return this + } + + val participantTargets = participants + .mapNotNull { participant -> + val phoneNumber = participant.destination + .trim() + .takeIf { it.isNotBlank() } + ?.takeIf(MmsSmsUtils::isPhoneNumber) + ?: return@mapNotNull null + + ConversationMetadataUiState.PhoneNumberCopyTarget( + displayName = participant.displayName + .takeIf { it.isNotBlank() } + ?: phoneNumber, + phoneNumber = phoneNumber, + ) + } + .toImmutableList() + + val targets = when { + participantTargets.isNotEmpty() -> participantTargets + otherParticipantPhoneNumber != null -> { + persistentListOf( + ConversationMetadataUiState.PhoneNumberCopyTarget( + displayName = title.takeIf { it.isNotBlank() } + ?: otherParticipantPhoneNumber, + phoneNumber = otherParticipantPhoneNumber, + ), + ) + } + else -> participantTargets + } + + return copy(phoneNumberCopyTargets = targets) +} diff --git a/src/com/android/messaging/ui/conversation/metadata/model/ConversationMetadataUiState.kt b/src/com/android/messaging/ui/conversation/metadata/model/ConversationMetadataUiState.kt index 84fbb51a6..0538b9a03 100644 --- a/src/com/android/messaging/ui/conversation/metadata/model/ConversationMetadataUiState.kt +++ b/src/com/android/messaging/ui/conversation/metadata/model/ConversationMetadataUiState.kt @@ -3,6 +3,8 @@ package com.android.messaging.ui.conversation.metadata.model import androidx.compose.runtime.Immutable import com.android.messaging.data.conversation.model.metadata.ConversationComposerAvailability import com.android.messaging.data.conversation.model.metadata.ConversationComposerDisabledReason +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.persistentListOf @Immutable internal sealed interface ConversationMetadataUiState { @@ -40,8 +42,15 @@ internal sealed interface ConversationMetadataUiState { val isArchived: Boolean, val isBlocked: Boolean, override val composerAvailability: ConversationComposerAvailability, + val phoneNumberCopyTargets: ImmutableList = persistentListOf(), ) : ConversationMetadataUiState + @Immutable + data class PhoneNumberCopyTarget( + val displayName: String, + val phoneNumber: String, + ) + @Immutable data object Unavailable : ConversationMetadataUiState { override val composerAvailability = ConversationComposerAvailability.Unavailable( diff --git a/src/com/android/messaging/ui/conversation/metadata/ui/ConversationTopAppBar.kt b/src/com/android/messaging/ui/conversation/metadata/ui/ConversationTopAppBar.kt index d82eb9661..47dbced45 100644 --- a/src/com/android/messaging/ui/conversation/metadata/ui/ConversationTopAppBar.kt +++ b/src/com/android/messaging/ui/conversation/metadata/ui/ConversationTopAppBar.kt @@ -1,7 +1,8 @@ package com.android.messaging.ui.conversation.metadata.ui -import androidx.compose.foundation.clickable +import androidx.compose.foundation.combinedClickable import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxWidth @@ -38,6 +39,8 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.hapticfeedback.HapticFeedbackType +import androidx.compose.ui.platform.LocalHapticFeedback import androidx.compose.ui.platform.LocalResources import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.pluralStringResource @@ -72,6 +75,8 @@ import com.android.messaging.ui.core.MessagingPreviewColumn import com.android.messaging.ui.core.MessagingPreviewTheme import com.android.messaging.ui.subscription.mapper.resolveDisplayName import com.android.messaging.util.AccessibilityUtil +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.persistentListOf private val CONVERSATION_TOP_APP_BAR_TITLE_SPACING = 12.dp private val CONVERSATION_TOP_APP_BAR_AVATAR_SIZE = 36.dp @@ -100,6 +105,7 @@ internal fun ConversationTopAppBar( onSimSelectorClick: () -> Unit = {}, onTitleClick: () -> Unit, onNavigateBack: () -> Unit, + onPhoneNumberCopy: (String) -> Unit = {}, ) { val presentation = rememberConversationTopAppBarPresentation( metadata = metadata, @@ -128,6 +134,10 @@ internal fun ConversationTopAppBar( isClickable = isTitleClickable, onClick = onTitleClick, presentation = presentation, + phoneNumberCopyTargets = (metadata as? ConversationMetadataUiState.Present) + ?.phoneNumberCopyTargets + ?: persistentListOf(), + onPhoneNumberCopy = onPhoneNumberCopy, ) }, navigationIcon = { @@ -197,31 +207,101 @@ private fun ConversationTopAppBarTitle( isClickable: Boolean, onClick: () -> Unit, presentation: ConversationTopAppBarPresentation, + phoneNumberCopyTargets: ImmutableList, + onPhoneNumberCopy: (String) -> Unit, ) { - Row( - modifier = Modifier - .heightIn(min = TopAppBarDefaults.TopAppBarExpandedHeight) - .testTag(tag = CONVERSATION_TOP_APP_BAR_TITLE_TEST_TAG) - .clickable( - enabled = isClickable, - onClick = onClick, + var isPhoneNumberMenuExpanded by remember { mutableStateOf(value = false) } + val hapticFeedback = LocalHapticFeedback.current + val copyLabel = stringResource(id = R.string.copy_to_clipboard) + val onPhoneNumberAction: (() -> Unit)? = when (phoneNumberCopyTargets.size) { + 0 -> null + 1 -> { + { + onPhoneNumberCopy(phoneNumberCopyTargets.single().phoneNumber) + } + } + else -> { + { + isPhoneNumberMenuExpanded = true + } + } + } + val onLongClick = onPhoneNumberAction?.let { action -> + { + hapticFeedback.performHapticFeedback(HapticFeedbackType.LongPress) + action() + } + } + + Box { + Row( + modifier = Modifier + .heightIn(min = TopAppBarDefaults.TopAppBarExpandedHeight) + .testTag(tag = CONVERSATION_TOP_APP_BAR_TITLE_TEST_TAG) + .combinedClickable( + enabled = isClickable, + onClick = onClick, + onLongClickLabel = copyLabel.takeIf { onLongClick != null }, + onLongClick = onLongClick, + ), + horizontalArrangement = Arrangement.spacedBy( + space = CONVERSATION_TOP_APP_BAR_TITLE_SPACING, ), - horizontalArrangement = Arrangement.spacedBy( - space = CONVERSATION_TOP_APP_BAR_TITLE_SPACING, - ), - verticalAlignment = Alignment.CenterVertically, - ) { - ConversationAvatar( - avatar = presentation.avatar, - isBlocked = presentation.isBlocked, - ) + verticalAlignment = Alignment.CenterVertically, + ) { + ConversationAvatar( + avatar = presentation.avatar, + isBlocked = presentation.isBlocked, + ) - ConversationTopAppBarText( - presentation = presentation, + ConversationTopAppBarText( + presentation = presentation, + ) + } + + ConversationPhoneNumberCopyMenu( + expanded = isPhoneNumberMenuExpanded, + targets = phoneNumberCopyTargets, + onDismissRequest = { isPhoneNumberMenuExpanded = false }, + onPhoneNumberCopy = { phoneNumber -> + isPhoneNumberMenuExpanded = false + onPhoneNumberCopy(phoneNumber) + }, ) } } +@Composable +private fun ConversationPhoneNumberCopyMenu( + expanded: Boolean, + targets: ImmutableList, + onDismissRequest: () -> Unit, + onPhoneNumberCopy: (String) -> Unit, +) { + DropdownMenu( + expanded = expanded, + onDismissRequest = onDismissRequest, + ) { + targets.forEach { target -> + DropdownMenuItem( + text = { + Column { + Text(text = target.displayName) + if (target.displayName != target.phoneNumber) { + Text( + text = target.phoneNumber.asLtrText(), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + }, + onClick = { onPhoneNumberCopy(target.phoneNumber) }, + ) + } + } +} + @Composable private fun ConversationTopAppBarText( presentation: ConversationTopAppBarPresentation, diff --git a/src/com/android/messaging/ui/conversation/screen/ConversationScreen.kt b/src/com/android/messaging/ui/conversation/screen/ConversationScreen.kt index 20a99ee36..996419fc8 100644 --- a/src/com/android/messaging/ui/conversation/screen/ConversationScreen.kt +++ b/src/com/android/messaging/ui/conversation/screen/ConversationScreen.kt @@ -1,5 +1,7 @@ package com.android.messaging.ui.conversation.screen +import android.content.ClipData +import android.content.ClipboardManager import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.material3.Scaffold import androidx.compose.material3.SnackbarHostState @@ -10,6 +12,7 @@ import androidx.compose.runtime.remember import androidx.compose.ui.Modifier import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.geometry.Rect as ComposeRect +import androidx.compose.ui.platform.LocalContext import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.android.messaging.data.conversation.model.draft.ConversationDraft @@ -124,6 +127,15 @@ internal fun ConversationScreenScaffold( onLockedAudioRecordingStartRequest: () -> Unit, screenModel: ConversationScreenModel, ) { + val context = LocalContext.current + val clipboardManager = remember(context) { + context.getSystemService(ClipboardManager::class.java) + } + val onPhoneNumberCopy = remember(clipboardManager) { + { phoneNumber: String -> + clipboardManager.setPrimaryClip(ClipData.newPlainText(null, phoneNumber)) + } + } val isSimSelectorAvailable = uiState.composer.simSelector.isAvailable val simSheetState = rememberConversationSimSheetState(isAvailable = isSimSelectorAvailable) val showSimSelectorSheet = rememberShowSimSelectorSheetCallback( @@ -141,6 +153,7 @@ internal fun ConversationScreenScaffold( onConversationDetailsClick = onConversationDetailsClick, onNavigateBack = onNavigateBack, onSimSelectorClick = showSimSelectorSheet, + onPhoneNumberCopy = onPhoneNumberCopy, screenModel = screenModel, ) }, @@ -173,6 +186,7 @@ internal fun ConversationScreenScaffold( onMessageDownloadClick = screenModel::onMessageDownloadClick, onMessageLongClick = screenModel::onMessageLongClick, onMessageResendClick = screenModel::onMessageResendClick, + onPhoneNumberCopy = onPhoneNumberCopy, onSimSelectorClick = showSimSelectorSheet, onUnblockClick = screenModel::onUnblockClick, ) @@ -208,6 +222,7 @@ private fun ConversationScreenTopBar( onConversationDetailsClick: () -> Unit, onNavigateBack: () -> Unit, onSimSelectorClick: () -> Unit, + onPhoneNumberCopy: (String) -> Unit, screenModel: ConversationScreenModel, ) { when { @@ -239,6 +254,7 @@ private fun ConversationScreenTopBar( onShowSubjectFieldClick = screenModel::onShowSubjectFieldClick, onSimSelectorClick = onSimSelectorClick, onTitleClick = onConversationDetailsClick, + onPhoneNumberCopy = onPhoneNumberCopy, onNavigateBack = onNavigateBack, ) } diff --git a/src/com/android/messaging/ui/conversation/screen/ConversationScreenContent.kt b/src/com/android/messaging/ui/conversation/screen/ConversationScreenContent.kt index 208c22b62..7bbc4a80c 100644 --- a/src/com/android/messaging/ui/conversation/screen/ConversationScreenContent.kt +++ b/src/com/android/messaging/ui/conversation/screen/ConversationScreenContent.kt @@ -66,6 +66,7 @@ internal fun ConversationScreenContent( onMessageDownloadClick: (String) -> Unit, onMessageLongClick: (String) -> Unit, onMessageResendClick: (String) -> Unit, + onPhoneNumberCopy: (String) -> Unit = {}, onSimSelectorClick: () -> Unit, onUnblockClick: () -> Unit, ) { @@ -113,6 +114,7 @@ internal fun ConversationScreenContent( onMessageDownloadClick = onMessageDownloadClick, onMessageLongClick = onMessageLongClick, onMessageResendClick = onMessageResendClick, + onPhoneNumberCopy = onPhoneNumberCopy, onSimSelectorClick = onSimSelectorClick, additionalTopContentPadding = messagesTopReservation, ) @@ -174,6 +176,7 @@ private fun ConversationScreenPresentContent( onMessageDownloadClick: (String) -> Unit, onMessageLongClick: (String) -> Unit, onMessageResendClick: (String) -> Unit, + onPhoneNumberCopy: (String) -> Unit, onSimSelectorClick: () -> Unit, additionalTopContentPadding: Dp, ) { @@ -229,6 +232,7 @@ private fun ConversationScreenPresentContent( onMessageDownloadClick = onMessageDownloadClick, onMessageLongClick = onMessageLongClick, onMessageResendClick = onMessageResendClick, + onPhoneNumberCopy = onPhoneNumberCopy, onSimSelectorClick = onSimSelectorClick, ) } diff --git a/src/com/android/messaging/ui/conversationlist/chats/ConversationListContent.kt b/src/com/android/messaging/ui/conversationlist/chats/ConversationListContent.kt index 47889095e..6ba15ab86 100644 --- a/src/com/android/messaging/ui/conversationlist/chats/ConversationListContent.kt +++ b/src/com/android/messaging/ui/conversationlist/chats/ConversationListContent.kt @@ -15,6 +15,7 @@ import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import com.android.messaging.R import com.android.messaging.ui.common.components.PrimaryActionButton +import com.android.messaging.ui.common.components.participant.PhoneNumberCopyTarget import com.android.messaging.ui.common.components.reorder.OverlayReorderAnimationController import com.android.messaging.ui.conversationlist.chats.model.ConversationListAction as Action import com.android.messaging.ui.conversationlist.common.item.ConversationSwipeKind @@ -28,6 +29,9 @@ import com.android.messaging.ui.conversationlist.common.support.previewConversat import com.android.messaging.ui.conversationlist.model.ConversationListContentUiState import com.android.messaging.ui.conversationlist.model.ConversationListItemUiModel import com.android.messaging.ui.core.MessagingPreviewTheme +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.ImmutableMap +import kotlinx.collections.immutable.persistentMapOf private val ChatSwipeSpec = ConversationListSwipeSpec( startToEnd = ConversationSwipeKind.ToggleRead, @@ -43,6 +47,8 @@ internal fun ConversationListContent( isSelectionMode: Boolean, fabBottomReserve: Dp, pinAnimationController: OverlayReorderAnimationController?, + phoneNumberCopyTargets: ImmutableMap> = + persistentMapOf(), modifier: Modifier = Modifier, ) { Box(modifier = modifier.fillMaxSize()) { @@ -79,6 +85,10 @@ internal fun ConversationListContent( scaffoldContentPadding = scaffoldContentPadding, fabBottomReserve = fabBottomReserve, pinAnimationController = pinAnimationController, + phoneNumberCopyTargets = phoneNumberCopyTargets, + onAvatarQuickActionsOpen = { conversationId -> + onAction(Action.AvatarQuickActionsOpened(conversationId)) + }, swipeSpec = ChatSwipeSpec, onItemEvent = { onAction(it.toChatAction()) }, ) diff --git a/src/com/android/messaging/ui/conversationlist/chats/ConversationListScreen.kt b/src/com/android/messaging/ui/conversationlist/chats/ConversationListScreen.kt index 95d660980..b65ce5f7c 100644 --- a/src/com/android/messaging/ui/conversationlist/chats/ConversationListScreen.kt +++ b/src/com/android/messaging/ui/conversationlist/chats/ConversationListScreen.kt @@ -408,6 +408,7 @@ private fun ConversationListScaffold( isSelectionMode = isSelectionMode, fabBottomReserve = FabBottomReserve, pinAnimationController = pinAnimationController, + phoneNumberCopyTargets = uiState.phoneNumberCopyTargets, ) ConversationListFabs( diff --git a/src/com/android/messaging/ui/conversationlist/chats/ConversationListViewModel.kt b/src/com/android/messaging/ui/conversationlist/chats/ConversationListViewModel.kt index f720fc2f1..0558237c0 100644 --- a/src/com/android/messaging/ui/conversationlist/chats/ConversationListViewModel.kt +++ b/src/com/android/messaging/ui/conversationlist/chats/ConversationListViewModel.kt @@ -2,12 +2,15 @@ package com.android.messaging.ui.conversationlist.chats import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope +import com.android.messaging.data.conversation.repository.ConversationParticipantsRepository import com.android.messaging.data.conversationlist.model.ConversationListItem import com.android.messaging.data.conversationlist.model.ConversationListMode import com.android.messaging.data.conversationlist.model.ConversationListSnapshot import com.android.messaging.data.conversationlist.repository.ConversationListRepository import com.android.messaging.data.conversationsettings.model.SnoozeOption import com.android.messaging.data.debug.DebugFeaturesProvider +import com.android.messaging.sms.MmsSmsUtils +import com.android.messaging.ui.common.components.participant.PhoneNumberCopyTarget import com.android.messaging.ui.conversationlist.chats.mapper.ConversationListUiStateMapper import com.android.messaging.ui.conversationlist.chats.model.ConversationListAction as Action import com.android.messaging.ui.conversationlist.chats.model.ConversationListEffect as Effect @@ -18,6 +21,8 @@ import com.android.messaging.ui.conversationlist.delegate.ConversationListSelect import com.android.messaging.ui.conversationlist.model.ConversationListAvatarUiModel import dagger.hilt.android.lifecycle.HiltViewModel import javax.inject.Inject +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.persistentMapOf import kotlinx.collections.immutable.toImmutableList import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.flow.Flow @@ -26,6 +31,7 @@ import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.filterNotNull +import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.receiveAsFlow import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch @@ -45,11 +51,16 @@ internal class ConversationListViewModel @Inject constructor( private val actionsDelegate: ConversationListActionsDelegate, private val optimisticSnapshotDelegate: ConversationListOptimisticSnapshotDelegate, private val debugFeaturesProvider: DebugFeaturesProvider, + private val conversationParticipantsRepository: ConversationParticipantsRepository, ) : ViewModel(), ConversationListScreenModel { private val isScrollToTopVisible = MutableStateFlow(false) private val isDebugEnabled = MutableStateFlow(debugFeaturesProvider.isEnabled()) + private val phoneNumberCopyTargets = MutableStateFlow( + persistentMapOf>(), + ) + private val loadingParticipantConversationIds = mutableSetOf() private val snapshot: StateFlow = optimisticSnapshotDelegate.snapshot @@ -61,13 +72,14 @@ internal class ConversationListViewModel @Inject constructor( selectionDelegate.selectedIds, isScrollToTopVisible, isDebugEnabled, - ) { snapshot, selectedIds, isScrollToTopVisible, isDebugEnabled -> + phoneNumberCopyTargets, + ) { snapshot, selectedIds, isScrollToTopVisible, isDebugEnabled, copyTargets -> uiStateMapper.map( snapshot = snapshot, selectedConversationIds = selectedIds, isScrollToTopVisible = isScrollToTopVisible, isDebugEnabled = isDebugEnabled, - ) + ).copy(phoneNumberCopyTargets = copyTargets) }.stateIn( scope = viewModelScope, started = SharingStarted.WhileSubscribed( @@ -202,6 +214,10 @@ internal class ConversationListViewModel @Inject constructor( _effects.trySend(Effect.OpenConversation(action.conversationId)) } + is Action.AvatarQuickActionsOpened -> { + loadPhoneNumberCopyTargets(action.conversationId) + } + is Action.AvatarCallClicked -> { _effects.trySend(Effect.PlaceCall(action.destination)) } @@ -236,6 +252,47 @@ internal class ConversationListViewModel @Inject constructor( } } + private fun loadPhoneNumberCopyTargets(conversationId: String) { + if ( + phoneNumberCopyTargets.value.containsKey(conversationId) || + !loadingParticipantConversationIds.add(conversationId) + ) { + return + } + + viewModelScope.launch { + try { + val participants = conversationParticipantsRepository + .getParticipants(conversationId = conversationId) + .first() + val targets = participants + .mapNotNull { participant -> + val phoneNumber = participant.destination + .trim() + .takeIf(String::isNotBlank) + ?.takeIf(MmsSmsUtils::isPhoneNumber) + ?: return@mapNotNull null + + PhoneNumberCopyTarget( + displayName = participant.displayName + .takeIf(String::isNotBlank) + ?: phoneNumber, + phoneNumber = phoneNumber, + ) + } + .distinctBy(PhoneNumberCopyTarget::phoneNumber) + .toImmutableList() + + phoneNumberCopyTargets.value = phoneNumberCopyTargets.value.put( + key = conversationId, + value = targets, + ) + } finally { + loadingParticipantConversationIds.remove(conversationId) + } + } + } + private fun onConversationClick(conversationId: String) { when { currentSelectedItems().isNotEmpty() -> { diff --git a/src/com/android/messaging/ui/conversationlist/chats/model/ConversationListAction.kt b/src/com/android/messaging/ui/conversationlist/chats/model/ConversationListAction.kt index 24739678c..2ef16853d 100644 --- a/src/com/android/messaging/ui/conversationlist/chats/model/ConversationListAction.kt +++ b/src/com/android/messaging/ui/conversationlist/chats/model/ConversationListAction.kt @@ -64,6 +64,10 @@ internal sealed interface ConversationListAction { val conversationId: String, ) : ListAction + data class AvatarQuickActionsOpened( + val conversationId: String, + ) : ListAction + data class AvatarCallClicked( val destination: String, ) : ListAction diff --git a/src/com/android/messaging/ui/conversationlist/chats/model/ConversationListUiState.kt b/src/com/android/messaging/ui/conversationlist/chats/model/ConversationListUiState.kt index ed3e57090..342e46592 100644 --- a/src/com/android/messaging/ui/conversationlist/chats/model/ConversationListUiState.kt +++ b/src/com/android/messaging/ui/conversationlist/chats/model/ConversationListUiState.kt @@ -1,7 +1,11 @@ package com.android.messaging.ui.conversationlist.chats.model import androidx.compose.runtime.Immutable +import com.android.messaging.ui.common.components.participant.PhoneNumberCopyTarget import com.android.messaging.ui.conversationlist.model.ConversationListContentUiState +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.ImmutableMap +import kotlinx.collections.immutable.persistentMapOf @Immutable internal data class ConversationListUiState( @@ -10,4 +14,6 @@ internal data class ConversationListUiState( val isScrollToTopVisible: Boolean = false, val hasBlockedParticipants: Boolean = false, val isDebugEnabled: Boolean = false, + val phoneNumberCopyTargets: ImmutableMap> = + persistentMapOf(), ) diff --git a/src/com/android/messaging/ui/conversationlist/common/item/ConversationListItemAvatar.kt b/src/com/android/messaging/ui/conversationlist/common/item/ConversationListItemAvatar.kt index 19906cb6f..56026a2ab 100644 --- a/src/com/android/messaging/ui/conversationlist/common/item/ConversationListItemAvatar.kt +++ b/src/com/android/messaging/ui/conversationlist/common/item/ConversationListItemAvatar.kt @@ -1,5 +1,7 @@ package com.android.messaging.ui.conversationlist.common.item +import android.content.ClipData +import android.content.ClipboardManager import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.size @@ -15,6 +17,7 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource import androidx.compose.ui.semantics.Role import androidx.compose.ui.semantics.clearAndSetSemantics @@ -22,17 +25,23 @@ import androidx.compose.ui.semantics.contentDescription import androidx.compose.ui.semantics.onClick import androidx.compose.ui.semantics.role import com.android.messaging.R +import com.android.messaging.sms.MmsSmsUtils import com.android.messaging.ui.common.components.participant.ParticipantQuickActionsPopup +import com.android.messaging.ui.common.components.participant.PhoneNumberCopyTarget import com.android.messaging.ui.common.components.participant.participantAvatarLabel import com.android.messaging.ui.common.components.participant.participantColorSeed import com.android.messaging.ui.common.components.selection.SelectionListAvatar import com.android.messaging.ui.conversationlist.model.ConversationListItemUiModel +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.persistentListOf @Composable internal fun ConversationListItemAvatar( item: ConversationListItemUiModel, isSelectionMode: Boolean, onToggleSelection: () -> Unit, + phoneNumberCopyTargets: ImmutableList, + onQuickActionsOpen: () -> Unit, onMessageClick: () -> Unit, onCallClick: (() -> Unit)?, onContactClick: (() -> Unit)?, @@ -56,7 +65,10 @@ internal fun ConversationListItemAvatar( val onAvatarClick = { when { isSelectionMode -> onToggleSelection() - else -> showQuickActions = true + else -> { + onQuickActionsOpen() + showQuickActions = true + } } } val avatarContentDescription = stringResource( @@ -66,6 +78,22 @@ internal fun ConversationListItemAvatar( }, item.title.orEmpty(), ) + val fallbackCopyTarget = item.avatar.normalizedDestination + ?.takeIf(String::isNotBlank) + ?.takeIf(MmsSmsUtils::isPhoneNumber) + ?.let { phoneNumber -> + PhoneNumberCopyTarget( + displayName = item.title?.takeIf(String::isNotBlank) ?: phoneNumber, + phoneNumber = phoneNumber, + ) + } + val resolvedCopyTargets = phoneNumberCopyTargets.takeIf { it.isNotEmpty() } + ?: fallbackCopyTarget?.let { persistentListOf(it) } + ?: persistentListOf() + val context = LocalContext.current + val clipboardManager = remember(context) { + context.getSystemService(ClipboardManager::class.java) + } Box(modifier = Modifier.size(ItemAvatarSize)) { SelectionListAvatar( @@ -89,6 +117,12 @@ internal fun ConversationListItemAvatar( fallbackIcon = fallbackIcon, fallbackLabel = fallbackLabel, colorSeedCode = colorSeedCode, + phoneNumberCopyTargets = resolvedCopyTargets, + onPhoneNumberCopy = { phoneNumber -> + clipboardManager?.setPrimaryClip( + ClipData.newPlainText(null, phoneNumber), + ) + }, onDismiss = { showQuickActions = false }, onMessageClick = onMessageClick, onCallClick = onCallClick, @@ -120,6 +154,8 @@ private fun ConversationListAvatarQuickActions( fallbackIcon: ImageVector, fallbackLabel: String?, colorSeedCode: String?, + phoneNumberCopyTargets: ImmutableList, + onPhoneNumberCopy: (String) -> Unit, onDismiss: () -> Unit, onMessageClick: () -> Unit, onCallClick: (() -> Unit)?, @@ -152,5 +188,10 @@ private fun ConversationListAvatarQuickActions( onDismiss() }, isContactSaved = item.avatar.isContactSaved, + phoneNumberCopyTargets = phoneNumberCopyTargets, + onPhoneNumberCopy = { phoneNumber -> + onPhoneNumberCopy(phoneNumber) + onDismiss() + }, ) } diff --git a/src/com/android/messaging/ui/conversationlist/common/item/ConversationListItemRow.kt b/src/com/android/messaging/ui/conversationlist/common/item/ConversationListItemRow.kt index aa0ae415a..6832cc8ee 100644 --- a/src/com/android/messaging/ui/conversationlist/common/item/ConversationListItemRow.kt +++ b/src/com/android/messaging/ui/conversationlist/common/item/ConversationListItemRow.kt @@ -32,12 +32,15 @@ import androidx.compose.ui.unit.dp import com.android.messaging.R import com.android.messaging.data.conversationlist.model.ConversationListMessageStatus import com.android.messaging.ui.common.components.TwoLineListItem +import com.android.messaging.ui.common.components.participant.PhoneNumberCopyTarget import com.android.messaging.ui.conversationlist.common.support.conversationListItemTestTag import com.android.messaging.ui.conversationlist.common.support.previewConversationListItem import com.android.messaging.ui.conversationlist.model.ConversationListItemUiModel import com.android.messaging.ui.conversationlist.model.ConversationListPreviewUiModel import com.android.messaging.ui.core.MessagingPreviewColumn import com.android.messaging.util.Dates +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.persistentListOf @Composable internal fun ConversationListItemRow( @@ -46,6 +49,8 @@ internal fun ConversationListItemRow( onLongClick: () -> Unit, modifier: Modifier = Modifier, isSelectionMode: Boolean = false, + phoneNumberCopyTargets: ImmutableList = persistentListOf(), + onAvatarQuickActionsOpen: () -> Unit = {}, onAvatarMessageClick: () -> Unit = {}, onAvatarCallClick: (() -> Unit)? = null, onAvatarContactClick: (() -> Unit)? = null, @@ -58,6 +63,8 @@ internal fun ConversationListItemRow( item = item, isSelectionMode = isSelectionMode, onToggleSelection = onClick, + phoneNumberCopyTargets = phoneNumberCopyTargets, + onQuickActionsOpen = onAvatarQuickActionsOpen, onMessageClick = onAvatarMessageClick, onCallClick = onAvatarCallClick, onContactClick = onAvatarContactClick, diff --git a/src/com/android/messaging/ui/conversationlist/common/list/ConversationListItems.kt b/src/com/android/messaging/ui/conversationlist/common/list/ConversationListItems.kt index c8d8850d3..e10921681 100644 --- a/src/com/android/messaging/ui/conversationlist/common/list/ConversationListItems.kt +++ b/src/com/android/messaging/ui/conversationlist/common/list/ConversationListItems.kt @@ -26,6 +26,7 @@ import androidx.compose.ui.unit.IntOffset import androidx.compose.ui.unit.dp import androidx.compose.ui.zIndex import com.android.messaging.ui.common.components.horizontalSafeDrawingInsets +import com.android.messaging.ui.common.components.participant.PhoneNumberCopyTarget import com.android.messaging.ui.common.components.reorder.OverlayReorderAnimationController import com.android.messaging.ui.conversationlist.common.item.ConversationListItemRow import com.android.messaging.ui.conversationlist.common.item.ConversationSwipeAction @@ -36,7 +37,10 @@ import com.android.messaging.ui.conversationlist.common.support.CONVERSATION_LIS import com.android.messaging.ui.conversationlist.common.support.rememberAppearanceAnimationTokens import com.android.messaging.ui.conversationlist.model.ConversationListItemUiModel import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.ImmutableMap import kotlinx.collections.immutable.ImmutableSet +import kotlinx.collections.immutable.persistentListOf +import kotlinx.collections.immutable.persistentMapOf private const val CONVERSATION_ROW_CONTENT_TYPE = "conversation_row" @@ -61,6 +65,9 @@ internal fun ConversationListItems( scaffoldContentPadding: PaddingValues, fabBottomReserve: Dp, pinAnimationController: OverlayReorderAnimationController?, + phoneNumberCopyTargets: ImmutableMap> = + persistentMapOf(), + onAvatarQuickActionsOpen: (String) -> Unit = {}, swipeSpec: ConversationListSwipeSpec, onItemEvent: (ConversationListItemEvent) -> Unit, ) { @@ -118,6 +125,8 @@ internal fun ConversationListItems( } }, swipeSpec = swipeSpec, + phoneNumberCopyTargets = phoneNumberCopyTargets, + onAvatarQuickActionsOpen = onAvatarQuickActionsOpen, onItemEvent = onItemEvent, ) } @@ -134,6 +143,8 @@ private fun LazyItemScope.ConversationListRow( pinAnimationController: OverlayReorderAnimationController?, onAppearanceAnimationFinished: () -> Unit, swipeSpec: ConversationListSwipeSpec, + phoneNumberCopyTargets: ImmutableMap>, + onAvatarQuickActionsOpen: (String) -> Unit, onItemEvent: (ConversationListItemEvent) -> Unit, ) { val isHiddenByPinAnimation = pinAnimationController?.isItemHidden(item.conversationId) == true @@ -186,6 +197,9 @@ private fun LazyItemScope.ConversationListRow( item = item, isSelectionMode = isSelectionMode, horizontalInsets = horizontalInsets, + phoneNumberCopyTargets = phoneNumberCopyTargets[item.conversationId] + ?: persistentListOf(), + onAvatarQuickActionsOpen = onAvatarQuickActionsOpen, onItemEvent = onItemEvent, ) } @@ -196,6 +210,8 @@ private fun ConversationListItemContent( item: ConversationListItemUiModel, isSelectionMode: Boolean, horizontalInsets: PaddingValues, + phoneNumberCopyTargets: ImmutableList, + onAvatarQuickActionsOpen: (String) -> Unit, onItemEvent: (ConversationListItemEvent) -> Unit, ) { val destination = item.avatar.normalizedDestination @@ -210,6 +226,10 @@ private fun ConversationListItemContent( onItemEvent(ConversationListItemEvent.LongClicked(item.conversationId)) }, isSelectionMode = isSelectionMode, + phoneNumberCopyTargets = phoneNumberCopyTargets, + onAvatarQuickActionsOpen = { + onAvatarQuickActionsOpen(item.conversationId) + }, onAvatarMessageClick = { onItemEvent(ConversationListItemEvent.AvatarMessageClicked(item.conversationId)) },