Add unread count to the room unread indicator (#6887)

* Add unread indicator count

* Address review
This commit is contained in:
bxdxnn
2026-06-03 18:08:35 +03:00
committed by GitHub
parent 2ab780576a
commit 27cdc0fe7d
9 changed files with 69 additions and 13 deletions
@@ -279,6 +279,7 @@ private fun RoomsViewList(
hideInviteAvatars = hideInvitesAvatars, hideInviteAvatars = hideInvitesAvatars,
isInviteSeen = room.displayType == RoomSummaryDisplayType.INVITE && isInviteSeen = room.displayType == RoomSummaryDisplayType.INVITE &&
state.seenRoomInvites.contains(room.roomId), state.seenRoomInvites.contains(room.roomId),
showUnreadCount = state.showUnreadCount,
onClick = onRoomClick, onClick = onRoomClick,
eventSink = eventSink, eventSink = eventSink,
) )
@@ -75,6 +75,7 @@ internal fun RoomSummaryRow(
room: RoomListRoomSummary, room: RoomListRoomSummary,
hideInviteAvatars: Boolean, hideInviteAvatars: Boolean,
isInviteSeen: Boolean, isInviteSeen: Boolean,
showUnreadCount: Boolean = false,
onClick: (RoomListRoomSummary) -> Unit, onClick: (RoomListRoomSummary) -> Unit,
eventSink: (RoomListEvent) -> Unit, eventSink: (RoomListEvent) -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
@@ -127,7 +128,7 @@ internal fun RoomSummaryRow(
timestamp = room.timestamp, timestamp = room.timestamp,
isHighlighted = room.isHighlighted isHighlighted = room.isHighlighted
) )
MessagePreviewAndIndicatorRow(room = room) MessagePreviewAndIndicatorRow(room = room, showUnreadCount = showUnreadCount)
} }
} }
RoomSummaryDisplayType.KNOCKED -> { RoomSummaryDisplayType.KNOCKED -> {
@@ -273,6 +274,7 @@ private fun InviteSubtitle(
@Composable @Composable
private fun MessagePreviewAndIndicatorRow( private fun MessagePreviewAndIndicatorRow(
room: RoomListRoomSummary, room: RoomListRoomSummary,
showUnreadCount: Boolean,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
Row( Row(
@@ -360,8 +362,18 @@ private fun MessagePreviewAndIndicatorRow(
} }
if (room.hasNewContent) { if (room.hasNewContent) {
val contentDescription = stringResource(CommonStrings.a11y_notifications_new_messages) val contentDescription = stringResource(CommonStrings.a11y_notifications_new_messages)
val count = if (showUnreadCount) {
if (room.userDefinedNotificationMode == RoomNotificationMode.MUTE) {
room.numberOfUnreadMessages
} else {
room.numberOfUnreadNotifications
}
} else {
null
}
UnreadIndicatorAtom( UnreadIndicatorAtom(
color = tint, color = tint,
count = count,
contentDescription = contentDescription, contentDescription = contentDescription,
) )
} }
@@ -36,6 +36,7 @@ open class RoomListContentStateProvider : PreviewParameterProvider<RoomListConte
internal fun aRoomsContentState( internal fun aRoomsContentState(
securityBannerState: SecurityBannerState = SecurityBannerState.None, securityBannerState: SecurityBannerState = SecurityBannerState.None,
showNewNotificationSoundBanner: Boolean = false, showNewNotificationSoundBanner: Boolean = false,
showUnreadCount: Boolean = false,
summaries: ImmutableList<RoomListRoomSummary> = aRoomListRoomSummaryList(), summaries: ImmutableList<RoomListRoomSummary> = aRoomListRoomSummaryList(),
fullScreenIntentPermissionsState: FullScreenIntentPermissionsState = aFullScreenIntentPermissionsState(), fullScreenIntentPermissionsState: FullScreenIntentPermissionsState = aFullScreenIntentPermissionsState(),
batteryOptimizationState: BatteryOptimizationState = aBatteryOptimizationState(), batteryOptimizationState: BatteryOptimizationState = aBatteryOptimizationState(),
@@ -43,6 +44,7 @@ internal fun aRoomsContentState(
) = RoomListContentState.Rooms( ) = RoomListContentState.Rooms(
securityBannerState = securityBannerState, securityBannerState = securityBannerState,
showNewNotificationSoundBanner = showNewNotificationSoundBanner, showNewNotificationSoundBanner = showNewNotificationSoundBanner,
showUnreadCount = showUnreadCount,
fullScreenIntentPermissionsState = fullScreenIntentPermissionsState, fullScreenIntentPermissionsState = fullScreenIntentPermissionsState,
batteryOptimizationState = batteryOptimizationState, batteryOptimizationState = batteryOptimizationState,
summaries = summaries, summaries = summaries,
@@ -44,6 +44,8 @@ import io.element.android.features.leaveroom.api.LeaveRoomEvent
import io.element.android.features.leaveroom.api.LeaveRoomState import io.element.android.features.leaveroom.api.LeaveRoomState
import io.element.android.libraries.architecture.AsyncData import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.architecture.Presenter import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.featureflag.api.FeatureFlagService
import io.element.android.libraries.featureflag.api.FeatureFlags
import io.element.android.libraries.fullscreenintent.api.FullScreenIntentPermissionsState import io.element.android.libraries.fullscreenintent.api.FullScreenIntentPermissionsState
import io.element.android.libraries.matrix.api.MatrixClient import io.element.android.libraries.matrix.api.MatrixClient
import io.element.android.libraries.matrix.api.core.RoomId import io.element.android.libraries.matrix.api.core.RoomId
@@ -90,6 +92,7 @@ class RoomListPresenter(
private val announcementService: AnnouncementService, private val announcementService: AnnouncementService,
private val coldStartWatcher: AnalyticsColdStartWatcher, private val coldStartWatcher: AnalyticsColdStartWatcher,
private val spaceFiltersPresenter: Presenter<SpaceFiltersState>, private val spaceFiltersPresenter: Presenter<SpaceFiltersState>,
private val featureFlagService: FeatureFlagService,
) : Presenter<RoomListState> { ) : Presenter<RoomListState> {
private val encryptionService = client.encryptionService private val encryptionService = client.encryptionService
@@ -165,13 +168,17 @@ class RoomListPresenter(
roomListDataSource.updateFilter(allFilters) roomListDataSource.updateFilter(allFilters)
} }
val canReportRoom by produceState(false) { value = client.canReportRoom() }
val showUnreadCount by produceState(false) {
value = featureFlagService.isFeatureEnabled(FeatureFlags.UnreadIndicatorCount)
}
val contentState = roomListContentState( val contentState = roomListContentState(
securityBannerDismissed, securityBannerDismissed,
showNewNotificationSoundBanner, showNewNotificationSoundBanner,
showUnreadCount,
) )
val canReportRoom by produceState(false) { value = client.canReportRoom() }
return RoomListState( return RoomListState(
contextMenu = contextMenu.value, contextMenu = contextMenu.value,
declineInviteMenu = declineInviteMenu.value, declineInviteMenu = declineInviteMenu.value,
@@ -226,6 +233,7 @@ class RoomListPresenter(
private fun roomListContentState( private fun roomListContentState(
securityBannerDismissed: Boolean, securityBannerDismissed: Boolean,
showNewNotificationSoundBanner: Boolean, showNewNotificationSoundBanner: Boolean,
showUnreadCount: Boolean,
): RoomListContentState { ): RoomListContentState {
val roomSummaries by produceState(initialValue = AsyncData.Loading()) { val roomSummaries by produceState(initialValue = AsyncData.Loading()) {
roomListDataSource.roomSummariesFlow.collect { value = AsyncData.Success(it) } roomListDataSource.roomSummariesFlow.collect { value = AsyncData.Success(it) }
@@ -254,6 +262,7 @@ class RoomListPresenter(
RoomListContentState.Rooms( RoomListContentState.Rooms(
securityBannerState = securityBannerState, securityBannerState = securityBannerState,
showNewNotificationSoundBanner = showNewNotificationSoundBanner, showNewNotificationSoundBanner = showNewNotificationSoundBanner,
showUnreadCount = showUnreadCount,
fullScreenIntentPermissionsState = fullScreenIntentPermissionsPresenter.present(), fullScreenIntentPermissionsState = fullScreenIntentPermissionsPresenter.present(),
batteryOptimizationState = batteryOptimizationPresenter.present(), batteryOptimizationState = batteryOptimizationPresenter.present(),
summaries = roomSummaries.dataOrNull().orEmpty().toImmutableList(), summaries = roomSummaries.dataOrNull().orEmpty().toImmutableList(),
@@ -72,6 +72,7 @@ sealed interface RoomListContentState {
val fullScreenIntentPermissionsState: FullScreenIntentPermissionsState, val fullScreenIntentPermissionsState: FullScreenIntentPermissionsState,
val batteryOptimizationState: BatteryOptimizationState, val batteryOptimizationState: BatteryOptimizationState,
val showNewNotificationSoundBanner: Boolean, val showNewNotificationSoundBanner: Boolean,
val showUnreadCount: Boolean,
val summaries: ImmutableList<RoomListRoomSummary>, val summaries: ImmutableList<RoomListRoomSummary>,
val seenRoomInvites: ImmutableSet<RoomId>, val seenRoomInvites: ImmutableSet<RoomId>,
) : RoomListContentState ) : RoomListContentState
@@ -36,6 +36,8 @@ import io.element.android.libraries.dateformatter.api.DateFormatter
import io.element.android.libraries.dateformatter.test.FakeDateFormatter import io.element.android.libraries.dateformatter.test.FakeDateFormatter
import io.element.android.libraries.eventformatter.api.RoomLatestEventFormatter import io.element.android.libraries.eventformatter.api.RoomLatestEventFormatter
import io.element.android.libraries.eventformatter.test.FakeRoomLatestEventFormatter import io.element.android.libraries.eventformatter.test.FakeRoomLatestEventFormatter
import io.element.android.libraries.featureflag.api.FeatureFlagService
import io.element.android.libraries.featureflag.test.FakeFeatureFlagService
import io.element.android.libraries.fullscreenintent.api.aFullScreenIntentPermissionsState import io.element.android.libraries.fullscreenintent.api.aFullScreenIntentPermissionsState
import io.element.android.libraries.matrix.api.MatrixClient import io.element.android.libraries.matrix.api.MatrixClient
import io.element.android.libraries.matrix.api.core.RoomId import io.element.android.libraries.matrix.api.core.RoomId
@@ -669,6 +671,7 @@ class RoomListPresenterTest {
appPreferencesStore: AppPreferencesStore = InMemoryAppPreferencesStore(), appPreferencesStore: AppPreferencesStore = InMemoryAppPreferencesStore(),
seenInvitesStore: SeenInvitesStore = InMemorySeenInvitesStore(), seenInvitesStore: SeenInvitesStore = InMemorySeenInvitesStore(),
announcementService: AnnouncementService = FakeAnnouncementService(), announcementService: AnnouncementService = FakeAnnouncementService(),
featureFlagService: FeatureFlagService = FakeFeatureFlagService(),
) = RoomListPresenter( ) = RoomListPresenter(
client = client, client = client,
leaveRoomPresenter = { leaveRoomState }, leaveRoomPresenter = { leaveRoomState },
@@ -697,5 +700,6 @@ class RoomListPresenterTest {
seenInvitesStore = seenInvitesStore, seenInvitesStore = seenInvitesStore,
announcementService = announcementService, announcementService = announcementService,
coldStartWatcher = FakeAnalyticsColdStartWatcher(), coldStartWatcher = FakeAnalyticsColdStartWatcher(),
featureFlagService = featureFlagService,
) )
} }
@@ -18,6 +18,7 @@ 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.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.rememberTextMeasurer import androidx.compose.ui.text.rememberTextMeasurer
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
@@ -37,13 +38,18 @@ private const val MAX_COUNT_STRING = "+$MAX_COUNT"
* @param count The number to display. If the number is greater than [MAX_COUNT], the counter will display [MAX_COUNT_STRING]. * @param count The number to display. If the number is greater than [MAX_COUNT], the counter will display [MAX_COUNT_STRING].
* If the number is less than 1, the counter will not be displayed. * If the number is less than 1, the counter will not be displayed.
* @param modifier The modifier to apply to this layout. * @param modifier The modifier to apply to this layout.
* @param containerColor The background color of the counter. When null, uses [isCritical] to pick a default.
* @param contentColor The text color inside the counter. When null, uses [textOnSolidPrimary].
* @param textStyle The style to apply to the text inside the counter. * @param textStyle The style to apply to the text inside the counter.
* @param isCritical If true, the counter will use a critical color scheme, otherwise it will use an accent color scheme. * @param isCritical If true, the counter will use a critical color scheme, otherwise it will use an accent color scheme.
* Only used when [containerColor] is null.
*/ */
@Composable @Composable
fun CounterAtom( fun CounterAtom(
count: Int, count: Int,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
containerColor: Color? = null,
contentColor: Color? = null,
textStyle: TextStyle = CounterAtomDefaults.textStyle, textStyle: TextStyle = CounterAtomDefaults.textStyle,
isCritical: Boolean = false, isCritical: Boolean = false,
) { ) {
@@ -65,7 +71,7 @@ fun CounterAtom(
.size(squareSize.toDp() + 1.dp) .size(squareSize.toDp() + 1.dp)
.clip(CircleShape) .clip(CircleShape)
.background( .background(
if (isCritical) { containerColor ?: if (isCritical) {
ElementTheme.colors.iconCriticalPrimary ElementTheme.colors.iconCriticalPrimary
} else { } else {
ElementTheme.colors.iconAccentPrimary ElementTheme.colors.iconAccentPrimary
@@ -76,7 +82,7 @@ fun CounterAtom(
modifier = Modifier.align(Alignment.Center), modifier = Modifier.align(Alignment.Center),
text = countAsText, text = countAsText,
style = textStyle, style = textStyle,
color = ElementTheme.colors.textOnSolidPrimary, color = contentColor ?: ElementTheme.colors.textOnSolidPrimary,
) )
} }
} }
@@ -10,6 +10,7 @@ package io.element.android.libraries.designsystem.atomic.atoms
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.CircleShape
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
@@ -29,19 +30,32 @@ import io.element.android.libraries.designsystem.theme.unreadIndicator
fun UnreadIndicatorAtom( fun UnreadIndicatorAtom(
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
size: Dp = 12.dp, size: Dp = 12.dp,
count: Long? = null,
color: Color = ElementTheme.colors.unreadIndicator, color: Color = ElementTheme.colors.unreadIndicator,
isVisible: Boolean = true, isVisible: Boolean = true,
contentDescription: String? = null, contentDescription: String? = null,
) { ) {
Box( when {
!isVisible -> Spacer(modifier = modifier.size(size))
count != null && count >= 1 -> CounterAtom(
count = count.toInt(),
modifier = modifier.semantics {
contentDescription?.let { this.contentDescription = it }
},
containerColor = color,
contentColor = ElementTheme.colors.bgCanvasDefault,
textStyle = ElementTheme.typography.fontBodySmMedium,
)
else -> Box(
modifier = modifier modifier = modifier
.semantics { .semantics {
contentDescription?.let { this.contentDescription = it } contentDescription?.let { this.contentDescription = it }
} }
.size(size) .size(size)
.clip(CircleShape) .clip(CircleShape)
.background(if (isVisible) color else Color.Transparent) .background(color),
) )
}
} }
@PreviewsDayNight @PreviewsDayNight
@@ -130,4 +130,11 @@ enum class FeatureFlags(
defaultValue = { false }, defaultValue = { false },
isFinished = false, isFinished = false,
), ),
UnreadIndicatorCount(
key = "feature.unread_indicator_count",
title = "Unread indicator count",
description = "Show the number of unread messages on the unread indicator in the room list.",
defaultValue = { false },
isFinished = false,
),
} }