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.matrix.api.core.SessionId
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.push.api.notifications.NotificationCleaner
import io.element.android.libraries.ui.strings.CommonStrings
@@ -34,7 +34,7 @@ class LoggedInEventProcessor(
@Assisted private val snackbarDispatcher: SnackbarDispatcher,
private val roomMembershipObserver: RoomMembershipObserver,
private val sessionId: SessionId,
private val roomListService: RoomListService,
private val syncService: SyncService,
private val notificationCleaner: NotificationCleaner,
private val dispatchers: CoroutineDispatchers
) {
@@ -73,9 +73,8 @@ class LoggedInEventProcessor(
}
.launchIn(childScope)
// Use the room list service state as a 'heartbeat' update to check existing notifications
roomListService.state
.filter { it == RoomListService.State.Running }
// Use the sync service heartbeat as a 'heartbeat' update to check existing notifications
syncService.heartBeat
.onEach {
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.room.CurrentUserMembership
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_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.tests.testutils.lambda.lambdaRecorder
import io.element.android.tests.testutils.lambda.value
@@ -155,24 +154,24 @@ class LoggedInEventProcessorTest {
}
@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 snackbarDispatcher = SnackbarDispatcher()
val notificationCleaner = FakeNotificationCleaner(clearReadRoomsNotificationsLambda = clearReadNotificationsLambda)
val roomListService = FakeRoomListService()
val syncService = FakeSyncService()
val loggedInEventProcessor = createLoggedInEventProcessor(
snackbarDispatcher = snackbarDispatcher,
notificationCleaner = notificationCleaner,
roomListService = roomListService,
syncService = syncService,
)
loggedInEventProcessor.observeEvents(backgroundScope)
testScheduler.runCurrent()
// Simulate the room list service state changing to Running
roomListService.postState(RoomListService.State.Running)
// Simulate a sync heartbeat
syncService.emitHeartBeat()
testScheduler.runCurrent()
@@ -184,13 +183,13 @@ class LoggedInEventProcessorTest {
snackbarDispatcher: SnackbarDispatcher,
roomMembershipObserver: RoomMembershipObserver = RoomMembershipObserver(),
sessionId: SessionId = A_SESSION_ID,
roomListService: FakeRoomListService = FakeRoomListService(),
syncService: FakeSyncService = FakeSyncService(),
notificationCleaner: FakeNotificationCleaner = FakeNotificationCleaner(),
) = LoggedInEventProcessor(
snackbarDispatcher = snackbarDispatcher,
roomMembershipObserver = roomMembershipObserver,
sessionId = sessionId,
roomListService = roomListService,
syncService = syncService,
notificationCleaner = notificationCleaner,
dispatchers = testCoroutineDispatchers(),
)
@@ -14,6 +14,7 @@ import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.produceState
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
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.sessionstorage.api.SessionStore
import kotlinx.collections.immutable.persistentListOf
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.FlowPreview
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.debounce
import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.flow.mapLatest
import kotlinx.coroutines.launch
import kotlin.time.Duration.Companion.milliseconds
@Inject
class HomePresenter(
@@ -53,10 +59,20 @@ class HomePresenter(
) : Presenter<HomeState> {
private val currentUserWithNeighborsBuilder = CurrentUserWithNeighborsBuilder()
@OptIn(FlowPreview::class, ExperimentalCoroutinesApi::class)
@Composable
override fun present(): HomeState {
val coroutineState = rememberCoroutineScope()
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 {
combine(
client.userProfile,
@@ -130,6 +146,7 @@ class HomePresenter(
snackbarMessage = snackbarMessage,
canReportBug = canReportBug,
showMultiAccountAnnouncement = showMultiAccountAnnouncement,
totalUnreadCount = totalUnreadCount,
eventSink = ::handleEvent,
)
}
@@ -29,6 +29,7 @@ data class HomeState(
val snackbarMessage: SnackbarMessage?,
val canReportBug: Boolean,
val showMultiAccountAnnouncement: Boolean,
val totalUnreadCount: Int,
val eventSink: (HomeEvent) -> Unit,
) {
val isBackHandlerEnabled = currentHomeNavigationBarItem != HomeNavigationBarItem.Chats || roomListState.spaceFiltersState is SpaceFiltersState.Selected
@@ -64,6 +64,7 @@ internal fun aHomeState(
homeSpacesState: HomeSpacesState = aHomeSpacesState(),
canReportBug: Boolean = true,
showMultiAccountAnnouncement: Boolean = false,
totalUnreadCount: Int = 0,
eventSink: (HomeEvent) -> Unit = {}
) = HomeState(
currentUserAndNeighbors = currentUserAndNeighbors.toImmutableList(),
@@ -75,5 +76,6 @@ internal fun aHomeState(
currentHomeNavigationBarItem = currentHomeNavigationBarItem,
roomListState = roomListState,
homeSpacesState = homeSpacesState,
totalUnreadCount = totalUnreadCount,
eventSink = eventSink,
)
@@ -259,6 +259,7 @@ private fun HomeScaffold(
}
}
},
totalUnreadCount = state.totalUnreadCount,
)
},
floatingActionButtonPosition = FabPosition.Center,
@@ -341,6 +342,7 @@ private fun HomeFloatingActionButton(
@Composable
private fun HomeBottomBar(
totalUnreadCount: Int,
currentHomeNavigationBarItem: HomeNavigationBarItem,
onItemClick: (HomeNavigationBarItem) -> Unit,
modifier: Modifier = Modifier,
@@ -361,6 +363,7 @@ private fun HomeBottomBar(
tooltipLabel = stringResource(item.labelRes),
isSelected = isSelected,
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.test
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.test.advanceTimeBy
import kotlinx.coroutines.test.runTest
import org.junit.Rule
import org.junit.Test
import kotlin.time.Duration.Companion.milliseconds
class HomePresenterTest {
@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
fun `present - should start with no user and then load user with error`() = runTest {
val matrixClient = FakeMatrixClient(
@@ -245,6 +279,16 @@ class HomePresenterTest {
.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(
@@ -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. */
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
import kotlinx.coroutines.flow.Flow
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.
*/
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())
}
override fun totalUnreadCount(): Result<Long> = runCatchingExceptions {
innerClient.totalUnreadNotifications().toLong()
}
private fun scheduleBackgroundSearchBackfill() {
featureFlagService.isFeatureEnabledFlow(FeatureFlags.MessageSearch)
.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.matrix.api.sync.SyncService
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.CoroutineScope
import kotlinx.coroutines.NonCancellable
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.withContext
import org.matrix.rustcomponents.sdk.RoomListServiceState
import org.matrix.rustcomponents.sdk.SyncServiceState
import timber.log.Timber
import java.util.concurrent.atomic.AtomicBoolean
@@ -78,4 +82,10 @@ class RustSyncService(
.stateIn(sessionCoroutineScope, SharingStarted.Eagerly, SyncState.Idle)
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 roomMembershipObserver: RoomMembershipObserver = RoomMembershipObserver(),
private val homeserverCapabilitiesProvider: FakeHomeserverCapabilitiesProvider = FakeHomeserverCapabilitiesProvider(),
private val totalUnreadCountResult: () -> Result<Long> = { Result.success(0L) },
private val accountManagementUrlResult: (AccountManagementAction?) -> Result<String?> = { lambdaError() },
private val resolveRoomAliasResult: suspend (RoomAlias) -> Result<Optional<ResolvedRoomAlias>> = {
Result.success(
@@ -468,4 +469,8 @@ class FakeMatrixClient(
override fun homeserverCapabilities(): 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.matrix.api.sync.SyncService
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.StateFlow
@@ -33,6 +35,14 @@ class FakeSyncService(
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) {
syncStateFlow.emit(syncState)
}