Allow displaying only unread notifications (#7803)

* Allow displaying only unread notifications

Add `FeatureFlags.ShowAllActivityInRoomList` (enabled by default, to match the current behavior) that, when disabled, will display the unread messages of a room instead of unread notifications if no unread notifications are found

* Restore UI changes to the badges, `RoomSummaryRow` and `HorizontalFloatingToolbar`

* Fix `NavigationBarIcon`: use `BadgeBox` component.

* Adjust padding in `RoomSummaryRow` to 6.dp

* Fix other indicators not displaying a dot anymore

* Update screenshots

---------

Co-authored-by: ElementBot <android@element.io>
This commit is contained in:
Jorge Martin Espinosa
2026-10-01 19:46:01 +02:00
committed by GitHub
parent a3ff7e1486
commit 2dd0a47eb4
197 changed files with 825 additions and 414 deletions
@@ -280,6 +280,7 @@ private fun RoomsViewList(
isInviteSeen = room.displayType == RoomSummaryDisplayType.INVITE &&
state.seenRoomInvites.contains(room.roomId),
showUnreadCount = state.showUnreadCount,
showAllActivity = state.showAllActivity,
onClick = onRoomClick,
eventSink = eventSink,
)
@@ -80,6 +80,7 @@ internal fun RoomSummaryRow(
onClick: (RoomListRoomSummary) -> Unit,
modifier: Modifier = Modifier,
showUnreadCount: Boolean = false,
showAllActivity: Boolean = false,
eventSink: (RoomListEvent) -> Unit,
) {
Box(modifier = modifier) {
@@ -131,7 +132,7 @@ internal fun RoomSummaryRow(
isHighlighted = room.isHighlighted,
dmUserStatus = room.dmUserStatus,
)
MessagePreviewAndIndicatorRow(room = room, showUnreadCount = showUnreadCount)
MessagePreviewAndIndicatorRow(room = room, showUnreadCount = showUnreadCount, showAllActivity = showAllActivity)
}
}
RoomSummaryDisplayType.KNOCKED -> {
@@ -278,6 +279,7 @@ private fun InviteSubtitle(
private fun MessagePreviewAndIndicatorRow(
room: RoomListRoomSummary,
showUnreadCount: Boolean,
showAllActivity: Boolean,
modifier: Modifier = Modifier,
) {
Row(
@@ -348,7 +350,7 @@ private fun MessagePreviewAndIndicatorRow(
.height(16.dp)
// Used to force this line to be read aloud earlier than the latest event when using Talkback
.zIndex(-1f),
horizontalArrangement = Arrangement.spacedBy(8.dp),
horizontalArrangement = Arrangement.spacedBy(6.dp),
verticalAlignment = Alignment.CenterVertically,
) {
val tint = if (room.isHighlighted) ElementTheme.colors.unreadIndicator else ElementTheme.colors.iconQuaternary
@@ -358,25 +360,17 @@ private fun MessagePreviewAndIndicatorRow(
isAudio = room.activeCallIntent == CallIntent.AUDIO
)
}
if (room.userDefinedNotificationMode == RoomNotificationMode.MUTE) {
val isMuted = room.userDefinedNotificationMode == RoomNotificationMode.MUTE
if (isMuted) {
NotificationOffIndicatorAtom()
} else if (room.numberOfUnreadMentions > 0) {
MentionIndicatorAtom()
}
if (room.hasNewContent) {
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(
color = tint,
count = count,
count = room.unreadIndicatorCount(showUnreadCount = showUnreadCount, showAllActivity = showAllActivity),
contentDescription = contentDescription,
)
}
@@ -384,6 +378,30 @@ private fun MessagePreviewAndIndicatorRow(
}
}
/**
* Computes the value to pass to [UnreadIndicatorAtom] as `count`.
* - `null`: nothing is displayed.
* - `0`: dot indicator without a count.
* - `> 0`: the number to display.
*/
internal fun RoomListRoomSummary.unreadIndicatorCount(
showUnreadCount: Boolean,
showAllActivity: Boolean,
): Long? {
val isMuted = userDefinedNotificationMode == RoomNotificationMode.MUTE
return when {
// Show only unread notifications when the room is not muted and there are unread notifications
!isMuted && numberOfUnreadNotifications > 0 -> numberOfUnreadNotifications
// Otherwise, show unread messages when showAllActivity is true
showAllActivity -> numberOfUnreadMessages.takeIf { it > 0 }
// Or don't display anything
else -> null
}?.let {
// We only want to show the count if the option is enabled, otherwise we just show the dot indicator (value 0)
if (showUnreadCount) it else 0
}
}
@Composable
private fun InviteNameAndIndicatorRow(
name: String?,
@@ -408,7 +426,8 @@ private fun InviteNameAndIndicatorRow(
)
if (!isInviteSeen) {
UnreadIndicatorAtom(
color = ElementTheme.colors.unreadIndicator
color = ElementTheme.colors.unreadIndicator,
count = 0,
)
}
}
@@ -457,5 +476,7 @@ internal fun RoomSummaryRowPreview(@PreviewParameter(RoomListRoomSummaryPreviewP
isInviteSeen = data.name == "Bob",
onClick = {},
eventSink = {},
showUnreadCount = data.numberOfUnreadNotifications > 0,
showAllActivity = true,
)
}
@@ -82,6 +82,24 @@ open class RoomListRoomSummaryPreviewParam : PreviewParameterProvider<RoomListRo
numberOfUnreadMentions = 1,
hasRoomCall = hasCall,
),
aRoomListRoomSummary(
name = roomNotificationMode.name,
latestEvent = LatestEvent.Synced("New notifications" + if (hasCall) ", call" else ""),
notificationMode = roomNotificationMode,
numberOfUnreadMessages = 0,
numberOfUnreadMentions = 0,
numberOfUnreadNotifications = 1,
hasRoomCall = hasCall,
),
aRoomListRoomSummary(
name = roomNotificationMode.name,
latestEvent = LatestEvent.Synced("New notifications" + if (hasCall) ", call" else ""),
notificationMode = roomNotificationMode,
numberOfUnreadMessages = 0,
numberOfUnreadMentions = 0,
numberOfUnreadNotifications = 100,
hasRoomCall = hasCall,
),
)
}.flatten()
}.flatten(),
@@ -40,6 +40,7 @@ internal fun aRoomsContentState(
summaries: ImmutableList<RoomListRoomSummary> = aRoomListRoomSummaryList(),
fullScreenIntentPermissionsState: FullScreenIntentPermissionsState = aFullScreenIntentPermissionsState(),
batteryOptimizationState: BatteryOptimizationState = aBatteryOptimizationState(),
showAllActivity: Boolean = false,
seenRoomInvites: Set<RoomId> = emptySet(),
) = RoomListContentState.Rooms(
securityBannerState = securityBannerState,
@@ -48,6 +49,7 @@ internal fun aRoomsContentState(
fullScreenIntentPermissionsState = fullScreenIntentPermissionsState,
batteryOptimizationState = batteryOptimizationState,
summaries = summaries,
showAllActivity = showAllActivity,
seenRoomInvites = seenRoomInvites.toImmutableSet(),
)
@@ -118,6 +118,10 @@ class RoomListPresenter(
val contextMenu = remember { mutableStateOf<RoomListState.ContextMenu>(RoomListState.ContextMenu.Hidden) }
val declineInviteMenu = remember { mutableStateOf<RoomListState.DeclineInviteMenu>(RoomListState.DeclineInviteMenu.Hidden) }
val showAllActivity by produceState(initialValue = false) {
featureFlagService.isFeatureEnabledFlow(FeatureFlags.ShowAllActivityInRoomList).collect { value = it }
}
fun handleEvent(event: RoomListEvent) {
when (event) {
is RoomListEvent.UpdateVisibleRange -> coroutineScope.launch {
@@ -165,13 +169,14 @@ class RoomListPresenter(
val canReportRoom by produceState(false) { value = client.canReportRoom() }
val showUnreadCount by produceState(false) {
value = featureFlagService.isFeatureEnabled(FeatureFlags.UnreadIndicatorCount)
featureFlagService.isFeatureEnabledFlow(FeatureFlags.UnreadIndicatorCount).collect { value = it }
}
val contentState = roomListContentState(
securityBannerDismissed,
showNewNotificationSoundBanner,
showUnreadCount,
showAllActivity,
)
return RoomListState(
@@ -230,6 +235,7 @@ class RoomListPresenter(
securityBannerDismissed: Boolean,
showNewNotificationSoundBanner: Boolean,
showUnreadCount: Boolean,
showAllActivity: Boolean,
): RoomListContentState {
val roomSummaries by produceState(initialValue = AsyncData.Loading()) {
roomListDataSource.roomSummariesFlow.collect { value = AsyncData.Success(it) }
@@ -262,6 +268,7 @@ class RoomListPresenter(
fullScreenIntentPermissionsState = fullScreenIntentPermissionsPresenter.present(),
batteryOptimizationState = batteryOptimizationPresenter.present(),
summaries = roomSummaries.dataOrNull().orEmpty().toImmutableList(),
showAllActivity = showAllActivity,
seenRoomInvites = seenRoomInvites.toImmutableSet(),
)
}
@@ -76,5 +76,6 @@ sealed interface RoomListContentState {
val showUnreadCount: Boolean,
val summaries: ImmutableList<RoomListRoomSummary>,
val seenRoomInvites: ImmutableSet<RoomId>,
val showAllActivity: Boolean,
) : RoomListContentState
}
@@ -0,0 +1,78 @@
/*
* Copyright (c) 2026 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.components
import com.google.common.truth.Truth.assertThat
import io.element.android.features.home.impl.model.createRoomListRoomSummary
import io.element.android.libraries.matrix.api.room.RoomNotificationMode
import org.junit.Test
class RoomSummaryRowUnreadIndicatorCountTest {
@Test
fun `unread notifications are used when the room is not muted`() {
val room = createRoomListRoomSummary(
numberOfUnreadNotifications = 3,
numberOfUnreadMessages = 5,
)
assertThat(room.unreadIndicatorCount(showUnreadCount = true, showAllActivity = false)).isEqualTo(3L)
assertThat(room.unreadIndicatorCount(showUnreadCount = true, showAllActivity = true)).isEqualTo(3L)
}
@Test
fun `unread notifications only show a dot when showUnreadCount is false`() {
val room = createRoomListRoomSummary(
numberOfUnreadNotifications = 3,
numberOfUnreadMessages = 5,
)
assertThat(room.unreadIndicatorCount(showUnreadCount = false, showAllActivity = false)).isEqualTo(0L)
assertThat(room.unreadIndicatorCount(showUnreadCount = false, showAllActivity = true)).isEqualTo(0L)
}
@Test
fun `unread messages are used when there are no unread notifications and showAllActivity is true`() {
val room = createRoomListRoomSummary(
numberOfUnreadNotifications = 0,
numberOfUnreadMessages = 5,
)
assertThat(room.unreadIndicatorCount(showUnreadCount = true, showAllActivity = true)).isEqualTo(5L)
assertThat(room.unreadIndicatorCount(showUnreadCount = false, showAllActivity = true)).isEqualTo(0L)
}
@Test
fun `unread messages are ignored when showAllActivity is false`() {
val room = createRoomListRoomSummary(
numberOfUnreadNotifications = 0,
numberOfUnreadMessages = 5,
)
assertThat(room.unreadIndicatorCount(showUnreadCount = true, showAllActivity = false)).isNull()
assertThat(room.unreadIndicatorCount(showUnreadCount = false, showAllActivity = false)).isNull()
}
@Test
fun `muted room ignores unread notifications and falls back to unread messages when showAllActivity is true`() {
val room = createRoomListRoomSummary(
numberOfUnreadNotifications = 3,
numberOfUnreadMessages = 5,
userDefinedNotificationMode = RoomNotificationMode.MUTE,
)
assertThat(room.unreadIndicatorCount(showUnreadCount = true, showAllActivity = true)).isEqualTo(5L)
assertThat(room.unreadIndicatorCount(showUnreadCount = false, showAllActivity = true)).isEqualTo(0L)
assertThat(room.unreadIndicatorCount(showUnreadCount = true, showAllActivity = false)).isNull()
}
@Test
fun `no count when there are no unread notifications nor unread messages`() {
val room = createRoomListRoomSummary(
numberOfUnreadNotifications = 0,
numberOfUnreadMessages = 0,
isMarkedUnread = true,
)
assertThat(room.unreadIndicatorCount(showUnreadCount = true, showAllActivity = true)).isNull()
assertThat(room.unreadIndicatorCount(showUnreadCount = false, showAllActivity = false)).isNull()
}
}
@@ -39,6 +39,7 @@ import io.element.android.libraries.dateformatter.test.FakeDateFormatter
import io.element.android.libraries.eventformatter.api.RoomLatestEventFormatter
import io.element.android.libraries.eventformatter.test.FakeRoomLatestEventFormatter
import io.element.android.libraries.featureflag.api.FeatureFlagService
import io.element.android.libraries.featureflag.api.FeatureFlags
import io.element.android.libraries.featureflag.test.FakeFeatureFlagService
import io.element.android.libraries.fullscreenintent.api.aFullScreenIntentPermissionsState
import io.element.android.libraries.matrix.api.MatrixClient
@@ -218,7 +219,7 @@ class RoomListPresenterTest {
presenter.test {
val initialState = awaitItem()
// Skip intermediate event for loading the value of the `UnreadIndicatorCount` feature flag
// Skip intermediate event for loading the value of the feature flags
skipItems(1)
val summary = createRoomListRoomSummary()
@@ -265,7 +266,7 @@ class RoomListPresenterTest {
presenter.test {
val initialState = awaitItem()
// Skip intermediate event for loading the value of the `UnreadIndicatorCount` feature flag
// Skip intermediate event for loading the value of the feature flags
skipItems(1)
val summary = createRoomListRoomSummary()
@@ -318,7 +319,7 @@ class RoomListPresenterTest {
presenter.test {
val initialState = awaitItem()
// Skip intermediate event for loading the value of the `UnreadIndicatorCount` feature flag
// Skip intermediate event for loading the value of the feature flags
skipItems(1)
eventRecorder.assertEmpty()
@@ -629,7 +630,7 @@ class RoomListPresenterTest {
)
presenter.test {
assertThat(announcementService.announcementsToShowFlow().first()).isEmpty()
skipItems(2) // Skip initial state and intermediate event for loading the value of the `UnreadIndicatorCount` feature flag
skipItems(2) // Skip initial state and intermediate event for loading the value of the feature flags
val state = awaitItem()
assertThat(state.contentAsRooms().showNewNotificationSoundBanner).isFalse()
announcementService.emitAnnouncementsToShow(listOf(Announcement.NewNotificationSound))
@@ -643,6 +644,51 @@ class RoomListPresenterTest {
}
}
@Test
fun `present - showAllActivity and showUnreadCount reflect the feature flag values`() = runTest {
val roomList = FakeDynamicRoomList(
summaries = MutableStateFlow(listOf(aRoomSummary())),
loadingState = MutableStateFlow(RoomList.LoadingState.Loaded(1)),
)
val matrixClient = FakeMatrixClient(
roomListService = FakeRoomListService(createRoomListLambda = { roomList }),
)
val featureFlagService = FakeFeatureFlagService(
initialState = mapOf(
FeatureFlags.ShowAllActivityInRoomList.key to true,
FeatureFlags.UnreadIndicatorCount.key to true,
)
)
val presenter = createRoomListPresenter(
client = matrixClient,
featureFlagService = featureFlagService,
)
presenter.test {
val enabledState = consumeItemsUntilPredicate { state ->
state.contentState is RoomListContentState.Rooms &&
state.contentAsRooms().showAllActivity &&
state.contentAsRooms().showUnreadCount
}.last()
assertThat(enabledState.contentAsRooms().showAllActivity).isTrue()
assertThat(enabledState.contentAsRooms().showUnreadCount).isTrue()
featureFlagService.setFeatureEnabled(FeatureFlags.ShowAllActivityInRoomList, false)
val allActivityDisabledState = consumeItemsUntilPredicate { state ->
state.contentState is RoomListContentState.Rooms && !state.contentAsRooms().showAllActivity
}.last()
assertThat(allActivityDisabledState.contentAsRooms().showAllActivity).isFalse()
assertThat(allActivityDisabledState.contentAsRooms().showUnreadCount).isTrue()
featureFlagService.setFeatureEnabled(FeatureFlags.UnreadIndicatorCount, false)
val unreadCountDisabledState = consumeItemsUntilPredicate { state ->
state.contentState is RoomListContentState.Rooms && !state.contentAsRooms().showUnreadCount
}.last()
assertThat(unreadCountDisabledState.contentAsRooms().showAllActivity).isFalse()
assertThat(unreadCountDisabledState.contentAsRooms().showUnreadCount).isFalse()
cancelAndIgnoreRemainingEvents()
}
}
private fun createTestMarkRoomAsRead(
client: MatrixClient,
notificationCleaner: NotificationCleaner,