From 38aa3f1e963cd356b4c4eacc65a58a2eaca084ce Mon Sep 17 00:00:00 2001 From: Jorge Martin Espinosa Date: Thu, 27 Mar 2025 14:09:47 +0100 Subject: [PATCH] Improve touch indicators for the user info UI in the timeline (#4482) * For the user info in the timeline items, display the ripple effects according to the bounds and shape of the user avatar and display name * Fix ripple in other screens too --- .../components/TimelineItemEventRow.kt | 37 +++++++++++++------ .../receipt/TimelineItemReadReceiptView.kt | 4 +- .../messages/impl/MessagesViewTest.kt | 28 ++++++++++++-- .../shared/UserProfileHeaderSection.kt | 3 ++ .../matrix/ui/components/InviteSenderView.kt | 4 +- .../android/libraries/testtags/TestTags.kt | 3 +- 6 files changed, 59 insertions(+), 20 deletions(-) diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemEventRow.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemEventRow.kt index ae17f26898..1b0a4df559 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemEventRow.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemEventRow.kt @@ -23,8 +23,8 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size -import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.wrapContentHeight +import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider @@ -37,6 +37,7 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.platform.LocalViewConfiguration import androidx.compose.ui.platform.ViewConfiguration +import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.stringResource import androidx.compose.ui.semantics.clearAndSetSemantics import androidx.compose.ui.semantics.contentDescription @@ -93,6 +94,7 @@ import io.element.android.libraries.matrix.ui.messages.reply.eventId import io.element.android.libraries.matrix.ui.messages.sender.SenderName import io.element.android.libraries.matrix.ui.messages.sender.SenderNameMode import io.element.android.libraries.testtags.TestTags +import io.element.android.libraries.testtags.testTag import io.element.android.libraries.ui.strings.CommonStrings import io.element.android.wysiwyg.link.Link import kotlinx.coroutines.launch @@ -314,6 +316,7 @@ private fun TimelineItemEventRowContent( event.senderId, event.senderProfile, event.senderAvatar, + onUserDataClick, Modifier .constrainAs(sender) { top.linkTo(parent.top) @@ -321,13 +324,7 @@ private fun TimelineItemEventRowContent( start.linkTo(parent.start) } .padding(horizontal = 16.dp) - .zIndex(1f) - .clickable(onClick = onUserDataClick) - // This is redundant when using talkback - .clearAndSetSemantics { - invisibleToUser() - testTag = TestTags.timelineItemSenderInfo.value - } + .zIndex(1f), ) } @@ -425,13 +422,31 @@ private fun MessageSenderInformation( senderId: UserId, senderProfile: ProfileTimelineDetails, senderAvatar: AvatarData, + onClick: () -> Unit, modifier: Modifier = Modifier ) { val avatarColors = AvatarColorsProvider.provide(senderAvatar.id) - Row(modifier = modifier) { - Avatar(senderAvatar) - Spacer(modifier = Modifier.width(4.dp)) + Row( + modifier = modifier + // Add external clickable modifier with no indicator so the touch target is larger than just the display name + .clickable(onClick = onClick, enabled = true, interactionSource = remember { MutableInteractionSource() }, indication = null) + .clearAndSetSemantics { + invisibleToUser() + } + ) { + Avatar( + modifier = Modifier + .testTag(TestTags.timelineItemSenderAvatar) + .clip(CircleShape) + .clickable(onClick = onClick), + avatarData = senderAvatar, + ) SenderName( + modifier = Modifier + .testTag(TestTags.timelineItemSenderName) + .clip(RoundedCornerShape(6.dp)) + .clickable(onClick = onClick) + .padding(horizontal = 4.dp), senderId = senderId, senderProfile = senderProfile, senderNameMode = SenderNameMode.Timeline(avatarColors.foreground), diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/receipt/TimelineItemReadReceiptView.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/receipt/TimelineItemReadReceiptView.kt index 701dd93fc7..80d594d4d7 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/receipt/TimelineItemReadReceiptView.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/receipt/TimelineItemReadReceiptView.kt @@ -26,6 +26,7 @@ import androidx.compose.ui.res.pluralStringResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.semantics.clearAndSetSemantics import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.testTag import androidx.compose.ui.tooling.preview.PreviewParameter import androidx.compose.ui.unit.dp import androidx.compose.ui.zIndex @@ -42,7 +43,6 @@ import io.element.android.libraries.designsystem.theme.components.Icon import io.element.android.libraries.designsystem.theme.components.Text import io.element.android.libraries.matrix.api.timeline.item.event.LocalEventSendState import io.element.android.libraries.testtags.TestTags -import io.element.android.libraries.testtags.testTag import io.element.android.libraries.ui.strings.CommonPlurals import io.element.android.libraries.ui.strings.CommonStrings import kotlinx.collections.immutable.ImmutableList @@ -60,7 +60,6 @@ fun TimelineItemReadReceiptView( ReadReceiptsAvatars( receipts = state.receipts, modifier = Modifier - .testTag(TestTags.messageReadReceipts) .clip(RoundedCornerShape(4.dp)) .clickable { onReadReceiptsClick() @@ -135,6 +134,7 @@ private fun ReadReceiptsAvatars( Row( modifier = modifier .clearAndSetSemantics { + testTag = TestTags.messageReadReceipts.value contentDescription = receiptDescription }, horizontalArrangement = Arrangement.spacedBy(4.dp - avatarStrokeSize), diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/MessagesViewTest.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/MessagesViewTest.kt index 9f46637948..5080013d27 100644 --- a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/MessagesViewTest.kt +++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/MessagesViewTest.kt @@ -104,7 +104,7 @@ class MessagesViewTest { state = state, onRoomDetailsClick = callback, ) - rule.onNodeWithText(state.roomName.dataOrNull().orEmpty()).performClick() + rule.onNodeWithText(state.roomName.dataOrNull().orEmpty(), useUnmergedTree = true).performClick() } } @@ -206,6 +206,7 @@ class MessagesViewTest { } @Test + @Config(qualifiers = "h1024dp") fun `clicking on a read receipt list emits the expected Event`() { val eventsRecorder = EventsRecorder() val state = aMessagesState( @@ -229,7 +230,7 @@ class MessagesViewTest { rule.setMessagesView( state = state, ) - rule.onNodeWithTag(TestTags.messageReadReceipts.value).performClick() + rule.onNodeWithTag(TestTags.messageReadReceipts.value, useUnmergedTree = true).performClick() eventsRecorder.assertSingle(ReadReceiptBottomSheetEvents.EventSelected(timelineItem)) } @@ -309,7 +310,7 @@ class MessagesViewTest { @Test @Config(qualifiers = "h1024dp") - fun `clicking on the sender of an Event invoke expected callback`() { + fun `clicking on the avatar of the sender of an Event invoke expected callback`() { val eventsRecorder = EventsRecorder(expectEvents = false) val state = aMessagesState( eventSink = eventsRecorder @@ -322,7 +323,26 @@ class MessagesViewTest { state = state, onUserDataClick = callback, ) - rule.onNodeWithTag(TestTags.timelineItemSenderInfo.value).performClick() + rule.onNodeWithTag(TestTags.timelineItemSenderAvatar.value, useUnmergedTree = true).performClick() + } + } + + @Test + @Config(qualifiers = "h1024dp") + fun `clicking on the display name of the sender of an Event invoke expected callback`() { + val eventsRecorder = EventsRecorder(expectEvents = false) + val state = aMessagesState( + eventSink = eventsRecorder + ) + val timelineItem = state.timelineState.timelineItems.first() + ensureCalledOnceWithParam( + param = (timelineItem as TimelineItem.Event).senderId + ) { callback -> + rule.setMessagesView( + state = state, + onUserDataClick = callback, + ) + rule.onNodeWithTag(TestTags.timelineItemSenderName.value, useUnmergedTree = true).performClick() } } diff --git a/features/userprofile/shared/src/main/kotlin/io/element/android/features/userprofile/shared/UserProfileHeaderSection.kt b/features/userprofile/shared/src/main/kotlin/io/element/android/features/userprofile/shared/UserProfileHeaderSection.kt index 3df57b9eb0..2681d7bfdf 100644 --- a/features/userprofile/shared/src/main/kotlin/io/element/android/features/userprofile/shared/UserProfileHeaderSection.kt +++ b/features/userprofile/shared/src/main/kotlin/io/element/android/features/userprofile/shared/UserProfileHeaderSection.kt @@ -13,9 +13,11 @@ import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.CircleShape import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clipToBounds import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextAlign @@ -58,6 +60,7 @@ fun UserProfileHeaderSection( Avatar( avatarData = AvatarData(userId.value, userName, avatarUrl, AvatarSize.UserHeader), modifier = Modifier + .clip(CircleShape) .clickable(enabled = avatarUrl != null) { openAvatarPreview(avatarUrl!!) } .testTag(TestTags.memberDetailAvatar) ) diff --git a/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/components/InviteSenderView.kt b/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/components/InviteSenderView.kt index 008438305d..452f6ed5a2 100644 --- a/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/components/InviteSenderView.kt +++ b/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/components/InviteSenderView.kt @@ -34,8 +34,8 @@ fun InviteSenderView( modifier = modifier, ) { Box(modifier = Modifier.padding(vertical = 2.dp)) { - Avatar(avatarData = inviteSender.avatarData) - } + Avatar(avatarData = inviteSender.avatarData) + } Text( text = inviteSender.annotatedString(), style = ElementTheme.typography.fontBodyMdRegular, diff --git a/libraries/testtags/src/main/kotlin/io/element/android/libraries/testtags/TestTags.kt b/libraries/testtags/src/main/kotlin/io/element/android/libraries/testtags/TestTags.kt index 8ee03dd5ad..6363c6781b 100644 --- a/libraries/testtags/src/main/kotlin/io/element/android/libraries/testtags/TestTags.kt +++ b/libraries/testtags/src/main/kotlin/io/element/android/libraries/testtags/TestTags.kt @@ -105,7 +105,8 @@ object TestTags { /** * Timeline item. */ - val timelineItemSenderInfo = TestTag("timeline_item-sender_info") + val timelineItemSenderAvatar = TestTag("timeline_item-sender_avatar") + val timelineItemSenderName = TestTag("timeline_item-sender_name") /** * Search field.