Add indicators in room list for sending event and errors.

This commit is contained in:
Benoit Marty 2025-11-27 12:22:57 +01:00
parent bd65a1ded2
commit 7a751abdb3
9 changed files with 160 additions and 42 deletions

View file

@ -40,6 +40,7 @@ import androidx.compose.ui.zIndex
import io.element.android.compound.theme.ElementTheme import io.element.android.compound.theme.ElementTheme
import io.element.android.compound.tokens.generated.CompoundIcons import io.element.android.compound.tokens.generated.CompoundIcons
import io.element.android.features.home.impl.R import io.element.android.features.home.impl.R
import io.element.android.features.home.impl.model.LatestEvent
import io.element.android.features.home.impl.model.RoomListRoomSummary import io.element.android.features.home.impl.model.RoomListRoomSummary
import io.element.android.features.home.impl.model.RoomListRoomSummaryProvider import io.element.android.features.home.impl.model.RoomListRoomSummaryProvider
import io.element.android.features.home.impl.model.RoomSummaryDisplayType import io.element.android.features.home.impl.model.RoomSummaryDisplayType
@ -120,6 +121,7 @@ internal fun RoomSummaryRow(
) { ) {
NameAndTimestampRow( NameAndTimestampRow(
name = room.name, name = room.name,
latestEvent = room.latestEvent,
timestamp = room.timestamp, timestamp = room.timestamp,
isHighlighted = room.isHighlighted isHighlighted = room.isHighlighted
) )
@ -136,6 +138,7 @@ internal fun RoomSummaryRow(
) { ) {
NameAndTimestampRow( NameAndTimestampRow(
name = room.name, name = room.name,
latestEvent = room.latestEvent,
timestamp = null, timestamp = null,
isHighlighted = room.isHighlighted isHighlighted = room.isHighlighted
) )
@ -211,6 +214,7 @@ private fun RoomSummaryScaffoldRow(
@Composable @Composable
private fun NameAndTimestampRow( private fun NameAndTimestampRow(
name: String?, name: String?,
latestEvent: LatestEvent,
timestamp: String?, timestamp: String?,
isHighlighted: Boolean, isHighlighted: Boolean,
modifier: Modifier = Modifier modifier: Modifier = Modifier
@ -218,10 +222,13 @@ private fun NameAndTimestampRow(
Row( Row(
modifier = modifier.fillMaxWidth(), modifier = modifier.fillMaxWidth(),
horizontalArrangement = spacedBy(16.dp) horizontalArrangement = spacedBy(16.dp)
) {
Row(
modifier = Modifier.weight(1f),
verticalAlignment = Alignment.CenterVertically,
) { ) {
// Name // Name
Text( Text(
modifier = Modifier.weight(1f),
style = ElementTheme.typography.fontBodyLgMedium, style = ElementTheme.typography.fontBodyLgMedium,
text = name ?: stringResource(id = CommonStrings.common_no_room_name), text = name ?: stringResource(id = CommonStrings.common_no_room_name),
fontStyle = FontStyle.Italic.takeIf { name == null }, fontStyle = FontStyle.Italic.takeIf { name == null },
@ -229,6 +236,29 @@ private fun NameAndTimestampRow(
maxLines = 1, maxLines = 1,
overflow = TextOverflow.Ellipsis overflow = TextOverflow.Ellipsis
) )
// Picto
when (latestEvent) {
is LatestEvent.Sending -> {
Spacer(modifier = Modifier.width(4.dp))
Icon(
modifier = Modifier.size(16.dp),
imageVector = CompoundIcons.Time(),
contentDescription = null,
tint = ElementTheme.colors.iconTertiary,
)
}
is LatestEvent.Error -> {
Spacer(modifier = Modifier.width(4.dp))
Icon(
modifier = Modifier.size(16.dp),
imageVector = CompoundIcons.ErrorSolid(),
contentDescription = null,
tint = ElementTheme.colors.iconCriticalPrimary,
)
}
else -> Unit
}
}
// Timestamp // Timestamp
Text( Text(
text = timestamp ?: "", text = timestamp ?: "",
@ -274,12 +304,30 @@ private fun MessagePreviewAndIndicatorRow(
modifier = modifier.fillMaxWidth(), modifier = modifier.fillMaxWidth(),
horizontalArrangement = spacedBy(28.dp) horizontalArrangement = spacedBy(28.dp)
) { ) {
val messagePreview = if (room.isTombstoned) { if (room.isTombstoned) {
stringResource(R.string.screen_roomlist_tombstoned_room_description) Text(
modifier = Modifier.weight(1f),
text = stringResource(R.string.screen_roomlist_tombstoned_room_description),
color = ElementTheme.colors.roomListRoomMessage,
style = ElementTheme.typography.fontBodyMdRegular,
minLines = 2,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
)
} else { } else {
room.latestEvent.orEmpty() if (room.latestEvent is LatestEvent.Error) {
} Text(
val annotatedMessagePreview = messagePreview as? AnnotatedString ?: AnnotatedString(text = messagePreview.toString()) modifier = Modifier.weight(1f),
text = stringResource(CommonStrings.common_message_failed_to_send),
color = ElementTheme.colors.textCriticalPrimary,
style = ElementTheme.typography.fontBodyMdRegular,
minLines = 2,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
)
} else {
val messagePreview = room.latestEvent.content()
val annotatedMessagePreview = messagePreview as? AnnotatedString ?: AnnotatedString(text = messagePreview.orEmpty().toString())
Text( Text(
modifier = Modifier.weight(1f), modifier = Modifier.weight(1f),
text = annotatedMessagePreview, text = annotatedMessagePreview,
@ -287,8 +335,10 @@ private fun MessagePreviewAndIndicatorRow(
style = ElementTheme.typography.fontBodyMdRegular, style = ElementTheme.typography.fontBodyMdRegular,
minLines = 2, minLines = 2,
maxLines = 2, maxLines = 2,
overflow = TextOverflow.Ellipsis overflow = TextOverflow.Ellipsis,
) )
}
}
// Call and unread // Call and unread
Row( Row(

View file

@ -9,6 +9,7 @@
package io.element.android.features.home.impl.datasource package io.element.android.features.home.impl.datasource
import dev.zacsweers.metro.Inject import dev.zacsweers.metro.Inject
import io.element.android.features.home.impl.model.LatestEvent
import io.element.android.features.home.impl.model.RoomListRoomSummary import io.element.android.features.home.impl.model.RoomListRoomSummary
import io.element.android.features.home.impl.model.RoomSummaryDisplayType import io.element.android.features.home.impl.model.RoomSummaryDisplayType
import io.element.android.libraries.core.extensions.orEmpty import io.element.android.libraries.core.extensions.orEmpty
@ -18,6 +19,7 @@ import io.element.android.libraries.designsystem.components.avatar.AvatarSize
import io.element.android.libraries.eventformatter.api.RoomLatestEventFormatter import io.element.android.libraries.eventformatter.api.RoomLatestEventFormatter
import io.element.android.libraries.matrix.api.room.CurrentUserMembership import io.element.android.libraries.matrix.api.room.CurrentUserMembership
import io.element.android.libraries.matrix.api.room.isDm import io.element.android.libraries.matrix.api.room.isDm
import io.element.android.libraries.matrix.api.roomlist.LatestEventValue
import io.element.android.libraries.matrix.api.roomlist.RoomSummary import io.element.android.libraries.matrix.api.roomlist.RoomSummary
import io.element.android.libraries.matrix.ui.model.getAvatarData import io.element.android.libraries.matrix.ui.model.getAvatarData
import io.element.android.libraries.matrix.ui.model.toInviteSender import io.element.android.libraries.matrix.ui.model.toInviteSender
@ -44,7 +46,7 @@ class RoomListRoomSummaryFactory(
mode = DateFormatterMode.TimeOrDate, mode = DateFormatterMode.TimeOrDate,
useRelative = true, useRelative = true,
), ),
latestEvent = roomLatestEventFormatter.format(roomSummary.latestEvent, roomInfo.isDm).orEmpty(), latestEvent = computeLatestEvent(roomSummary.latestEvent, roomInfo.isDm),
avatarData = avatarData, avatarData = avatarData,
userDefinedNotificationMode = roomInfo.userDefinedNotificationMode, userDefinedNotificationMode = roomInfo.userDefinedNotificationMode,
hasRoomCall = roomInfo.hasRoomCall, hasRoomCall = roomInfo.hasRoomCall,
@ -71,4 +73,28 @@ class RoomListRoomSummaryFactory(
isSpace = roomInfo.isSpace, isSpace = roomInfo.isSpace,
) )
} }
private fun computeLatestEvent(latestEvent: LatestEventValue, dm: Boolean): LatestEvent {
return when (latestEvent) {
is LatestEventValue.None -> {
LatestEvent.None
}
is LatestEventValue.Local -> {
if (latestEvent.isSending) {
val content = roomLatestEventFormatter.format(latestEvent, dm).orEmpty()
LatestEvent.Sending(
content = content,
)
} else {
LatestEvent.Error
}
}
is LatestEventValue.Remote -> {
val content = roomLatestEventFormatter.format(latestEvent, dm).orEmpty()
LatestEvent.Regular(
content = content,
)
}
}
}
} }

View file

@ -0,0 +1,34 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.home.impl.model
import androidx.compose.runtime.Immutable
@Immutable
sealed interface LatestEvent {
data object None : LatestEvent
data class Regular(
val content: CharSequence?,
) : LatestEvent
data class Sending(
val content: CharSequence?,
) : LatestEvent
data object Error : LatestEvent
fun content(): CharSequence? {
return when (this) {
is None -> null
is Regular -> content
is Sending -> content
is Error -> null
}
}
}

View file

@ -29,7 +29,7 @@ data class RoomListRoomSummary(
val numberOfUnreadNotifications: Long, val numberOfUnreadNotifications: Long,
val isMarkedUnread: Boolean, val isMarkedUnread: Boolean,
val timestamp: String?, val timestamp: String?,
val latestEvent: CharSequence?, val latestEvent: LatestEvent,
val avatarData: AvatarData, val avatarData: AvatarData,
val userDefinedNotificationMode: RoomNotificationMode?, val userDefinedNotificationMode: RoomNotificationMode?,
val hasRoomCall: Boolean, val hasRoomCall: Boolean,

View file

@ -25,12 +25,14 @@ open class RoomListRoomSummaryProvider : PreviewParameterProvider<RoomListRoomSu
aRoomListRoomSummary(displayType = RoomSummaryDisplayType.PLACEHOLDER), aRoomListRoomSummary(displayType = RoomSummaryDisplayType.PLACEHOLDER),
aRoomListRoomSummary(), aRoomListRoomSummary(),
aRoomListRoomSummary(name = null), aRoomListRoomSummary(name = null),
aRoomListRoomSummary(lastMessage = null), aRoomListRoomSummary(latestEvent = LatestEvent.None),
aRoomListRoomSummary( aRoomListRoomSummary(
name = "A very long room name that should be truncated", name = "A very long room name that should be truncated",
lastMessage = "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt" + latestEvent = LatestEvent.Regular(
"Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt" +
" ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea com" + " ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea com" +
"modo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur.", "modo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur."
),
timestamp = "yesterday", timestamp = "yesterday",
numberOfUnreadMessages = 1, numberOfUnreadMessages = 1,
), ),
@ -44,7 +46,7 @@ open class RoomListRoomSummaryProvider : PreviewParameterProvider<RoomListRoomSu
listOf( listOf(
aRoomListRoomSummary( aRoomListRoomSummary(
name = roomNotificationMode.name, name = roomNotificationMode.name,
lastMessage = "No activity" + if (hasCall) ", call" else "", latestEvent = LatestEvent.Regular("No activity" + if (hasCall) ", call" else ""),
notificationMode = roomNotificationMode, notificationMode = roomNotificationMode,
numberOfUnreadMessages = 0, numberOfUnreadMessages = 0,
numberOfUnreadMentions = 0, numberOfUnreadMentions = 0,
@ -52,7 +54,7 @@ open class RoomListRoomSummaryProvider : PreviewParameterProvider<RoomListRoomSu
), ),
aRoomListRoomSummary( aRoomListRoomSummary(
name = roomNotificationMode.name, name = roomNotificationMode.name,
lastMessage = "New messages" + if (hasCall) ", call" else "", latestEvent = LatestEvent.Regular("New messages" + if (hasCall) ", call" else ""),
notificationMode = roomNotificationMode, notificationMode = roomNotificationMode,
numberOfUnreadMessages = 1, numberOfUnreadMessages = 1,
numberOfUnreadMentions = 0, numberOfUnreadMentions = 0,
@ -60,7 +62,7 @@ open class RoomListRoomSummaryProvider : PreviewParameterProvider<RoomListRoomSu
), ),
aRoomListRoomSummary( aRoomListRoomSummary(
name = roomNotificationMode.name, name = roomNotificationMode.name,
lastMessage = "New messages, mentions" + if (hasCall) ", call" else "", latestEvent = LatestEvent.Regular("New messages, mentions" + if (hasCall) ", call" else ""),
notificationMode = roomNotificationMode, notificationMode = roomNotificationMode,
numberOfUnreadMessages = 1, numberOfUnreadMessages = 1,
numberOfUnreadMentions = 1, numberOfUnreadMentions = 1,
@ -68,7 +70,7 @@ open class RoomListRoomSummaryProvider : PreviewParameterProvider<RoomListRoomSu
), ),
aRoomListRoomSummary( aRoomListRoomSummary(
name = roomNotificationMode.name, name = roomNotificationMode.name,
lastMessage = "New mentions" + if (hasCall) ", call" else "", latestEvent = LatestEvent.Regular("New mentions" + if (hasCall) ", call" else ""),
notificationMode = roomNotificationMode, notificationMode = roomNotificationMode,
numberOfUnreadMessages = 0, numberOfUnreadMessages = 0,
numberOfUnreadMentions = 1, numberOfUnreadMentions = 1,
@ -127,6 +129,10 @@ open class RoomListRoomSummaryProvider : PreviewParameterProvider<RoomListRoomSu
isTombstoned = true, isTombstoned = true,
) )
), ),
listOf(
aRoomListRoomSummary(latestEvent = LatestEvent.Sending("A sending message")),
aRoomListRoomSummary(latestEvent = LatestEvent.Error),
)
).flatten() ).flatten()
} }
@ -148,8 +154,8 @@ internal fun aRoomListRoomSummary(
numberOfUnreadMentions: Long = 0, numberOfUnreadMentions: Long = 0,
numberOfUnreadNotifications: Long = 0, numberOfUnreadNotifications: Long = 0,
isMarkedUnread: Boolean = false, isMarkedUnread: Boolean = false,
lastMessage: String? = "Last message", latestEvent: LatestEvent = LatestEvent.Regular("Last message"),
timestamp: String? = lastMessage?.let { "88:88" }, timestamp: String? = latestEvent.takeIf { it !is LatestEvent.None }?.let { "88:88" },
notificationMode: RoomNotificationMode? = null, notificationMode: RoomNotificationMode? = null,
hasRoomCall: Boolean = false, hasRoomCall: Boolean = false,
avatarData: AvatarData = AvatarData(id, name, size = AvatarSize.RoomListItem), avatarData: AvatarData = AvatarData(id, name, size = AvatarSize.RoomListItem),
@ -171,7 +177,7 @@ internal fun aRoomListRoomSummary(
numberOfUnreadNotifications = numberOfUnreadNotifications, numberOfUnreadNotifications = numberOfUnreadNotifications,
isMarkedUnread = isMarkedUnread, isMarkedUnread = isMarkedUnread,
timestamp = timestamp, timestamp = timestamp,
latestEvent = lastMessage, latestEvent = latestEvent,
avatarData = avatarData, avatarData = avatarData,
userDefinedNotificationMode = notificationMode, userDefinedNotificationMode = notificationMode,
hasRoomCall = hasRoomCall, hasRoomCall = hasRoomCall,

View file

@ -11,6 +11,7 @@ package io.element.android.features.home.impl.roomlist
import androidx.compose.ui.tooling.preview.PreviewParameterProvider import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import io.element.android.features.home.impl.filters.RoomListFiltersState import io.element.android.features.home.impl.filters.RoomListFiltersState
import io.element.android.features.home.impl.filters.aRoomListFiltersState import io.element.android.features.home.impl.filters.aRoomListFiltersState
import io.element.android.features.home.impl.model.LatestEvent
import io.element.android.features.home.impl.model.RoomListRoomSummary import io.element.android.features.home.impl.model.RoomListRoomSummary
import io.element.android.features.home.impl.model.RoomSummaryDisplayType import io.element.android.features.home.impl.model.RoomSummaryDisplayType
import io.element.android.features.home.impl.model.aRoomListRoomSummary import io.element.android.features.home.impl.model.aRoomListRoomSummary
@ -88,7 +89,7 @@ internal fun aRoomListRoomSummaryList(): ImmutableList<RoomListRoomSummary> {
name = "Room", name = "Room",
numberOfUnreadMessages = 1, numberOfUnreadMessages = 1,
timestamp = "14:18", timestamp = "14:18",
lastMessage = "A very very very very long message which suites on two lines", latestEvent = LatestEvent.Regular("A very very very very long message which suites on two lines"),
avatarData = AvatarData("!id", "R", size = AvatarSize.RoomListItem), avatarData = AvatarData("!id", "R", size = AvatarSize.RoomListItem),
id = "!roomId:domain", id = "!roomId:domain",
), ),
@ -96,7 +97,7 @@ internal fun aRoomListRoomSummaryList(): ImmutableList<RoomListRoomSummary> {
name = "Room#2", name = "Room#2",
numberOfUnreadMessages = 0, numberOfUnreadMessages = 0,
timestamp = "14:16", timestamp = "14:16",
lastMessage = "A short message", latestEvent = LatestEvent.Regular("A short message"),
avatarData = AvatarData("!id", "Z", size = AvatarSize.RoomListItem), avatarData = AvatarData("!id", "Z", size = AvatarSize.RoomListItem),
id = "!roomId2:domain", id = "!roomId2:domain",
), ),
@ -119,7 +120,7 @@ internal fun generateRoomListRoomSummaryList(
name = "Room#$index", name = "Room#$index",
numberOfUnreadMessages = 0, numberOfUnreadMessages = 0,
timestamp = "14:16", timestamp = "14:16",
lastMessage = "A message", latestEvent = LatestEvent.Regular("A message"),
avatarData = AvatarData("!id$index", "${(65 + index % 26).toChar()}", size = AvatarSize.RoomListItem), avatarData = AvatarData("!id$index", "${(65 + index % 26).toChar()}", size = AvatarSize.RoomListItem),
id = "!roomId$index:domain", id = "!roomId$index:domain",
) )

View file

@ -96,7 +96,7 @@ internal fun createRoomListRoomSummary(
numberOfUnreadNotifications = numberOfUnreadNotifications, numberOfUnreadNotifications = numberOfUnreadNotifications,
isMarkedUnread = isMarkedUnread, isMarkedUnread = isMarkedUnread,
timestamp = timestamp, timestamp = timestamp,
latestEvent = "", latestEvent = LatestEvent.Regular(""),
avatarData = AvatarData(id = A_ROOM_ID.value, name = A_ROOM_NAME, size = AvatarSize.RoomListItem), avatarData = AvatarData(id = A_ROOM_ID.value, name = A_ROOM_NAME, size = AvatarSize.RoomListItem),
displayType = displayType, displayType = displayType,
userDefinedNotificationMode = userDefinedNotificationMode, userDefinedNotificationMode = userDefinedNotificationMode,

View file

@ -170,7 +170,7 @@ class RoomListViewTest {
// Remove automatic initial events // Remove automatic initial events
eventsRecorder.clear() eventsRecorder.clear()
rule.onNodeWithText(room0.latestEvent!!.toString()).performClick() rule.onNodeWithText(room0.latestEvent.content().toString()).performClick()
} }
eventsRecorder.assertEmpty() eventsRecorder.assertEmpty()
@ -192,7 +192,7 @@ class RoomListViewTest {
) )
// Remove automatic initial events // Remove automatic initial events
eventsRecorder.clear() eventsRecorder.clear()
rule.onNodeWithText(room0.latestEvent!!.toString()) rule.onNodeWithText(room0.latestEvent.content().toString())
.performClick() .performClick()
.performClick() .performClick()
} }
@ -214,7 +214,7 @@ class RoomListViewTest {
// Remove automatic initial events // Remove automatic initial events
eventsRecorder.clear() eventsRecorder.clear()
rule.onNodeWithText(room0.latestEvent!!.toString()).performTouchInput { longClick() } rule.onNodeWithText(room0.latestEvent.content().toString()).performTouchInput { longClick() }
eventsRecorder.assertSingle(RoomListEvents.ShowContextMenu(room0)) eventsRecorder.assertSingle(RoomListEvents.ShowContextMenu(room0))
} }

View file

@ -246,6 +246,7 @@ Reason: %1$s."</string>
</plurals> </plurals>
<string name="common_message">"Message"</string> <string name="common_message">"Message"</string>
<string name="common_message_actions">"Message actions"</string> <string name="common_message_actions">"Message actions"</string>
<string name="common_message_failed_to_send">"Message failed to send"</string>
<string name="common_message_layout">"Message layout"</string> <string name="common_message_layout">"Message layout"</string>
<string name="common_message_removed">"Message removed"</string> <string name="common_message_removed">"Message removed"</string>
<string name="common_modern">"Modern"</string> <string name="common_modern">"Modern"</string>