From 095fda3d9e735c12a3259b3269cf0cc9b7cff7a7 Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Wed, 14 Oct 2026 14:31:31 +0200 Subject: [PATCH] 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) --- .../features/home/impl/HomePresenter.kt | 20 ++++-------------- .../features/home/impl/HomePresenterTest.kt | 13 ++++++++++-- .../impl/root/PreferencesRootPresenter.kt | 14 ++----------- .../libraries/matrix/api/MatrixClient.kt | 21 +++++++++++++++++++ 4 files changed, 38 insertions(+), 30 deletions(-) diff --git a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/HomePresenter.kt b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/HomePresenter.kt index 95f6c229b8..910b00f86f 100644 --- a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/HomePresenter.kt +++ b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/HomePresenter.kt @@ -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 { 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, diff --git a/features/home/impl/src/test/kotlin/io/element/android/features/home/impl/HomePresenterTest.kt b/features/home/impl/src/test/kotlin/io/element/android/features/home/impl/HomePresenterTest.kt index fa0d40017e..c9b7c04064 100644 --- a/features/home/impl/src/test/kotlin/io/element/android/features/home/impl/HomePresenterTest.kt +++ b/features/home/impl/src/test/kotlin/io/element/android/features/home/impl/HomePresenterTest.kt @@ -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 { diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootPresenter.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootPresenter.kt index 9f68066bd1..dd10a3eaaa 100644 --- a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootPresenter.kt +++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootPresenter.kt @@ -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 { 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() } } diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/MatrixClient.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/MatrixClient.kt index 51579a88d4..1260c9ec82 100644 --- a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/MatrixClient.kt +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/MatrixClient.kt @@ -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 { + 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() +}