Merge pull request #5824 from element-hq/feature/bma/roomListIndicators

Add room list indicators about last message
This commit is contained in:
Benoit Marty
2025-12-02 17:42:46 +01:00
committed by GitHub
18 changed files with 205 additions and 67 deletions
@@ -40,6 +40,7 @@ import androidx.compose.ui.zIndex
import io.element.android.compound.theme.ElementTheme
import io.element.android.compound.tokens.generated.CompoundIcons
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.RoomListRoomSummaryProvider
import io.element.android.features.home.impl.model.RoomSummaryDisplayType
@@ -120,6 +121,7 @@ internal fun RoomSummaryRow(
) {
NameAndTimestampRow(
name = room.name,
latestEvent = room.latestEvent,
timestamp = room.timestamp,
isHighlighted = room.isHighlighted
)
@@ -136,6 +138,7 @@ internal fun RoomSummaryRow(
) {
NameAndTimestampRow(
name = room.name,
latestEvent = room.latestEvent,
timestamp = null,
isHighlighted = room.isHighlighted
)
@@ -211,6 +214,7 @@ private fun RoomSummaryScaffoldRow(
@Composable
private fun NameAndTimestampRow(
name: String?,
latestEvent: LatestEvent,
timestamp: String?,
isHighlighted: Boolean,
modifier: Modifier = Modifier
@@ -218,10 +222,13 @@ private fun NameAndTimestampRow(
Row(
modifier = modifier.fillMaxWidth(),
horizontalArrangement = spacedBy(16.dp)
) {
Row(
modifier = Modifier.weight(1f),
verticalAlignment = Alignment.CenterVertically,
) {
// Name
Text(
modifier = Modifier.weight(1f),
style = ElementTheme.typography.fontBodyLgMedium,
text = name ?: stringResource(id = CommonStrings.common_no_room_name),
fontStyle = FontStyle.Italic.takeIf { name == null },
@@ -229,6 +236,29 @@ private fun NameAndTimestampRow(
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
Text(
text = timestamp ?: "",
@@ -274,12 +304,30 @@ private fun MessagePreviewAndIndicatorRow(
modifier = modifier.fillMaxWidth(),
horizontalArrangement = spacedBy(28.dp)
) {
val messagePreview = if (room.isTombstoned) {
stringResource(R.string.screen_roomlist_tombstoned_room_description)
if (room.isTombstoned) {
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 {
room.latestEvent.orEmpty()
}
val annotatedMessagePreview = messagePreview as? AnnotatedString ?: AnnotatedString(text = messagePreview.toString())
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,
@@ -287,8 +335,10 @@ private fun MessagePreviewAndIndicatorRow(
style = ElementTheme.typography.fontBodyMdRegular,
minLines = 2,
maxLines = 2,
overflow = TextOverflow.Ellipsis
overflow = TextOverflow.Ellipsis,
)
}
}
// Call and unread
Row(
@@ -9,6 +9,7 @@
package io.element.android.features.home.impl.datasource
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.RoomSummaryDisplayType
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.matrix.api.room.CurrentUserMembership
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.ui.model.getAvatarData
import io.element.android.libraries.matrix.ui.model.toInviteSender
@@ -44,7 +46,7 @@ class RoomListRoomSummaryFactory(
mode = DateFormatterMode.TimeOrDate,
useRelative = true,
),
latestEvent = roomLatestEventFormatter.format(roomSummary.latestEvent, roomInfo.isDm).orEmpty(),
latestEvent = computeLatestEvent(roomSummary.latestEvent, roomInfo.isDm),
avatarData = avatarData,
userDefinedNotificationMode = roomInfo.userDefinedNotificationMode,
hasRoomCall = roomInfo.hasRoomCall,
@@ -71,4 +73,28 @@ class RoomListRoomSummaryFactory(
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.Synced(
content = content,
)
}
}
}
}
@@ -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 Synced(
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 Synced -> content
is Sending -> content
is Error -> null
}
}
}
@@ -29,7 +29,7 @@ data class RoomListRoomSummary(
val numberOfUnreadNotifications: Long,
val isMarkedUnread: Boolean,
val timestamp: String?,
val latestEvent: CharSequence?,
val latestEvent: LatestEvent,
val avatarData: AvatarData,
val userDefinedNotificationMode: RoomNotificationMode?,
val hasRoomCall: Boolean,
@@ -25,12 +25,14 @@ open class RoomListRoomSummaryProvider : PreviewParameterProvider<RoomListRoomSu
aRoomListRoomSummary(displayType = RoomSummaryDisplayType.PLACEHOLDER),
aRoomListRoomSummary(),
aRoomListRoomSummary(name = null),
aRoomListRoomSummary(lastMessage = null),
aRoomListRoomSummary(latestEvent = LatestEvent.None),
aRoomListRoomSummary(
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.Synced(
"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" +
"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",
numberOfUnreadMessages = 1,
),
@@ -44,7 +46,7 @@ open class RoomListRoomSummaryProvider : PreviewParameterProvider<RoomListRoomSu
listOf(
aRoomListRoomSummary(
name = roomNotificationMode.name,
lastMessage = "No activity" + if (hasCall) ", call" else "",
latestEvent = LatestEvent.Synced("No activity" + if (hasCall) ", call" else ""),
notificationMode = roomNotificationMode,
numberOfUnreadMessages = 0,
numberOfUnreadMentions = 0,
@@ -52,7 +54,7 @@ open class RoomListRoomSummaryProvider : PreviewParameterProvider<RoomListRoomSu
),
aRoomListRoomSummary(
name = roomNotificationMode.name,
lastMessage = "New messages" + if (hasCall) ", call" else "",
latestEvent = LatestEvent.Synced("New messages" + if (hasCall) ", call" else ""),
notificationMode = roomNotificationMode,
numberOfUnreadMessages = 1,
numberOfUnreadMentions = 0,
@@ -60,7 +62,7 @@ open class RoomListRoomSummaryProvider : PreviewParameterProvider<RoomListRoomSu
),
aRoomListRoomSummary(
name = roomNotificationMode.name,
lastMessage = "New messages, mentions" + if (hasCall) ", call" else "",
latestEvent = LatestEvent.Synced("New messages, mentions" + if (hasCall) ", call" else ""),
notificationMode = roomNotificationMode,
numberOfUnreadMessages = 1,
numberOfUnreadMentions = 1,
@@ -68,7 +70,7 @@ open class RoomListRoomSummaryProvider : PreviewParameterProvider<RoomListRoomSu
),
aRoomListRoomSummary(
name = roomNotificationMode.name,
lastMessage = "New mentions" + if (hasCall) ", call" else "",
latestEvent = LatestEvent.Synced("New mentions" + if (hasCall) ", call" else ""),
notificationMode = roomNotificationMode,
numberOfUnreadMessages = 0,
numberOfUnreadMentions = 1,
@@ -127,6 +129,10 @@ open class RoomListRoomSummaryProvider : PreviewParameterProvider<RoomListRoomSu
isTombstoned = true,
)
),
listOf(
aRoomListRoomSummary(latestEvent = LatestEvent.Sending("A sending message")),
aRoomListRoomSummary(latestEvent = LatestEvent.Error),
)
).flatten()
}
@@ -148,8 +154,8 @@ internal fun aRoomListRoomSummary(
numberOfUnreadMentions: Long = 0,
numberOfUnreadNotifications: Long = 0,
isMarkedUnread: Boolean = false,
lastMessage: String? = "Last message",
timestamp: String? = lastMessage?.let { "88:88" },
latestEvent: LatestEvent = LatestEvent.Synced("Last message"),
timestamp: String? = latestEvent.takeIf { it !is LatestEvent.None }?.let { "88:88" },
notificationMode: RoomNotificationMode? = null,
hasRoomCall: Boolean = false,
avatarData: AvatarData = AvatarData(id, name, size = AvatarSize.RoomListItem),
@@ -171,7 +177,7 @@ internal fun aRoomListRoomSummary(
numberOfUnreadNotifications = numberOfUnreadNotifications,
isMarkedUnread = isMarkedUnread,
timestamp = timestamp,
latestEvent = lastMessage,
latestEvent = latestEvent,
avatarData = avatarData,
userDefinedNotificationMode = notificationMode,
hasRoomCall = hasRoomCall,
@@ -11,6 +11,7 @@ package io.element.android.features.home.impl.roomlist
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
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.model.LatestEvent
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.aRoomListRoomSummary
@@ -88,7 +89,7 @@ internal fun aRoomListRoomSummaryList(): ImmutableList<RoomListRoomSummary> {
name = "Room",
numberOfUnreadMessages = 1,
timestamp = "14:18",
lastMessage = "A very very very very long message which suites on two lines",
latestEvent = LatestEvent.Synced("A very very very very long message which suites on two lines"),
avatarData = AvatarData("!id", "R", size = AvatarSize.RoomListItem),
id = "!roomId:domain",
),
@@ -96,7 +97,7 @@ internal fun aRoomListRoomSummaryList(): ImmutableList<RoomListRoomSummary> {
name = "Room#2",
numberOfUnreadMessages = 0,
timestamp = "14:16",
lastMessage = "A short message",
latestEvent = LatestEvent.Synced("A short message"),
avatarData = AvatarData("!id", "Z", size = AvatarSize.RoomListItem),
id = "!roomId2:domain",
),
@@ -119,7 +120,7 @@ internal fun generateRoomListRoomSummaryList(
name = "Room#$index",
numberOfUnreadMessages = 0,
timestamp = "14:16",
lastMessage = "A message",
latestEvent = LatestEvent.Synced("A message"),
avatarData = AvatarData("!id$index", "${(65 + index % 26).toChar()}", size = AvatarSize.RoomListItem),
id = "!roomId$index:domain",
)
@@ -11,11 +11,12 @@ package io.element.android.features.home.impl.datasource
import io.element.android.libraries.dateformatter.api.DateFormatter
import io.element.android.libraries.dateformatter.test.FakeDateFormatter
import io.element.android.libraries.eventformatter.api.RoomLatestEventFormatter
import io.element.android.libraries.eventformatter.test.FakeRoomLatestEventFormatter
fun aRoomListRoomSummaryFactory(
dateFormatter: DateFormatter = FakeDateFormatter { _, _, _ -> "Today" },
roomLatestEventFormatter: RoomLatestEventFormatter = RoomLatestEventFormatter { _, _ -> "Hey" }
roomLatestEventFormatter: RoomLatestEventFormatter = FakeRoomLatestEventFormatter(),
) = RoomListRoomSummaryFactory(
dateFormatter = dateFormatter,
roomLatestEventFormatter = roomLatestEventFormatter
roomLatestEventFormatter = roomLatestEventFormatter,
)
@@ -96,7 +96,7 @@ internal fun createRoomListRoomSummary(
numberOfUnreadNotifications = numberOfUnreadNotifications,
isMarkedUnread = isMarkedUnread,
timestamp = timestamp,
latestEvent = "",
latestEvent = LatestEvent.Synced(""),
avatarData = AvatarData(id = A_ROOM_ID.value, name = A_ROOM_NAME, size = AvatarSize.RoomListItem),
displayType = displayType,
userDefinedNotificationMode = userDefinedNotificationMode,
@@ -170,7 +170,7 @@ class RoomListViewTest {
// Remove automatic initial events
eventsRecorder.clear()
rule.onNodeWithText(room0.latestEvent!!.toString()).performClick()
rule.onNodeWithText(room0.latestEvent.content().toString()).performClick()
}
eventsRecorder.assertEmpty()
@@ -192,7 +192,7 @@ class RoomListViewTest {
)
// Remove automatic initial events
eventsRecorder.clear()
rule.onNodeWithText(room0.latestEvent!!.toString())
rule.onNodeWithText(room0.latestEvent.content().toString())
.performClick()
.performClick()
}
@@ -214,7 +214,7 @@ class RoomListViewTest {
// Remove automatic initial events
eventsRecorder.clear()
rule.onNodeWithText(room0.latestEvent!!.toString()).performTouchInput { longClick() }
rule.onNodeWithText(room0.latestEvent.content().toString()).performTouchInput { longClick() }
eventsRecorder.assertSingle(RoomListEvents.ShowContextMenu(room0))
}
@@ -10,6 +10,7 @@ package io.element.android.libraries.eventformatter.api
import io.element.android.libraries.matrix.api.roomlist.LatestEventValue
fun interface RoomLatestEventFormatter {
fun format(latestEvent: LatestEventValue, isDmRoom: Boolean): CharSequence?
interface RoomLatestEventFormatter {
fun format(latestEvent: LatestEventValue.Local, isDmRoom: Boolean): CharSequence?
fun format(latestEvent: LatestEventValue.Remote, isDmRoom: Boolean): CharSequence?
}
@@ -59,25 +59,27 @@ class DefaultRoomLatestEventFormatter(
private const val MAX_SAFE_LENGTH = 500
}
override fun format(latestEvent: LatestEventValue, isDmRoom: Boolean): CharSequence? {
return when (latestEvent) {
LatestEventValue.None -> null
is LatestEventValue.Local -> formatContent(
override fun format(
latestEvent: LatestEventValue.Local,
isDmRoom: Boolean,
): CharSequence? = formatContent(
content = latestEvent.content,
isDmRoom = isDmRoom,
isOutgoing = true,
senderId = latestEvent.senderId,
senderDisambiguatedDisplayName = latestEvent.senderProfile.getDisambiguatedDisplayName(latestEvent.senderId)
)
is LatestEventValue.Remote -> formatContent(
override fun format(
latestEvent: LatestEventValue.Remote,
isDmRoom: Boolean,
): CharSequence? = formatContent(
content = latestEvent.content,
isDmRoom = isDmRoom,
isOutgoing = latestEvent.isOwn,
senderId = latestEvent.senderId,
senderDisambiguatedDisplayName = latestEvent.senderProfile.getDisambiguatedDisplayName(latestEvent.senderId)
)
}
}
private fun formatContent(
content: EventContent,
@@ -929,7 +929,7 @@ class DefaultRoomLatestEventFormatterTest {
sentByYou: Boolean,
senderDisplayName: String?,
content: EventContent,
): LatestEventValue {
): LatestEventValue.Remote {
val sender = if (sentByYou) A_USER_ID else someoneElseId
val profile = aProfileDetails(senderDisplayName)
return aRemoteLatestEvent(
@@ -14,7 +14,11 @@ import io.element.android.libraries.matrix.api.roomlist.LatestEventValue
class FakeRoomLatestEventFormatter : RoomLatestEventFormatter {
private var result: CharSequence? = null
override fun format(latestEvent: LatestEventValue, isDmRoom: Boolean): CharSequence? {
override fun format(latestEvent: LatestEventValue.Local, isDmRoom: Boolean): CharSequence? {
return result
}
override fun format(latestEvent: LatestEventValue.Remote, isDmRoom: Boolean): CharSequence? {
return result
}
@@ -246,6 +246,7 @@ Reason: %1$s."</string>
</plurals>
<string name="common_message">"Message"</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_removed">"Message removed"</string>
<string name="common_modern">"Modern"</string>
@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:761e963499f97f21b5c5b8b80e5d75092fbc4305371bcc413f595b503890d511
size 13085
@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:de31aa3dbb9c9427189621d35022211220197de8ed8584f36801eb99d613f0b1
size 13728
@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:1f7327f29c9236b549d7f5037e3483e487a23902c5c30bd20a7a4bc0e6c13bec
size 13009
@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:f92a70303d515a135eb87eac344dc4a974183da8773524a48f790b31a70338e8
size 13528