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
This commit is contained in:
Jorge Martin Espinosa 2025-03-27 14:09:47 +01:00 committed by GitHub
parent a82f756c49
commit 38aa3f1e96
6 changed files with 59 additions and 20 deletions

View file

@ -23,8 +23,8 @@ import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.wrapContentHeight import androidx.compose.foundation.layout.wrapContentHeight
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.CompositionLocalProvider
@ -37,6 +37,7 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.platform.LocalViewConfiguration import androidx.compose.ui.platform.LocalViewConfiguration
import androidx.compose.ui.platform.ViewConfiguration import androidx.compose.ui.platform.ViewConfiguration
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.clearAndSetSemantics import androidx.compose.ui.semantics.clearAndSetSemantics
import androidx.compose.ui.semantics.contentDescription 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.SenderName
import io.element.android.libraries.matrix.ui.messages.sender.SenderNameMode import io.element.android.libraries.matrix.ui.messages.sender.SenderNameMode
import io.element.android.libraries.testtags.TestTags 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.libraries.ui.strings.CommonStrings
import io.element.android.wysiwyg.link.Link import io.element.android.wysiwyg.link.Link
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@ -314,6 +316,7 @@ private fun TimelineItemEventRowContent(
event.senderId, event.senderId,
event.senderProfile, event.senderProfile,
event.senderAvatar, event.senderAvatar,
onUserDataClick,
Modifier Modifier
.constrainAs(sender) { .constrainAs(sender) {
top.linkTo(parent.top) top.linkTo(parent.top)
@ -321,13 +324,7 @@ private fun TimelineItemEventRowContent(
start.linkTo(parent.start) start.linkTo(parent.start)
} }
.padding(horizontal = 16.dp) .padding(horizontal = 16.dp)
.zIndex(1f) .zIndex(1f),
.clickable(onClick = onUserDataClick)
// This is redundant when using talkback
.clearAndSetSemantics {
invisibleToUser()
testTag = TestTags.timelineItemSenderInfo.value
}
) )
} }
@ -425,13 +422,31 @@ private fun MessageSenderInformation(
senderId: UserId, senderId: UserId,
senderProfile: ProfileTimelineDetails, senderProfile: ProfileTimelineDetails,
senderAvatar: AvatarData, senderAvatar: AvatarData,
onClick: () -> Unit,
modifier: Modifier = Modifier modifier: Modifier = Modifier
) { ) {
val avatarColors = AvatarColorsProvider.provide(senderAvatar.id) val avatarColors = AvatarColorsProvider.provide(senderAvatar.id)
Row(modifier = modifier) { Row(
Avatar(senderAvatar) modifier = modifier
Spacer(modifier = Modifier.width(4.dp)) // 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( SenderName(
modifier = Modifier
.testTag(TestTags.timelineItemSenderName)
.clip(RoundedCornerShape(6.dp))
.clickable(onClick = onClick)
.padding(horizontal = 4.dp),
senderId = senderId, senderId = senderId,
senderProfile = senderProfile, senderProfile = senderProfile,
senderNameMode = SenderNameMode.Timeline(avatarColors.foreground), senderNameMode = SenderNameMode.Timeline(avatarColors.foreground),

View file

@ -26,6 +26,7 @@ import androidx.compose.ui.res.pluralStringResource
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.clearAndSetSemantics import androidx.compose.ui.semantics.clearAndSetSemantics
import androidx.compose.ui.semantics.contentDescription import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.testTag
import androidx.compose.ui.tooling.preview.PreviewParameter import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.zIndex 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.designsystem.theme.components.Text
import io.element.android.libraries.matrix.api.timeline.item.event.LocalEventSendState import io.element.android.libraries.matrix.api.timeline.item.event.LocalEventSendState
import io.element.android.libraries.testtags.TestTags 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.CommonPlurals
import io.element.android.libraries.ui.strings.CommonStrings import io.element.android.libraries.ui.strings.CommonStrings
import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.ImmutableList
@ -60,7 +60,6 @@ fun TimelineItemReadReceiptView(
ReadReceiptsAvatars( ReadReceiptsAvatars(
receipts = state.receipts, receipts = state.receipts,
modifier = Modifier modifier = Modifier
.testTag(TestTags.messageReadReceipts)
.clip(RoundedCornerShape(4.dp)) .clip(RoundedCornerShape(4.dp))
.clickable { .clickable {
onReadReceiptsClick() onReadReceiptsClick()
@ -135,6 +134,7 @@ private fun ReadReceiptsAvatars(
Row( Row(
modifier = modifier modifier = modifier
.clearAndSetSemantics { .clearAndSetSemantics {
testTag = TestTags.messageReadReceipts.value
contentDescription = receiptDescription contentDescription = receiptDescription
}, },
horizontalArrangement = Arrangement.spacedBy(4.dp - avatarStrokeSize), horizontalArrangement = Arrangement.spacedBy(4.dp - avatarStrokeSize),

View file

@ -104,7 +104,7 @@ class MessagesViewTest {
state = state, state = state,
onRoomDetailsClick = callback, 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 @Test
@Config(qualifiers = "h1024dp")
fun `clicking on a read receipt list emits the expected Event`() { fun `clicking on a read receipt list emits the expected Event`() {
val eventsRecorder = EventsRecorder<ReadReceiptBottomSheetEvents>() val eventsRecorder = EventsRecorder<ReadReceiptBottomSheetEvents>()
val state = aMessagesState( val state = aMessagesState(
@ -229,7 +230,7 @@ class MessagesViewTest {
rule.setMessagesView( rule.setMessagesView(
state = state, state = state,
) )
rule.onNodeWithTag(TestTags.messageReadReceipts.value).performClick() rule.onNodeWithTag(TestTags.messageReadReceipts.value, useUnmergedTree = true).performClick()
eventsRecorder.assertSingle(ReadReceiptBottomSheetEvents.EventSelected(timelineItem)) eventsRecorder.assertSingle(ReadReceiptBottomSheetEvents.EventSelected(timelineItem))
} }
@ -309,7 +310,7 @@ class MessagesViewTest {
@Test @Test
@Config(qualifiers = "h1024dp") @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<MessagesEvents>(expectEvents = false) val eventsRecorder = EventsRecorder<MessagesEvents>(expectEvents = false)
val state = aMessagesState( val state = aMessagesState(
eventSink = eventsRecorder eventSink = eventsRecorder
@ -322,7 +323,26 @@ class MessagesViewTest {
state = state, state = state,
onUserDataClick = callback, 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<MessagesEvents>(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()
} }
} }

View file

@ -13,9 +13,11 @@ import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.clipToBounds import androidx.compose.ui.draw.clipToBounds
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextAlign
@ -58,6 +60,7 @@ fun UserProfileHeaderSection(
Avatar( Avatar(
avatarData = AvatarData(userId.value, userName, avatarUrl, AvatarSize.UserHeader), avatarData = AvatarData(userId.value, userName, avatarUrl, AvatarSize.UserHeader),
modifier = Modifier modifier = Modifier
.clip(CircleShape)
.clickable(enabled = avatarUrl != null) { openAvatarPreview(avatarUrl!!) } .clickable(enabled = avatarUrl != null) { openAvatarPreview(avatarUrl!!) }
.testTag(TestTags.memberDetailAvatar) .testTag(TestTags.memberDetailAvatar)
) )

View file

@ -34,8 +34,8 @@ fun InviteSenderView(
modifier = modifier, modifier = modifier,
) { ) {
Box(modifier = Modifier.padding(vertical = 2.dp)) { Box(modifier = Modifier.padding(vertical = 2.dp)) {
Avatar(avatarData = inviteSender.avatarData) Avatar(avatarData = inviteSender.avatarData)
} }
Text( Text(
text = inviteSender.annotatedString(), text = inviteSender.annotatedString(),
style = ElementTheme.typography.fontBodyMdRegular, style = ElementTheme.typography.fontBodyMdRegular,

View file

@ -105,7 +105,8 @@ object TestTags {
/** /**
* Timeline item. * 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. * Search field.