Add MatrixClient.totalUnreadCountFlow() extension

Extract the logic to observe the total unread count of a MatrixClient, refreshed on each sync
heartbeat, to a shared extension and use it in HomePresenter and PreferencesRootPresenter.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Benoit Marty
2026-10-14 14:31:31 +02:00
parent b37f1d5b71
commit 095fda3d9e
4 changed files with 38 additions and 30 deletions
@@ -14,7 +14,6 @@ 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
@@ -33,16 +32,12 @@ import io.element.android.libraries.featureflag.api.FeatureFlags
import io.element.android.libraries.indicator.api.IndicatorService
import io.element.android.libraries.matrix.api.MatrixClient
import io.element.android.libraries.matrix.api.sync.SyncService
import io.element.android.libraries.matrix.api.totalUnreadCountFlow
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(
@@ -59,20 +54,13 @@ 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 totalUnreadCount by remember { client.totalUnreadCountFlow() }
// The initial value is only used for the first composition, remember it to avoid calling the SDK on every recomposition.
.collectAsState(initial = remember { client.totalUnreadCount().getOrDefault(0).toInt() })
val currentUserAndNeighbors by remember {
combine(
client.userProfile,
@@ -6,6 +6,8 @@
* Please see LICENSE files in the repository root for full details.
*/
@file:OptIn(ExperimentalCoroutinesApi::class)
package io.element.android.features.home.impl
import com.google.common.truth.Truth.assertThat
@@ -39,6 +41,7 @@ import io.element.android.tests.testutils.WarmUpRule
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.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.test.advanceTimeBy
import kotlinx.coroutines.test.runTest
@@ -124,7 +127,10 @@ class HomePresenterTest {
var unreadCount = 0L
val syncService = FakeSyncService()
val presenter = createHomePresenter(
client = FakeMatrixClient(totalUnreadCountResult = { Result.success(unreadCount) }),
client = FakeMatrixClient(
syncService = syncService,
totalUnreadCountResult = { Result.success(unreadCount) },
),
syncService = syncService,
)
presenter.test {
@@ -140,7 +146,10 @@ class HomePresenterTest {
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) }),
client = FakeMatrixClient(
syncService = syncService,
totalUnreadCountResult = { Result.failure(AN_EXCEPTION) },
),
syncService = syncService,
)
presenter.test {
@@ -32,6 +32,7 @@ import io.element.android.libraries.featureflag.api.FeatureFlags
import io.element.android.libraries.matrix.api.MatrixClient
import io.element.android.libraries.matrix.api.MatrixClientProvider
import io.element.android.libraries.matrix.api.core.SessionId
import io.element.android.libraries.matrix.api.totalUnreadCountFlow
import io.element.android.libraries.matrix.api.user.toMatrixUser
import io.element.android.libraries.preferences.api.store.AppPreferencesStore
import io.element.android.libraries.sessionstorage.api.SessionStore
@@ -40,17 +41,12 @@ import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.FlowPreview
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.debounce
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.onStart
import kotlinx.coroutines.launch
import kotlin.time.Duration.Companion.milliseconds
@OptIn(ExperimentalCoroutinesApi::class)
@Inject
@@ -182,14 +178,8 @@ class PreferencesRootPresenter(
)
}
@OptIn(FlowPreview::class)
private fun unreadCountFlow(sessionId: SessionId): Flow<Int> {
val client = matrixClientProvider.getOrNull(sessionId) ?: return flowOf(0)
return client.syncService.heartBeat
// Delay the heartbeat so the SDK has time to update the unread count before we read it.
.debounce(100.milliseconds)
.onStart { emit(Unit) }
.map { client.totalUnreadCount().getOrDefault(0).toInt() }
.distinctUntilChanged()
return client.totalUnreadCountFlow()
}
}
@@ -51,9 +51,15 @@ import io.element.android.libraries.matrix.api.user.UserStatus
import io.element.android.libraries.matrix.api.verification.SessionVerificationService
import kotlinx.collections.immutable.ImmutableList
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.FlowPreview
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.debounce
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.onStart
import java.util.Optional
import kotlin.time.Duration.Companion.milliseconds
/**
* Entry point for everything scoped to a single logged in session: the sub-services, room access, and account level operations.
@@ -523,3 +529,18 @@ fun MatrixClient.roomAliasFromName(name: String): RoomAlias? {
?.takeIf { MatrixPatterns.isRoomAlias(it) }
?.let { tryOrNull { RoomAlias(it) } }
}
/**
* Returns a flow of the total number of unread notifications across all rooms.
* The first value is emitted immediately, then the value is refreshed on each sync heartbeat.
* In case of error, 0 is emitted.
*/
@OptIn(FlowPreview::class)
fun MatrixClient.totalUnreadCountFlow(): Flow<Int> {
return syncService.heartBeat
// Delay the heartbeat so the SDK has time to update the unread count before we read it.
.debounce(100.milliseconds)
.onStart { emit(Unit) }
.map { totalUnreadCount().getOrDefault(0).toInt() }
.distinctUntilChanged()
}