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,
@@ -267,6 +267,7 @@ private fun ThreadListItemRow(
UnreadIndicatorAtom(
size = 14.dp,
isVisible = hasUnreadNotifications,
count = 0,
)
}
}
@@ -580,6 +580,7 @@ private fun JumpToPositionButton(
.offset { IntOffset(x = 0, y = dotYOffset.roundToPx()) },
color = ElementTheme.colors.iconSuccessPrimary,
border = BorderStroke(2.dp, ElementTheme.colors.bgCanvasDefault),
count = 0,
)
}
}
@@ -34,4 +34,5 @@ sealed interface NotificationSettingsEvent {
data object DismissCallRingtoneDialog : NotificationSettingsEvent
data class SelectCallRingtonePreset(val sound: NotificationSound) : NotificationSettingsEvent
data object LaunchCallRingtonePicker : NotificationSettingsEvent
data object ToggleShowAllActivityInRoomList : NotificationSettingsEvent
}
@@ -28,6 +28,8 @@ import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.architecture.runUpdatingStateNoSuccess
import io.element.android.libraries.core.extensions.runCatchingExceptions
import io.element.android.libraries.di.annotations.SessionCoroutineScope
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.matrix.api.MatrixClient
import io.element.android.libraries.matrix.api.notificationsettings.NotificationSettingsService
@@ -73,6 +75,7 @@ class NotificationSettingsPresenter(
private val stringProvider: StringProvider,
@SessionCoroutineScope
private val sessionCoroutineScope: CoroutineScope,
private val featureFlagService: FeatureFlagService,
) : Presenter<NotificationSettingsState> {
// Serializes the pick → copy → persist → recreate pipeline per slot. The copier already locks
// its `<slot>.tmp` file, but the persist+recreate window outside the copier is unprotected;
@@ -196,6 +199,11 @@ class NotificationSettingsPresenter(
)
}
val showAllActivityInRoomList by produceState(false) {
featureFlagService.isFeatureEnabledFlow(FeatureFlags.ShowAllActivityInRoomList)
.collect { value = it }
}
fun handleEvent(event: NotificationSettingsEvent) {
when (event) {
is NotificationSettingsEvent.SetAtRoomNotificationsEnabled -> {
@@ -276,6 +284,9 @@ class NotificationSettingsPresenter(
NotificationSettingsEvent.DismissCallRingtoneCopyError -> {
callRingtoneCopyError = false
}
NotificationSettingsEvent.ToggleShowAllActivityInRoomList -> localCoroutineScope.launch {
featureFlagService.setFeatureEnabled(FeatureFlags.ShowAllActivityInRoomList, !showAllActivityInRoomList)
}
}
}
@@ -304,6 +315,7 @@ class NotificationSettingsPresenter(
showCallRingtoneDialog = showCallRingtoneDialog,
pendingMessageSoundPickerLaunch = pendingMessageSoundPickerLaunch,
pendingCallRingtonePickerLaunch = pendingCallRingtonePickerLaunch,
showAllActivityInRoomList = showAllActivityInRoomList,
eventSink = ::handleEvent,
)
}
@@ -42,6 +42,10 @@ data class NotificationSettingsState(
* auto-opens on screen entry.
*/
val pendingCallRingtonePickerLaunch: Int,
/**
* Whether to show all activity indicators in the room list, using unread messages instead of notifications.
*/
val showAllActivityInRoomList: Boolean,
val eventSink: (NotificationSettingsEvent) -> Unit,
) {
sealed interface MatrixSettings {
@@ -99,6 +99,7 @@ fun aValidNotificationSettingsState(
callRingtoneCopyError: Boolean = false,
showMessageSoundDialog: Boolean = false,
showCallRingtoneDialog: Boolean = false,
showAllActivityInRoomList: Boolean = false,
pendingMessageSoundPickerLaunch: Int = 0,
pendingCallRingtonePickerLaunch: Int = 0,
eventSink: (NotificationSettingsEvent) -> Unit = {},
@@ -133,6 +134,7 @@ fun aValidNotificationSettingsState(
showCallRingtoneDialog = showCallRingtoneDialog,
pendingMessageSoundPickerLaunch = pendingMessageSoundPickerLaunch,
pendingCallRingtonePickerLaunch = pendingCallRingtonePickerLaunch,
showAllActivityInRoomList = showAllActivityInRoomList,
eventSink = eventSink,
)
@@ -166,6 +168,7 @@ fun aInvalidNotificationSettingsState(
showCallRingtoneDialog = false,
pendingMessageSoundPickerLaunch = 0,
pendingCallRingtonePickerLaunch = 0,
showAllActivityInRoomList = false,
eventSink = eventSink,
)
@@ -199,6 +199,16 @@ private fun NotificationSettingsContentView(
onCheckedChange = onMentionNotificationsChange
)
}
PreferenceCategory {
PreferenceSwitch(
title = stringResource(R.string.screen_notification_settings_show_all_activity_title),
subtitle = stringResource(R.string.screen_notification_settings_show_all_activity_subtitle),
isChecked = state.showAllActivityInRoomList,
onCheckedChange = { state.eventSink(NotificationSettingsEvent.ToggleShowAllActivityInRoomList) }
)
}
SoundsPreferenceCategory(state = state)
PreferenceCategory(title = stringResource(id = R.string.screen_notification_settings_additional_settings_section_title)) {
// TODO We are removing the call notification toggle until support for call notifications has been added
@@ -93,7 +93,6 @@
<string name="screen_notification_settings_mode_mentions">"Згадки"</string>
<string name="screen_notification_settings_notification_section_title">"Сповіщати мене про"</string>
<string name="screen_notification_settings_room_mention_label">"Згадки @room"</string>
<string name="screen_notification_settings_show_all_activity">"Показати всю діяльність у списку кімнат (крапки або кількість непрочитаних повідомлень)"</string>
<string name="screen_notification_settings_sound_custom_fallback">"Власний"</string>
<string name="screen_notification_settings_sound_custom_sound_button_title">"Користувацький звук…"</string>
<string name="screen_notification_settings_sound_delete_sound_error_title">"Помилка видалення файлу"</string>
@@ -91,7 +91,6 @@
<string name="screen_notification_settings_mode_mentions">"提及"</string>
<string name="screen_notification_settings_notification_section_title">"通知我以下类型"</string>
<string name="screen_notification_settings_room_mention_label">"含有 @room 的提及"</string>
<string name="screen_notification_settings_show_all_activity">"在房间列表上显示所有活动(用于未读消息的圆点或数字)"</string>
<string name="screen_notification_settings_sound_custom_fallback">"自定义"</string>
<string name="screen_notification_settings_sound_custom_sound_button_title">"自定义提示音…"</string>
<string name="screen_notification_settings_sound_delete_sound_error_title">"删除文件时出错"</string>
@@ -92,7 +92,8 @@ If you proceed, some of your settings may change."</string>
<string name="screen_notification_settings_mode_mentions">"Mentions"</string>
<string name="screen_notification_settings_notification_section_title">"Notify me for"</string>
<string name="screen_notification_settings_room_mention_label">"Mentions of @room"</string>
<string name="screen_notification_settings_show_all_activity">"Show all activity in the room list (dots or number of unread messages)"</string>
<string name="screen_notification_settings_show_all_activity_subtitle">"Dots or number of unread messages"</string>
<string name="screen_notification_settings_show_all_activity_title">"Show all activity in the room list"</string>
<string name="screen_notification_settings_sound_custom_fallback">"Custom"</string>
<string name="screen_notification_settings_sound_custom_sound_button_title">"Custom sound…"</string>
<string name="screen_notification_settings_sound_delete_sound_error_title">"Error deleting file"</string>
@@ -10,6 +10,9 @@ package io.element.android.features.preferences.impl.notifications
import com.google.common.truth.Truth.assertThat
import io.element.android.libraries.architecture.AsyncData
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.FullScreenIntentPermissionsState
import io.element.android.libraries.fullscreenintent.api.aFullScreenIntentPermissionsState
import io.element.android.libraries.matrix.api.MatrixClient
@@ -1104,6 +1107,46 @@ class NotificationSettingsPresenterTest {
}
}
@Test
fun `present - showAllActivityInRoomList reflects the feature flag value`() = runTest {
val featureFlagService = FakeFeatureFlagService(
initialState = mapOf(FeatureFlags.ShowAllActivityInRoomList.key to true)
)
val presenter = createNotificationSettingsPresenter(featureFlagService = featureFlagService)
presenter.test {
val state = consumeItemsUntilPredicate { it.showAllActivityInRoomList }.last()
assertThat(state.showAllActivityInRoomList).isTrue()
featureFlagService.setFeatureEnabled(FeatureFlags.ShowAllActivityInRoomList, false)
val updatedState = consumeItemsUntilPredicate { !it.showAllActivityInRoomList }.last()
assertThat(updatedState.showAllActivityInRoomList).isFalse()
cancelAndIgnoreRemainingEvents()
}
}
@Test
fun `present - ToggleShowAllActivityInRoomList toggles the feature flag`() = runTest {
val featureFlagService = FakeFeatureFlagService(
initialState = mapOf(FeatureFlags.ShowAllActivityInRoomList.key to false)
)
val presenter = createNotificationSettingsPresenter(featureFlagService = featureFlagService)
presenter.test {
val initialState = awaitItem()
assertThat(initialState.showAllActivityInRoomList).isFalse()
initialState.eventSink(NotificationSettingsEvent.ToggleShowAllActivityInRoomList)
val enabledState = consumeItemsUntilPredicate { it.showAllActivityInRoomList }.last()
assertThat(enabledState.showAllActivityInRoomList).isTrue()
assertThat(featureFlagService.isFeatureEnabled(FeatureFlags.ShowAllActivityInRoomList)).isTrue()
enabledState.eventSink(NotificationSettingsEvent.ToggleShowAllActivityInRoomList)
val disabledState = consumeItemsUntilPredicate { !it.showAllActivityInRoomList }.last()
assertThat(disabledState.showAllActivityInRoomList).isFalse()
assertThat(featureFlagService.isFeatureEnabled(FeatureFlags.ShowAllActivityInRoomList)).isFalse()
cancelAndIgnoreRemainingEvents()
}
}
private fun TestScope.createNotificationSettingsPresenter(
notificationSettingsService: FakeNotificationSettingsService = FakeNotificationSettingsService(),
pushService: PushService = FakePushService(),
@@ -1116,6 +1159,7 @@ class NotificationSettingsPresenterTest {
NotificationSoundCopier.CopyResult.Success(source, source)
}),
soundDisplayNameResolver: FakeSoundDisplayNameResolver = FakeSoundDisplayNameResolver(),
featureFlagService: FeatureFlagService = FakeFeatureFlagService(),
): NotificationSettingsPresenter {
val matrixClient = FakeMatrixClient(notificationSettingsService = notificationSettingsService)
return NotificationSettingsPresenter(
@@ -1131,6 +1175,7 @@ class NotificationSettingsPresenterTest {
soundDisplayNameResolver = soundDisplayNameResolver,
stringProvider = FakeStringProvider(),
sessionCoroutineScope = backgroundScope,
featureFlagService = featureFlagService,
)
}
}
@@ -183,6 +183,35 @@ class NotificationSettingsViewTest : RobolectricTest() {
)
}
@Config(qualifiers = "h1024dp")
@Test
fun `clicking on disable show all activity emits the expected events`() {
testShowAllActivityToggle(true)
}
@Config(qualifiers = "h1024dp")
@Test
fun `clicking on enable show all activity emits the expected events`() {
testShowAllActivityToggle(false)
}
private fun testShowAllActivityToggle(initialState: Boolean) = runAndroidComposeUiTest {
val eventsRecorder = EventsRecorder<NotificationSettingsEvent>()
setNotificationSettingsView(
state = aValidNotificationSettingsState(
showAllActivityInRoomList = initialState,
eventSink = eventsRecorder
),
)
clickOn(R.string.screen_notification_settings_show_all_activity_title)
eventsRecorder.assertList(
listOf(
NotificationSettingsEvent.RefreshSystemNotificationsEnabled,
NotificationSettingsEvent.ToggleShowAllActivityInRoomList,
)
)
}
@Config(qualifiers = "h1024dp")
@Test
fun `with an error configuration, clicking on continue emits the expected events`() = runAndroidComposeUiTest {
@@ -8,39 +8,43 @@
package io.element.android.libraries.designsystem.atomic.atoms
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement.spacedBy
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.sizeIn
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.rememberTextMeasurer
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.coerceAtLeast
import androidx.compose.ui.unit.dp
import io.element.android.compound.theme.ElementTheme
import io.element.android.compound.tokens.generated.SemanticColors
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.text.toDp
import io.element.android.libraries.designsystem.theme.components.Text
private const val MAX_COUNT = 99
private const val MAX_COUNT_STRING = "+$MAX_COUNT"
private const val MAX_COUNT_STRING = "$MAX_COUNT+"
/**
* A counter atom that displays a number in a circle.
* Figma link : https://www.figma.com/design/G1xy0HDZKJf5TCRFmKb5d5/Compound-Android-Components?node-id=2805-2649&m=dev
*
* @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, just a dot will be displayed instead.
* @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 contentColor The text color inside the counter. When null, uses [SemanticColors.textOnSolidPrimary].
* @param contentPadding The padding to apply to the text inside the counter.
* @param textStyle The style to apply to the text inside the counter.
* @param dotSize The size of the dot when [count] is less than 1. It defaults to `12.0.dp`.
* @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.
*/
@@ -50,41 +54,51 @@ fun CounterAtom(
modifier: Modifier = Modifier,
containerColor: Color? = null,
contentColor: Color? = null,
contentPadding: PaddingValues = PaddingValues.Zero,
textStyle: TextStyle = CounterAtomDefaults.textStyle,
dotSize: Dp = 12.0.dp,
isCritical: Boolean = false,
) {
if (count < 1) return
val countAsText = when (count) {
in 0..MAX_COUNT -> count.toString()
in Int.MIN_VALUE..0 -> null
in 1..MAX_COUNT -> count.toString()
else -> MAX_COUNT_STRING
}
val textMeasurer = rememberTextMeasurer()
// Measure the maximum count string size
val textLayoutResult = textMeasurer.measure(
text = MAX_COUNT_STRING,
style = textStyle
)
val textSize = textLayoutResult.size
val squareSize = maxOf(textSize.width, textSize.height)
Box(
modifier = modifier
.size(squareSize.toDp() + 1.dp)
.clip(CircleShape)
.background(
containerColor ?: if (isCritical) {
ElementTheme.colors.iconCriticalPrimary
} else {
ElementTheme.colors.iconAccentPrimary
}
)
) {
Text(
modifier = Modifier.align(Alignment.Center),
val minSingleDigitSize = if (countAsText?.length == 1) {
val textMeasurer = rememberTextMeasurer()
// Measure the maximum count string size
val textLayoutResult = textMeasurer.measure(
text = countAsText,
style = textStyle,
color = contentColor ?: ElementTheme.colors.textOnSolidPrimary,
style = textStyle
)
val textSize = textLayoutResult.size
maxOf(textSize.width, textSize.height)
} else {
0
}
androidx.compose.material3.Badge(
modifier = modifier.then(
if (countAsText == null) Modifier.sizeIn(minWidth = dotSize, minHeight = dotSize) else Modifier
),
containerColor = containerColor ?: if (isCritical) ElementTheme.colors.iconCriticalPrimary else ElementTheme.colors.iconAccentPrimary,
contentColor = contentColor ?: ElementTheme.colors.textOnSolidPrimary,
content = countAsText?.let {
@Composable {
Text(
modifier = Modifier.sizeIn(
minWidth = (minSingleDigitSize.toDp() - 8.dp).coerceAtLeast(0.dp),
minHeight = minSingleDigitSize.toDp()
).padding(contentPadding),
text = countAsText,
style = textStyle,
color = contentColor ?: ElementTheme.colors.textOnSolidPrimary,
textAlign = TextAlign.Center,
)
}
}
)
}
object CounterAtomDefaults {
@@ -95,7 +109,7 @@ object CounterAtomDefaults {
@PreviewsDayNight
@Composable
internal fun CounterAtomPreview() = ElementPreview {
Column(verticalArrangement = spacedBy(2.dp)) {
Row(horizontalArrangement = spacedBy(2.dp), verticalAlignment = Alignment.CenterVertically) {
CounterAtom(count = 0)
CounterAtom(count = 4)
CounterAtom(count = 99)
@@ -9,15 +9,16 @@
package io.element.android.libraries.designsystem.atomic.atoms
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics
@@ -37,35 +38,36 @@ fun UnreadIndicatorAtom(
isVisible: Boolean = true,
contentDescription: String? = null,
border: BorderStroke? = null,
contentPadding: PaddingValues = PaddingValues.Zero,
) {
when {
!isVisible -> Spacer(modifier = modifier.size(size))
count != null && count >= 1 -> CounterAtom(
count = count.toInt(),
modifier = modifier
.semantics {
contentDescription?.let { this.contentDescription = it }
}
.then(if (border != null) Modifier.border(border, CircleShape) else Modifier),
containerColor = color,
contentColor = ElementTheme.colors.bgCanvasDefault,
textStyle = ElementTheme.typography.fontBodySmMedium,
)
else -> Box(
modifier = modifier
.semantics {
contentDescription?.let { this.contentDescription = it }
}
.size(size)
.clip(CircleShape)
.background(color)
.then(if (border != null) Modifier.border(border, CircleShape) else Modifier),
)
else -> count?.let {
CounterAtom(
count = count.toInt(),
modifier = modifier
.semantics {
contentDescription?.let { this.contentDescription = it }
}
.then(if (border != null) Modifier.border(border, RoundedCornerShape(percent = 50)) else Modifier),
containerColor = color,
contentColor = ElementTheme.colors.bgCanvasDefault,
textStyle = ElementTheme.typography.fontBodySmMedium,
contentPadding = contentPadding,
)
}
}
}
@PreviewsDayNight
@Composable
internal fun UnreadIndicatorAtomPreview() = ElementPreview {
UnreadIndicatorAtom()
Row(horizontalArrangement = Arrangement.spacedBy(2.dp), verticalAlignment = Alignment.CenterVertically) {
UnreadIndicatorAtom(count = null)
UnreadIndicatorAtom(count = 0)
UnreadIndicatorAtom(count = 1)
UnreadIndicatorAtom(count = 10)
UnreadIndicatorAtom(count = 99)
UnreadIndicatorAtom(count = 999)
}
}
@@ -8,16 +8,17 @@
package io.element.android.libraries.designsystem.theme.components
import androidx.compose.animation.core.FiniteAnimationSpec
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.RowScope
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.widthIn
import androidx.compose.material3.BadgedBox
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.FilledIconButton
import androidx.compose.material3.FloatingToolbarColors
import androidx.compose.material3.FloatingToolbarDefaults
import androidx.compose.material3.FloatingToolbarHorizontalFabPosition
@@ -27,7 +28,6 @@ import androidx.compose.material3.TooltipAnchorPosition
import androidx.compose.material3.TooltipDefaults
import androidx.compose.material3.rememberTooltipState
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.Shape
@@ -137,8 +137,20 @@ fun HorizontalFloatingToolbarItem(
contentColor = ElementTheme.colors.iconSecondary,
)
}
Box {
FilledIconButton(
BadgedBox(
badge = {
counter?.let {
CounterAtom(
count = it,
// Tweak the offset so it's centered on the icon
modifier = Modifier.offset(x = (-16).dp, y = 10.dp),
contentPadding = PaddingValues(vertical = 0.5.dp),
textStyle = ElementTheme.typography.fontBodySmMedium,
)
}
}
) {
IconButton(
modifier = Modifier.widthIn(min = 56.dp),
colors = colors,
onClick = onClick,
@@ -149,15 +161,6 @@ fun HorizontalFloatingToolbarItem(
contentDescription = tooltipLabel,
)
}
if (counter != null) {
CounterAtom(
modifier = Modifier
.align(Alignment.TopEnd)
.padding(top = 6.dp, end = 3.dp),
count = counter,
textStyle = ElementTheme.typography.fontBodyXsMedium,
)
}
}
}
}
@@ -170,18 +173,47 @@ fun HorizontalFloatingToolbarSeparator(modifier: Modifier = Modifier) {
@PreviewsDayNight
@Composable
internal fun HorizontalFloatingToolbarPreview() = ElementPreview {
ContentToPreview(
floatingActionButton = {
FloatingActionButton(
onClick = {},
) {
Icon(
imageVector = CompoundIcons.Plus(),
contentDescription = null,
)
Column {
ContentToPreview(
count = 1,
floatingActionButton = {
FloatingActionButton(
onClick = {},
) {
Icon(
imageVector = CompoundIcons.Plus(),
contentDescription = null,
)
}
}
}
)
)
ContentToPreview(
count = 99,
floatingActionButton = {
FloatingActionButton(
onClick = {},
) {
Icon(
imageVector = CompoundIcons.Plus(),
contentDescription = null,
)
}
}
)
ContentToPreview(
count = 999,
floatingActionButton = {
FloatingActionButton(
onClick = {},
) {
Icon(
imageVector = CompoundIcons.Plus(),
contentDescription = null,
)
}
}
)
}
}
@PreviewsDayNight
@@ -194,10 +226,11 @@ internal fun HorizontalFloatingToolbarNoFabPreview() = ElementPreview {
@Composable
private fun ContentToPreview(
count: Int? = 6,
floatingActionButton: (@Composable () -> Unit)?,
) {
HorizontalFloatingToolbar(
modifier = Modifier.padding(28.dp),
modifier = Modifier.padding(horizontal = 28.dp, vertical = 16.dp),
floatingActionButton = floatingActionButton,
) {
listOf(
@@ -211,7 +244,7 @@ private fun ContentToPreview(
icon = icon,
tooltipLabel = "Label",
isSelected = index == 0,
counter = if (index == 0) 6 else null,
counter = if (index == 0) count else null,
forceRenderingTooltip = true,
onClick = { },
)
@@ -8,12 +8,10 @@
package io.element.android.libraries.designsystem.theme.components
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.offset
import androidx.compose.material3.BadgedBox
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.unit.dp
import io.element.android.compound.theme.ElementTheme
import io.element.android.libraries.designsystem.atomic.atoms.CounterAtom
@@ -24,16 +22,19 @@ fun NavigationBarIcon(
count: Int = 0,
isCritical: Boolean = false,
) {
Box(modifier) {
BadgedBox(
modifier = modifier,
badge = {
CounterAtom(
textStyle = ElementTheme.typography.fontBodyXsMedium,
count = count,
isCritical = isCritical,
)
}
) {
Icon(
imageVector = imageVector,
contentDescription = null,
)
CounterAtom(
modifier = Modifier.offset(11.dp, (-11).dp),
textStyle = ElementTheme.typography.fontBodyXsMedium,
count = count,
isCritical = isCritical,
)
}
}
@@ -169,4 +169,13 @@ enum class FeatureFlags(
defaultValue = { false },
isFinished = false,
),
ShowAllActivityInRoomList(
key = "feature.show_all_activity_in_room_list",
title = "Show all activity in room list",
description = "Show all activity indicators in the room list, using unread messages instead of notifications.",
defaultValue = { true },
// To hide it from the developer options, since it's displayed in the notification settings instead
isFinished = true,
isInLabs = false,
)
}
@@ -169,7 +169,8 @@ private fun NameAndIndicatorRow(
)
if (showIndicator) {
UnreadIndicatorAtom(
color = ElementTheme.colors.unreadIndicator
color = ElementTheme.colors.unreadIndicator,
count = 0,
)
}
}

Some files were not shown because too many files have changed in this diff Show More