Add total unread notification count to home screen (#7891)

* Add total unread notification count to home screen

This will be calculated on each sync heartbeat (for now, using the state updates for the room list service).

* Simplify `counter` parameter logic

* Read the total unread count when the home screen is first displayed

---------

Co-authored-by: Benoit Marty <benoitm@element.io>
This commit is contained in:
Jorge Martin Espinosa
2026-10-13 12:58:07 +02:00
committed by GitHub
parent 50aa723f63
commit 9cc5cf9a01
13 changed files with 121 additions and 14 deletions
@@ -18,7 +18,7 @@ import io.element.android.libraries.designsystem.utils.snackbar.SnackbarDispatch
import io.element.android.libraries.designsystem.utils.snackbar.SnackbarMessage import io.element.android.libraries.designsystem.utils.snackbar.SnackbarMessage
import io.element.android.libraries.matrix.api.core.SessionId import io.element.android.libraries.matrix.api.core.SessionId
import io.element.android.libraries.matrix.api.room.RoomMembershipObserver import io.element.android.libraries.matrix.api.room.RoomMembershipObserver
import io.element.android.libraries.matrix.api.roomlist.RoomListService import io.element.android.libraries.matrix.api.sync.SyncService
import io.element.android.libraries.matrix.api.timeline.item.event.MembershipChange import io.element.android.libraries.matrix.api.timeline.item.event.MembershipChange
import io.element.android.libraries.push.api.notifications.NotificationCleaner import io.element.android.libraries.push.api.notifications.NotificationCleaner
import io.element.android.libraries.ui.strings.CommonStrings import io.element.android.libraries.ui.strings.CommonStrings
@@ -34,7 +34,7 @@ class LoggedInEventProcessor(
@Assisted private val snackbarDispatcher: SnackbarDispatcher, @Assisted private val snackbarDispatcher: SnackbarDispatcher,
private val roomMembershipObserver: RoomMembershipObserver, private val roomMembershipObserver: RoomMembershipObserver,
private val sessionId: SessionId, private val sessionId: SessionId,
private val roomListService: RoomListService, private val syncService: SyncService,
private val notificationCleaner: NotificationCleaner, private val notificationCleaner: NotificationCleaner,
private val dispatchers: CoroutineDispatchers private val dispatchers: CoroutineDispatchers
) { ) {
@@ -73,9 +73,8 @@ class LoggedInEventProcessor(
} }
.launchIn(childScope) .launchIn(childScope)
// Use the room list service state as a 'heartbeat' update to check existing notifications // Use the sync service heartbeat as a 'heartbeat' update to check existing notifications
roomListService.state syncService.heartBeat
.filter { it == RoomListService.State.Running }
.onEach { .onEach {
notificationCleaner.clearReadRoomsNotifications(sessionId) notificationCleaner.clearReadRoomsNotifications(sessionId)
} }
@@ -12,10 +12,9 @@ import io.element.android.libraries.designsystem.utils.snackbar.SnackbarDispatch
import io.element.android.libraries.matrix.api.core.SessionId import io.element.android.libraries.matrix.api.core.SessionId
import io.element.android.libraries.matrix.api.room.CurrentUserMembership import io.element.android.libraries.matrix.api.room.CurrentUserMembership
import io.element.android.libraries.matrix.api.room.RoomMembershipObserver import io.element.android.libraries.matrix.api.room.RoomMembershipObserver
import io.element.android.libraries.matrix.api.roomlist.RoomListService
import io.element.android.libraries.matrix.test.A_ROOM_ID import io.element.android.libraries.matrix.test.A_ROOM_ID
import io.element.android.libraries.matrix.test.A_SESSION_ID import io.element.android.libraries.matrix.test.A_SESSION_ID
import io.element.android.libraries.matrix.test.roomlist.FakeRoomListService import io.element.android.libraries.matrix.test.sync.FakeSyncService
import io.element.android.libraries.push.test.notifications.FakeNotificationCleaner import io.element.android.libraries.push.test.notifications.FakeNotificationCleaner
import io.element.android.tests.testutils.lambda.lambdaRecorder import io.element.android.tests.testutils.lambda.lambdaRecorder
import io.element.android.tests.testutils.lambda.value import io.element.android.tests.testutils.lambda.value
@@ -155,24 +154,24 @@ class LoggedInEventProcessorTest {
} }
@Test @Test
fun `test notifications are cleared when room list service state is running`() = runTest { fun `test notifications are cleared when sync heartbeat is emitted`() = runTest {
val clearReadNotificationsLambda = lambdaRecorder { _: SessionId -> } val clearReadNotificationsLambda = lambdaRecorder { _: SessionId -> }
val snackbarDispatcher = SnackbarDispatcher() val snackbarDispatcher = SnackbarDispatcher()
val notificationCleaner = FakeNotificationCleaner(clearReadRoomsNotificationsLambda = clearReadNotificationsLambda) val notificationCleaner = FakeNotificationCleaner(clearReadRoomsNotificationsLambda = clearReadNotificationsLambda)
val roomListService = FakeRoomListService() val syncService = FakeSyncService()
val loggedInEventProcessor = createLoggedInEventProcessor( val loggedInEventProcessor = createLoggedInEventProcessor(
snackbarDispatcher = snackbarDispatcher, snackbarDispatcher = snackbarDispatcher,
notificationCleaner = notificationCleaner, notificationCleaner = notificationCleaner,
roomListService = roomListService, syncService = syncService,
) )
loggedInEventProcessor.observeEvents(backgroundScope) loggedInEventProcessor.observeEvents(backgroundScope)
testScheduler.runCurrent() testScheduler.runCurrent()
// Simulate the room list service state changing to Running // Simulate a sync heartbeat
roomListService.postState(RoomListService.State.Running) syncService.emitHeartBeat()
testScheduler.runCurrent() testScheduler.runCurrent()
@@ -184,13 +183,13 @@ class LoggedInEventProcessorTest {
snackbarDispatcher: SnackbarDispatcher, snackbarDispatcher: SnackbarDispatcher,
roomMembershipObserver: RoomMembershipObserver = RoomMembershipObserver(), roomMembershipObserver: RoomMembershipObserver = RoomMembershipObserver(),
sessionId: SessionId = A_SESSION_ID, sessionId: SessionId = A_SESSION_ID,
roomListService: FakeRoomListService = FakeRoomListService(), syncService: FakeSyncService = FakeSyncService(),
notificationCleaner: FakeNotificationCleaner = FakeNotificationCleaner(), notificationCleaner: FakeNotificationCleaner = FakeNotificationCleaner(),
) = LoggedInEventProcessor( ) = LoggedInEventProcessor(
snackbarDispatcher = snackbarDispatcher, snackbarDispatcher = snackbarDispatcher,
roomMembershipObserver = roomMembershipObserver, roomMembershipObserver = roomMembershipObserver,
sessionId = sessionId, sessionId = sessionId,
roomListService = roomListService, syncService = syncService,
notificationCleaner = notificationCleaner, notificationCleaner = notificationCleaner,
dispatchers = testCoroutineDispatchers(), dispatchers = testCoroutineDispatchers(),
) )
@@ -14,6 +14,7 @@ import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.produceState
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.saveable.rememberSaveable
@@ -34,9 +35,14 @@ import io.element.android.libraries.matrix.api.MatrixClient
import io.element.android.libraries.matrix.api.sync.SyncService import io.element.android.libraries.matrix.api.sync.SyncService
import io.element.android.libraries.sessionstorage.api.SessionStore import io.element.android.libraries.sessionstorage.api.SessionStore
import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.persistentListOf
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.FlowPreview
import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.debounce
import kotlinx.coroutines.flow.filter import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.flow.mapLatest
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlin.time.Duration.Companion.milliseconds
@Inject @Inject
class HomePresenter( class HomePresenter(
@@ -53,10 +59,20 @@ class HomePresenter(
) : Presenter<HomeState> { ) : Presenter<HomeState> {
private val currentUserWithNeighborsBuilder = CurrentUserWithNeighborsBuilder() private val currentUserWithNeighborsBuilder = CurrentUserWithNeighborsBuilder()
@OptIn(FlowPreview::class, ExperimentalCoroutinesApi::class)
@Composable @Composable
override fun present(): HomeState { override fun present(): HomeState {
val coroutineState = rememberCoroutineScope() val coroutineState = rememberCoroutineScope()
val matrixUser by client.userProfile.collectAsState() val matrixUser by client.userProfile.collectAsState()
val totalUnreadCount by produceState(client.totalUnreadCount().getOrDefault(0).toInt()) {
syncService.heartBeat
// Delay the heartbeat so the SDK has time to update the unread count before we read it.
.debounce(100.milliseconds)
.mapLatest { client.totalUnreadCount().getOrDefault(0).toInt() }
.collect { value = it }
}
val currentUserAndNeighbors by remember { val currentUserAndNeighbors by remember {
combine( combine(
client.userProfile, client.userProfile,
@@ -130,6 +146,7 @@ class HomePresenter(
snackbarMessage = snackbarMessage, snackbarMessage = snackbarMessage,
canReportBug = canReportBug, canReportBug = canReportBug,
showMultiAccountAnnouncement = showMultiAccountAnnouncement, showMultiAccountAnnouncement = showMultiAccountAnnouncement,
totalUnreadCount = totalUnreadCount,
eventSink = ::handleEvent, eventSink = ::handleEvent,
) )
} }
@@ -29,6 +29,7 @@ data class HomeState(
val snackbarMessage: SnackbarMessage?, val snackbarMessage: SnackbarMessage?,
val canReportBug: Boolean, val canReportBug: Boolean,
val showMultiAccountAnnouncement: Boolean, val showMultiAccountAnnouncement: Boolean,
val totalUnreadCount: Int,
val eventSink: (HomeEvent) -> Unit, val eventSink: (HomeEvent) -> Unit,
) { ) {
val isBackHandlerEnabled = currentHomeNavigationBarItem != HomeNavigationBarItem.Chats || roomListState.spaceFiltersState is SpaceFiltersState.Selected val isBackHandlerEnabled = currentHomeNavigationBarItem != HomeNavigationBarItem.Chats || roomListState.spaceFiltersState is SpaceFiltersState.Selected
@@ -64,6 +64,7 @@ internal fun aHomeState(
homeSpacesState: HomeSpacesState = aHomeSpacesState(), homeSpacesState: HomeSpacesState = aHomeSpacesState(),
canReportBug: Boolean = true, canReportBug: Boolean = true,
showMultiAccountAnnouncement: Boolean = false, showMultiAccountAnnouncement: Boolean = false,
totalUnreadCount: Int = 0,
eventSink: (HomeEvent) -> Unit = {} eventSink: (HomeEvent) -> Unit = {}
) = HomeState( ) = HomeState(
currentUserAndNeighbors = currentUserAndNeighbors.toImmutableList(), currentUserAndNeighbors = currentUserAndNeighbors.toImmutableList(),
@@ -75,5 +76,6 @@ internal fun aHomeState(
currentHomeNavigationBarItem = currentHomeNavigationBarItem, currentHomeNavigationBarItem = currentHomeNavigationBarItem,
roomListState = roomListState, roomListState = roomListState,
homeSpacesState = homeSpacesState, homeSpacesState = homeSpacesState,
totalUnreadCount = totalUnreadCount,
eventSink = eventSink, eventSink = eventSink,
) )
@@ -259,6 +259,7 @@ private fun HomeScaffold(
} }
} }
}, },
totalUnreadCount = state.totalUnreadCount,
) )
}, },
floatingActionButtonPosition = FabPosition.Center, floatingActionButtonPosition = FabPosition.Center,
@@ -341,6 +342,7 @@ private fun HomeFloatingActionButton(
@Composable @Composable
private fun HomeBottomBar( private fun HomeBottomBar(
totalUnreadCount: Int,
currentHomeNavigationBarItem: HomeNavigationBarItem, currentHomeNavigationBarItem: HomeNavigationBarItem,
onItemClick: (HomeNavigationBarItem) -> Unit, onItemClick: (HomeNavigationBarItem) -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
@@ -361,6 +363,7 @@ private fun HomeBottomBar(
tooltipLabel = stringResource(item.labelRes), tooltipLabel = stringResource(item.labelRes),
isSelected = isSelected, isSelected = isSelected,
onClick = { onItemClick(item) }, onClick = { onItemClick(item) },
counter = totalUnreadCount.takeIf { item == HomeNavigationBarItem.Chats && it > 0 }
) )
} }
} }
@@ -40,9 +40,11 @@ import io.element.android.tests.testutils.lambda.lambdaRecorder
import io.element.android.tests.testutils.lambda.value import io.element.android.tests.testutils.lambda.value
import io.element.android.tests.testutils.test import io.element.android.tests.testutils.test
import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.test.advanceTimeBy
import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.runTest
import org.junit.Rule import org.junit.Rule
import org.junit.Test import org.junit.Test
import kotlin.time.Duration.Companion.milliseconds
class HomePresenterTest { class HomePresenterTest {
@get:Rule @get:Rule
@@ -117,6 +119,38 @@ class HomePresenterTest {
} }
} }
@Test
fun `present - total unread count is updated on sync heartbeat`() = runTest {
var unreadCount = 0L
val syncService = FakeSyncService()
val presenter = createHomePresenter(
client = FakeMatrixClient(totalUnreadCountResult = { Result.success(unreadCount) }),
syncService = syncService,
)
presenter.test {
assertThat(awaitItem().totalUnreadCount).isEqualTo(0)
unreadCount = 5
syncService.emitHeartBeat()
advanceTimeBy(200.milliseconds)
assertThat(awaitItem().totalUnreadCount).isEqualTo(5)
}
}
@Test
fun `present - total unread count defaults to 0 when the SDK call fails`() = runTest {
val syncService = FakeSyncService()
val presenter = createHomePresenter(
client = FakeMatrixClient(totalUnreadCountResult = { Result.failure(AN_EXCEPTION) }),
syncService = syncService,
)
presenter.test {
assertThat(awaitItem().totalUnreadCount).isEqualTo(0)
syncService.emitHeartBeat()
advanceTimeBy(200.milliseconds)
expectNoEvents()
}
}
@Test @Test
fun `present - should start with no user and then load user with error`() = runTest { fun `present - should start with no user and then load user with error`() = runTest {
val matrixClient = FakeMatrixClient( val matrixClient = FakeMatrixClient(
@@ -245,6 +279,16 @@ class HomePresenterTest {
.with(value(Announcement.MultiAccount)) .with(value(Announcement.MultiAccount))
} }
} }
@Test
fun `present - total unread count is loaded in the initial state`() = runTest {
val presenter = createHomePresenter(
client = FakeMatrixClient(totalUnreadCountResult = { Result.success(5) }),
)
presenter.test {
assertThat(awaitItem().totalUnreadCount).isEqualTo(5)
}
}
} }
internal fun createHomePresenter( internal fun createHomePresenter(
@@ -505,6 +505,12 @@ interface MatrixClient : ClientUrlContentFetcher {
/** Returns a provider for the capabilities the homeserver advertises, such as whether the display name can be changed. */ /** Returns a provider for the capabilities the homeserver advertises, such as whether the display name can be changed. */
fun homeserverCapabilities(): HomeserverCapabilitiesProvider fun homeserverCapabilities(): HomeserverCapabilitiesProvider
/**
* Returns the total number of unread notifications across all rooms, as currently known by the client.
* This is a snapshot and may be out of date if the sync is running.
*/
fun totalUnreadCount(): Result<Long>
} }
/** /**
@@ -8,6 +8,7 @@
package io.element.android.libraries.matrix.api.sync package io.element.android.libraries.matrix.api.sync
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
/** /**
@@ -37,4 +38,10 @@ interface SyncService {
* This is `false` only while the state is [SyncState.Offline]; an idle or errored sync still counts as online. * This is `false` only while the state is [SyncState.Offline]; an idle or errored sync still counts as online.
*/ */
val isOnline: StateFlow<Boolean> val isOnline: StateFlow<Boolean>
/**
* Flow that will emit every time the sync has received a response from the server, even if it was empty.
* Useful for triggering UI updates when the sync is running.
*/
val heartBeat: Flow<Unit>
} }
@@ -1024,6 +1024,10 @@ class RustMatrixClient(
return RustHomeserverCapabilitiesProvider(innerClient.homeserverCapabilities()) return RustHomeserverCapabilitiesProvider(innerClient.homeserverCapabilities())
} }
override fun totalUnreadCount(): Result<Long> = runCatchingExceptions {
innerClient.totalUnreadNotifications().toLong()
}
private fun scheduleBackgroundSearchBackfill() { private fun scheduleBackgroundSearchBackfill() {
featureFlagService.isFeatureEnabledFlow(FeatureFlags.MessageSearch) featureFlagService.isFeatureEnabledFlow(FeatureFlags.MessageSearch)
.distinctUntilChanged() .distinctUntilChanged()
@@ -12,16 +12,20 @@ import io.element.android.libraries.core.coroutine.mapState
import io.element.android.libraries.core.extensions.runCatchingExceptions import io.element.android.libraries.core.extensions.runCatchingExceptions
import io.element.android.libraries.matrix.api.sync.SyncService import io.element.android.libraries.matrix.api.sync.SyncService
import io.element.android.libraries.matrix.api.sync.SyncState import io.element.android.libraries.matrix.api.sync.SyncState
import io.element.android.libraries.matrix.impl.roomlist.stateFlow
import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.NonCancellable import kotlinx.coroutines.NonCancellable
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import org.matrix.rustcomponents.sdk.RoomListServiceState
import org.matrix.rustcomponents.sdk.SyncServiceState import org.matrix.rustcomponents.sdk.SyncServiceState
import timber.log.Timber import timber.log.Timber
import java.util.concurrent.atomic.AtomicBoolean import java.util.concurrent.atomic.AtomicBoolean
@@ -78,4 +82,10 @@ class RustSyncService(
.stateIn(sessionCoroutineScope, SharingStarted.Eagerly, SyncState.Idle) .stateIn(sessionCoroutineScope, SharingStarted.Eagerly, SyncState.Idle)
override val isOnline: StateFlow<Boolean> = syncState.mapState { it != SyncState.Offline } override val isOnline: StateFlow<Boolean> = syncState.mapState { it != SyncState.Offline }
// TODO actually use a heartbeat mechanism from the SDK, not simulate it with the room list service
override val heartBeat: Flow<Unit> = inner.roomListService()
.stateFlow()
.filter { it == RoomListServiceState.RUNNING }
.map {}
} }
@@ -102,6 +102,7 @@ class FakeMatrixClient(
override val mediaPreviewService: MediaPreviewService = FakeMediaPreviewService(), override val mediaPreviewService: MediaPreviewService = FakeMediaPreviewService(),
override val roomMembershipObserver: RoomMembershipObserver = RoomMembershipObserver(), override val roomMembershipObserver: RoomMembershipObserver = RoomMembershipObserver(),
private val homeserverCapabilitiesProvider: FakeHomeserverCapabilitiesProvider = FakeHomeserverCapabilitiesProvider(), private val homeserverCapabilitiesProvider: FakeHomeserverCapabilitiesProvider = FakeHomeserverCapabilitiesProvider(),
private val totalUnreadCountResult: () -> Result<Long> = { Result.success(0L) },
private val accountManagementUrlResult: (AccountManagementAction?) -> Result<String?> = { lambdaError() }, private val accountManagementUrlResult: (AccountManagementAction?) -> Result<String?> = { lambdaError() },
private val resolveRoomAliasResult: suspend (RoomAlias) -> Result<Optional<ResolvedRoomAlias>> = { private val resolveRoomAliasResult: suspend (RoomAlias) -> Result<Optional<ResolvedRoomAlias>> = {
Result.success( Result.success(
@@ -468,4 +469,8 @@ class FakeMatrixClient(
override fun homeserverCapabilities(): HomeserverCapabilitiesProvider { override fun homeserverCapabilities(): HomeserverCapabilitiesProvider {
return homeserverCapabilitiesProvider return homeserverCapabilitiesProvider
} }
override fun totalUnreadCount(): Result<Long> {
return totalUnreadCountResult()
}
} }
@@ -11,6 +11,8 @@ package io.element.android.libraries.matrix.test.sync
import io.element.android.libraries.core.coroutine.mapState import io.element.android.libraries.core.coroutine.mapState
import io.element.android.libraries.matrix.api.sync.SyncService import io.element.android.libraries.matrix.api.sync.SyncService
import io.element.android.libraries.matrix.api.sync.SyncState import io.element.android.libraries.matrix.api.sync.SyncState
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
@@ -33,6 +35,14 @@ class FakeSyncService(
override val isOnline: StateFlow<Boolean> = syncState.mapState { it != SyncState.Offline } override val isOnline: StateFlow<Boolean> = syncState.mapState { it != SyncState.Offline }
private val heartBeatFlow = MutableSharedFlow<Unit>(extraBufferCapacity = 1)
override val heartBeat: Flow<Unit> = heartBeatFlow
suspend fun emitHeartBeat() {
heartBeatFlow.emit(Unit)
}
suspend fun emitSyncState(syncState: SyncState) { suspend fun emitSyncState(syncState: SyncState) {
syncStateFlow.emit(syncState) syncStateFlow.emit(syncState)
} }