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
@ -219,16 +223,42 @@ private fun NameAndTimestampRow(
modifier = modifier.fillMaxWidth(), modifier = modifier.fillMaxWidth(),
horizontalArrangement = spacedBy(16.dp) horizontalArrangement = spacedBy(16.dp)
) { ) {
// Name Row(
Text(
modifier = Modifier.weight(1f), modifier = Modifier.weight(1f),
style = ElementTheme.typography.fontBodyLgMedium, verticalAlignment = Alignment.CenterVertically,
text = name ?: stringResource(id = CommonStrings.common_no_room_name), ) {
fontStyle = FontStyle.Italic.takeIf { name == null }, // Name
color = ElementTheme.colors.roomListRoomName, Text(
maxLines = 1, style = ElementTheme.typography.fontBodyLgMedium,
overflow = TextOverflow.Ellipsis text = name ?: stringResource(id = CommonStrings.common_no_room_name),
) fontStyle = FontStyle.Italic.takeIf { name == null },
color = ElementTheme.colors.roomListRoomName,
maxLines = 1,
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,21 +304,41 @@ 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(
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(
modifier = Modifier.weight(1f),
text = annotatedMessagePreview,
color = ElementTheme.colors.roomListRoomMessage,
style = ElementTheme.typography.fontBodyMdRegular,
minLines = 2,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
)
}
} }
val annotatedMessagePreview = messagePreview as? AnnotatedString ?: AnnotatedString(text = messagePreview.toString())
Text(
modifier = Modifier.weight(1f),
text = annotatedMessagePreview,
color = ElementTheme.colors.roomListRoomMessage,
style = ElementTheme.typography.fontBodyMdRegular,
minLines = 2,
maxLines = 2,
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(
" ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea com" + "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt" +
"modo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur.", " 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."
),
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>