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
commit 14b83d6a7e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
18 changed files with 205 additions and 67 deletions

View file

@ -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
@ -219,16 +223,42 @@ private fun NameAndTimestampRow(
modifier = modifier.fillMaxWidth(),
horizontalArrangement = spacedBy(16.dp)
) {
// Name
Text(
Row(
modifier = Modifier.weight(1f),
style = ElementTheme.typography.fontBodyLgMedium,
text = name ?: stringResource(id = CommonStrings.common_no_room_name),
fontStyle = FontStyle.Italic.takeIf { name == null },
color = ElementTheme.colors.roomListRoomName,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
verticalAlignment = Alignment.CenterVertically,
) {
// Name
Text(
style = ElementTheme.typography.fontBodyLgMedium,
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
Text(
text = timestamp ?: "",
@ -274,21 +304,41 @@ 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()
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
Row(

View file

@ -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,
)
}
}
}
}

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 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
}
}
}

View file

@ -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,

View file

@ -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" +
" 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.",
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."
),
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,

View file

@ -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",
)

View file

@ -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,
)

View file

@ -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,

View file

@ -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))
}

View file

@ -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?
}

View file

@ -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(
content = latestEvent.content,
isDmRoom = isDmRoom,
isOutgoing = true,
senderId = latestEvent.senderId,
senderDisambiguatedDisplayName = latestEvent.senderProfile.getDisambiguatedDisplayName(latestEvent.senderId)
)
is LatestEventValue.Remote -> formatContent(
content = latestEvent.content,
isDmRoom = isDmRoom,
isOutgoing = latestEvent.isOwn,
senderId = latestEvent.senderId,
senderDisambiguatedDisplayName = latestEvent.senderProfile.getDisambiguatedDisplayName(latestEvent.senderId)
)
}
}
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)
)
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,

View file

@ -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(

View file

@ -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
}

View file

@ -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>

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:761e963499f97f21b5c5b8b80e5d75092fbc4305371bcc413f595b503890d511
size 13085

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:de31aa3dbb9c9427189621d35022211220197de8ed8584f36801eb99d613f0b1
size 13728

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:1f7327f29c9236b549d7f5037e3483e487a23902c5c30bd20a7a4bc0e6c13bec
size 13009

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:f92a70303d515a135eb87eac344dc4a974183da8773524a48f790b31a70338e8
size 13528