From b37f1d5b71ce3b246ef6fdd8b1afb149eae124ab Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Wed, 14 Oct 2026 12:33:14 +0200 Subject: [PATCH] Show the unread count of the other accounts in the multi-account section In the settings screen, when the other accounts section is expanded, display a counter with the total number of unread notifications for each non-active account. The count is read from the account's MatrixClient (if in memory) and refreshed on its sync heartbeat. Part of #7769 Co-Authored-By: Claude Opus 5.5 (1M context) --- .../preferences/impl/root/OtherAccount.kt | 21 +++++++ .../impl/root/PreferencesRootPresenter.kt | 45 +++++++++++-- .../impl/root/PreferencesRootState.kt | 2 +- .../root/PreferencesRootStatePreviewParam.kt | 22 ++++++- .../impl/root/PreferencesRootView.kt | 14 +++-- .../impl/root/PreferencesRootPresenterTest.kt | 63 +++++++++++++++++-- .../impl/root/PreferencesRootViewTest.kt | 54 ++++++++++++---- 7 files changed, 190 insertions(+), 31 deletions(-) create mode 100644 features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/root/OtherAccount.kt diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/root/OtherAccount.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/root/OtherAccount.kt new file mode 100644 index 0000000000..7ac47056c2 --- /dev/null +++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/root/OtherAccount.kt @@ -0,0 +1,21 @@ +/* + * 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.preferences.impl.root + +import io.element.android.libraries.matrix.api.user.MatrixUser + +/** + * A signed-in account which is not the current one. + * + * @param user the user of the account. + * @param unreadCount the total number of unread notifications for this account. + */ +data class OtherAccount( + val user: MatrixUser, + val unreadCount: Int, +) 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 2bd55397c5..9f68066bd1 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 @@ -30,6 +30,8 @@ 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.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.user.toMatrixUser import io.element.android.libraries.preferences.api.store.AppPreferencesStore import io.element.android.libraries.sessionstorage.api.SessionStore @@ -37,9 +39,20 @@ import io.element.android.services.analytics.api.AnalyticsService 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 class PreferencesRootPresenter( private val matrixClient: MatrixClient, @@ -50,6 +63,7 @@ class PreferencesRootPresenter( private val featureFlagService: FeatureFlagService, private val locationService: LocationService, private val sessionStore: SessionStore, + private val matrixClientProvider: MatrixClientProvider, private val appPreferencesStore: AppPreferencesStore, private val userStatusPresenter: Presenter, private val preferencesAccountPresenter: Presenter, @@ -83,12 +97,20 @@ class PreferencesRootPresenter( }.collectAsState(initial = Theme.System) val otherSessions by remember { - sessionStore.sessionsFlow().map { list -> - list - .filter { it.userId != matrixClient.sessionId.value } - .map { it.toMatrixUser() } - .toImmutableList() - } + sessionStore.sessionsFlow() + .map { list -> list.filter { it.userId != matrixClient.sessionId.value } } + .flatMapLatest { sessions -> + if (sessions.isEmpty()) { + flowOf(persistentListOf()) + } else { + combine( + sessions.map { sessionData -> + val user = sessionData.toMatrixUser() + unreadCountFlow(user.userId).map { unreadCount -> OtherAccount(user, unreadCount) } + } + ) { it.toImmutableList() } + } + } }.collectAsState(initial = persistentListOf()) val themeOption by remember { @@ -159,4 +181,15 @@ class PreferencesRootPresenter( eventSink = ::handleEvent, ) } + + @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() + } } diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootState.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootState.kt index b51d83592f..bf9fb14416 100644 --- a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootState.kt +++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootState.kt @@ -28,7 +28,7 @@ data class PreferencesRootState( val version: String, val isMultiAccountEnabled: Boolean, val isOtherAccountsSectionExpanded: Boolean, - val otherSessions: ImmutableList, + val otherSessions: ImmutableList, val showAnalyticsSettings: Boolean, val showDeveloperSettings: Boolean, val showLabsItem: Boolean, diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootStatePreviewParam.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootStatePreviewParam.kt index 1bbd9e0548..3125291c56 100644 --- a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootStatePreviewParam.kt +++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootStatePreviewParam.kt @@ -29,7 +29,7 @@ open class PreferencesRootStatePreviewParam : PreviewParameterProvider + anOtherAccount(user = user, unreadCount = unreadCount) + }, + ), ) } @@ -66,7 +74,7 @@ fun aPreferencesRootState( isOtherAccountsSectionExpanded: Boolean = false, theme: ThemeOption = ThemeOption.System, availableThemeOptions: List = ThemeOption.entries, - otherSessions: List = emptyList(), + otherSessions: List = emptyList(), showAnalyticsSettings: Boolean = false, showDeveloperSettings: Boolean = false, showLabsItem: Boolean = false, @@ -91,3 +99,11 @@ fun aPreferencesRootState( snackbarMessage = snackbarMessage, eventSink = eventSink, ) + +fun anOtherAccount( + user: MatrixUser = aMatrixUser(), + unreadCount: Int = 0, +) = OtherAccount( + user = user, + unreadCount = unreadCount, +) diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootView.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootView.kt index 38c02f04e1..aa5a63eeaf 100644 --- a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootView.kt +++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootView.kt @@ -43,6 +43,7 @@ import io.element.android.features.preferences.impl.userstatus.UserStatusState import io.element.android.features.preferences.impl.userstatus.UserStatusView import io.element.android.libraries.architecture.AsyncAction import io.element.android.libraries.architecture.coverage.ExcludeFromCoverage +import io.element.android.libraries.designsystem.atomic.atoms.CounterAtom import io.element.android.libraries.designsystem.components.async.AsyncActionIndicator import io.element.android.libraries.designsystem.components.async.AsyncIndicator import io.element.android.libraries.designsystem.components.avatar.AvatarRow @@ -240,7 +241,7 @@ private fun ColumnScope.MultiAccountSection( AvatarRow( avatarDataList = state.otherSessions .take(3) - .map { it.getAvatarData(AvatarSize.OtherAccountItem) } + .map { it.user.getAvatarData(AvatarSize.OtherAccountItem) } .toImmutableList(), avatarType = AvatarType.User, lastOnTop = true, @@ -271,16 +272,21 @@ private fun ColumnScope.MultiAccountSection( thickness = 1.dp, color = ElementTheme.colors.bgSubtleSecondary, ) - state.otherSessions.forEach { matrixUser -> + state.otherSessions.forEach { otherAccount -> MatrixUserRow( modifier = Modifier .clickable { - state.eventSink(PreferencesRootEvent.SwitchToSession(matrixUser.userId)) + state.eventSink(PreferencesRootEvent.SwitchToSession(otherAccount.user.userId)) } .padding(top = 2.dp, bottom = 2.dp, end = 8.dp), - matrixUser = matrixUser, + matrixUser = otherAccount.user, avatarSize = AvatarSize.AccountItem, verticalSpaceWidth = 16.dp, + trailingContent = if (otherAccount.unreadCount > 0) { + { CounterAtom(count = otherAccount.unreadCount) } + } else { + null + }, ) } AddAccountItem(onAddAccountClick) diff --git a/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootPresenterTest.kt b/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootPresenterTest.kt index 34f60cbae3..24fab24025 100644 --- a/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootPresenterTest.kt +++ b/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootPresenterTest.kt @@ -24,13 +24,18 @@ import io.element.android.libraries.featureflag.api.FeatureFlagService import io.element.android.libraries.featureflag.api.FeatureFlags import io.element.android.libraries.featureflag.test.FakeFeature import io.element.android.libraries.featureflag.test.FakeFeatureFlagService +import io.element.android.libraries.matrix.api.MatrixClientProvider import io.element.android.libraries.matrix.api.user.MatrixUser import io.element.android.libraries.matrix.test.AN_AVATAR_URL +import io.element.android.libraries.matrix.test.AN_EXCEPTION import io.element.android.libraries.matrix.test.A_SESSION_ID import io.element.android.libraries.matrix.test.A_SESSION_ID_2 +import io.element.android.libraries.matrix.test.A_USER_ID_3 import io.element.android.libraries.matrix.test.A_USER_NAME import io.element.android.libraries.matrix.test.FakeMatrixClient +import io.element.android.libraries.matrix.test.FakeMatrixClientProvider import io.element.android.libraries.matrix.test.core.aBuildMeta +import io.element.android.libraries.matrix.test.sync.FakeSyncService import io.element.android.libraries.preferences.api.store.AppPreferencesStore import io.element.android.libraries.preferences.test.InMemoryAppPreferencesStore import io.element.android.libraries.sessionstorage.api.SessionStore @@ -256,16 +261,62 @@ class PreferencesRootPresenterTest { assertThat(state.isMultiAccountEnabled).isTrue() assertThat(state.otherSessions).hasSize(1) assertThat(state.otherSessions[0]).isEqualTo( - MatrixUser( - userId = A_SESSION_ID_2, - displayName = "Bob", - avatarUrl = "avatarUrl", - avatarThumbnail = "avatarData", + OtherAccount( + user = MatrixUser( + userId = A_SESSION_ID_2, + displayName = "Bob", + avatarUrl = "avatarUrl", + avatarThumbnail = "avatarData", + ), + unreadCount = 0, ) ) } } + @Test + fun `present - other accounts keep the session store order and get their unread count`() = runTest { + var bobUnreadCount = 3L + val bobSyncService = FakeSyncService() + val bobClient = FakeMatrixClient( + sessionId = A_SESSION_ID_2, + syncService = bobSyncService, + totalUnreadCountResult = { Result.success(bobUnreadCount) }, + ) + createPreferencesRootPresenter( + matrixClient = FakeMatrixClient(sessionId = A_SESSION_ID), + sessionStore = InMemorySessionStore( + initialList = listOf( + aSessionData(sessionId = A_USER_ID_3.value), + aSessionData(sessionId = A_SESSION_ID.value), + aSessionData(sessionId = A_SESSION_ID_2.value), + ) + ), + matrixClientProvider = FakeMatrixClientProvider { sessionId -> + when (sessionId) { + A_SESSION_ID_2 -> Result.success(bobClient) + // Client for the third account is not in memory + else -> Result.failure(AN_EXCEPTION) + } + }, + ).test { + val state = consumeItemsUntilPredicate { it.otherSessions.isNotEmpty() }.last() + assertThat(state.otherSessions.map { it.user.userId to it.unreadCount }).containsExactly( + A_USER_ID_3 to 0, + A_SESSION_ID_2 to 3, + ).inOrder() + // The unread count is refreshed on sync heartbeat + bobUnreadCount = 5 + bobSyncService.emitHeartBeat() + val updatedState = consumeItemsUntilPredicate { state -> state.otherSessions.any { it.unreadCount == 5 } }.last() + assertThat(updatedState.otherSessions.map { it.user.userId to it.unreadCount }).containsExactly( + A_USER_ID_3 to 0, + A_SESSION_ID_2 to 5, + ).inOrder() + cancelAndIgnoreRemainingEvents() + } + } + @Test fun `present - other accounts section is collapsed if the preference says so`() = runTest { createPreferencesRootPresenter( @@ -305,6 +356,7 @@ class PreferencesRootPresenterTest { sessionStore: SessionStore = InMemorySessionStore(), appPreferencesStore: AppPreferencesStore = InMemoryAppPreferencesStore(), locationService: LocationService = FakeLocationService(isServiceAvailable = true), + matrixClientProvider: MatrixClientProvider = FakeMatrixClientProvider(), ) = PreferencesRootPresenter( matrixClient = matrixClient, analyticsService = FakeAnalyticsService(), @@ -314,6 +366,7 @@ class PreferencesRootPresenterTest { featureFlagService = featureFlagService, locationService = locationService, sessionStore = sessionStore, + matrixClientProvider = matrixClientProvider, appPreferencesStore = appPreferencesStore, userStatusPresenter = { aUserStatusState() }, preferencesAccountPresenter = { aPreferencesAccountState() }, diff --git a/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootViewTest.kt b/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootViewTest.kt index 3afd6cde9d..eb1ec56f66 100644 --- a/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootViewTest.kt +++ b/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootViewTest.kt @@ -76,9 +76,11 @@ class PreferencesRootViewTest : RobolectricTest() { isMultiAccountEnabled = true, isOtherAccountsSectionExpanded = true, otherSessions = listOf( - aMatrixUser( - id = A_USER_ID_2.value, - displayName = "Bob", + anOtherAccount( + user = aMatrixUser( + id = A_USER_ID_2.value, + displayName = "Bob", + ), ) ), eventSink = eventsRecorder, @@ -88,6 +90,28 @@ class PreferencesRootViewTest : RobolectricTest() { eventsRecorder.assertSingle(PreferencesRootEvent.SwitchToSession(A_USER_ID_2)) } + @Test + fun `unread count of other session is displayed`() = runAndroidComposeUiTest { + val eventsRecorder = EventsRecorder(expectEvents = false) + setView( + aPreferencesRootState( + isMultiAccountEnabled = true, + isOtherAccountsSectionExpanded = true, + otherSessions = listOf( + anOtherAccount( + user = aMatrixUser( + id = A_USER_ID_2.value, + displayName = "Bob", + ), + unreadCount = 42, + ) + ), + eventSink = eventsRecorder, + ), + ) + onNodeWithText("42").assertExists() + } + @Test fun `clicking on Switch accounts sends a ToggleOtherAccountsExpanded`() = runAndroidComposeUiTest { val eventsRecorder = EventsRecorder() @@ -96,9 +120,11 @@ class PreferencesRootViewTest : RobolectricTest() { isMultiAccountEnabled = true, isOtherAccountsSectionExpanded = false, otherSessions = listOf( - aMatrixUser( - id = A_USER_ID_2.value, - displayName = "Bob", + anOtherAccount( + user = aMatrixUser( + id = A_USER_ID_2.value, + displayName = "Bob", + ), ) ), eventSink = eventsRecorder, @@ -116,9 +142,11 @@ class PreferencesRootViewTest : RobolectricTest() { isMultiAccountEnabled = true, isOtherAccountsSectionExpanded = false, otherSessions = listOf( - aMatrixUser( - id = A_USER_ID_2.value, - displayName = "Bob", + anOtherAccount( + user = aMatrixUser( + id = A_USER_ID_2.value, + displayName = "Bob", + ), ) ), eventSink = eventsRecorder, @@ -138,9 +166,11 @@ class PreferencesRootViewTest : RobolectricTest() { isMultiAccountEnabled = true, isOtherAccountsSectionExpanded = true, otherSessions = listOf( - aMatrixUser( - id = A_USER_ID_2.value, - displayName = "Bob", + anOtherAccount( + user = aMatrixUser( + id = A_USER_ID_2.value, + displayName = "Bob", + ), ) ), eventSink = eventsRecorder,