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) <noreply@anthropic.com>
This commit is contained in:
Benoit Marty
2026-10-14 12:33:14 +02:00
parent 9b3a9a0a2d
commit b37f1d5b71
7 changed files with 190 additions and 31 deletions
@@ -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,
)
@@ -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.FeatureFlagService
import io.element.android.libraries.featureflag.api.FeatureFlags import io.element.android.libraries.featureflag.api.FeatureFlags
import io.element.android.libraries.matrix.api.MatrixClient 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.matrix.api.user.toMatrixUser
import io.element.android.libraries.preferences.api.store.AppPreferencesStore import io.element.android.libraries.preferences.api.store.AppPreferencesStore
import io.element.android.libraries.sessionstorage.api.SessionStore 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.persistentListOf
import kotlinx.collections.immutable.toImmutableList import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.CoroutineScope 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.map
import kotlinx.coroutines.flow.onStart
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlin.time.Duration.Companion.milliseconds
@OptIn(ExperimentalCoroutinesApi::class)
@Inject @Inject
class PreferencesRootPresenter( class PreferencesRootPresenter(
private val matrixClient: MatrixClient, private val matrixClient: MatrixClient,
@@ -50,6 +63,7 @@ class PreferencesRootPresenter(
private val featureFlagService: FeatureFlagService, private val featureFlagService: FeatureFlagService,
private val locationService: LocationService, private val locationService: LocationService,
private val sessionStore: SessionStore, private val sessionStore: SessionStore,
private val matrixClientProvider: MatrixClientProvider,
private val appPreferencesStore: AppPreferencesStore, private val appPreferencesStore: AppPreferencesStore,
private val userStatusPresenter: Presenter<UserStatusState>, private val userStatusPresenter: Presenter<UserStatusState>,
private val preferencesAccountPresenter: Presenter<PreferencesAccountState>, private val preferencesAccountPresenter: Presenter<PreferencesAccountState>,
@@ -83,11 +97,19 @@ class PreferencesRootPresenter(
}.collectAsState(initial = Theme.System) }.collectAsState(initial = Theme.System)
val otherSessions by remember { val otherSessions by remember {
sessionStore.sessionsFlow().map { list -> sessionStore.sessionsFlow()
list .map { list -> list.filter { it.userId != matrixClient.sessionId.value } }
.filter { it.userId != matrixClient.sessionId.value } .flatMapLatest { sessions ->
.map { it.toMatrixUser() } if (sessions.isEmpty()) {
.toImmutableList() flowOf(persistentListOf())
} else {
combine(
sessions.map { sessionData ->
val user = sessionData.toMatrixUser()
unreadCountFlow(user.userId).map { unreadCount -> OtherAccount(user, unreadCount) }
}
) { it.toImmutableList() }
}
} }
}.collectAsState(initial = persistentListOf()) }.collectAsState(initial = persistentListOf())
@@ -159,4 +181,15 @@ class PreferencesRootPresenter(
eventSink = ::handleEvent, eventSink = ::handleEvent,
) )
} }
@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()
}
} }
@@ -28,7 +28,7 @@ data class PreferencesRootState(
val version: String, val version: String,
val isMultiAccountEnabled: Boolean, val isMultiAccountEnabled: Boolean,
val isOtherAccountsSectionExpanded: Boolean, val isOtherAccountsSectionExpanded: Boolean,
val otherSessions: ImmutableList<MatrixUser>, val otherSessions: ImmutableList<OtherAccount>,
val showAnalyticsSettings: Boolean, val showAnalyticsSettings: Boolean,
val showDeveloperSettings: Boolean, val showDeveloperSettings: Boolean,
val showLabsItem: Boolean, val showLabsItem: Boolean,
@@ -29,7 +29,7 @@ open class PreferencesRootStatePreviewParam : PreviewParameterProvider<Preferenc
myUser = aMatrixUser(avatarUrl = "anAvatarUrl"), myUser = aMatrixUser(avatarUrl = "anAvatarUrl"),
version = "Version 1.1 (1)", version = "Version 1.1 (1)",
isMultiAccountEnabled = true, isMultiAccountEnabled = true,
otherSessions = aMatrixUserList().drop(1).take(1), otherSessions = aMatrixUserList().drop(1).take(1).map { anOtherAccount(it) },
showAnalyticsSettings = true, showAnalyticsSettings = true,
showLabsItem = true, showLabsItem = true,
), ),
@@ -40,7 +40,7 @@ open class PreferencesRootStatePreviewParam : PreviewParameterProvider<Preferenc
aPreferencesRootState( aPreferencesRootState(
isMultiAccountEnabled = true, isMultiAccountEnabled = true,
isOtherAccountsSectionExpanded = true, isOtherAccountsSectionExpanded = true,
otherSessions = aMatrixUserList().drop(1).take(3), otherSessions = aMatrixUserList().drop(1).take(3).map { anOtherAccount(it) },
), ),
aPreferencesRootState( aPreferencesRootState(
showLabsItem = true, showLabsItem = true,
@@ -55,6 +55,14 @@ open class PreferencesRootStatePreviewParam : PreviewParameterProvider<Preferenc
aPreferencesRootState(userStatusState = null), aPreferencesRootState(userStatusState = null),
// Location sharing not available // Location sharing not available
aPreferencesRootState(showLocationSettings = false), aPreferencesRootState(showLocationSettings = false),
// Other accounts with unread notifications
aPreferencesRootState(
isMultiAccountEnabled = true,
isOtherAccountsSectionExpanded = true,
otherSessions = aMatrixUserList().drop(1).take(3).zip(listOf(3, 0, 120)) { user, unreadCount ->
anOtherAccount(user = user, unreadCount = unreadCount)
},
),
) )
} }
@@ -66,7 +74,7 @@ fun aPreferencesRootState(
isOtherAccountsSectionExpanded: Boolean = false, isOtherAccountsSectionExpanded: Boolean = false,
theme: ThemeOption = ThemeOption.System, theme: ThemeOption = ThemeOption.System,
availableThemeOptions: List<ThemeOption> = ThemeOption.entries, availableThemeOptions: List<ThemeOption> = ThemeOption.entries,
otherSessions: List<MatrixUser> = emptyList(), otherSessions: List<OtherAccount> = emptyList(),
showAnalyticsSettings: Boolean = false, showAnalyticsSettings: Boolean = false,
showDeveloperSettings: Boolean = false, showDeveloperSettings: Boolean = false,
showLabsItem: Boolean = false, showLabsItem: Boolean = false,
@@ -91,3 +99,11 @@ fun aPreferencesRootState(
snackbarMessage = snackbarMessage, snackbarMessage = snackbarMessage,
eventSink = eventSink, eventSink = eventSink,
) )
fun anOtherAccount(
user: MatrixUser = aMatrixUser(),
unreadCount: Int = 0,
) = OtherAccount(
user = user,
unreadCount = unreadCount,
)
@@ -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.features.preferences.impl.userstatus.UserStatusView
import io.element.android.libraries.architecture.AsyncAction import io.element.android.libraries.architecture.AsyncAction
import io.element.android.libraries.architecture.coverage.ExcludeFromCoverage 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.AsyncActionIndicator
import io.element.android.libraries.designsystem.components.async.AsyncIndicator import io.element.android.libraries.designsystem.components.async.AsyncIndicator
import io.element.android.libraries.designsystem.components.avatar.AvatarRow import io.element.android.libraries.designsystem.components.avatar.AvatarRow
@@ -240,7 +241,7 @@ private fun ColumnScope.MultiAccountSection(
AvatarRow( AvatarRow(
avatarDataList = state.otherSessions avatarDataList = state.otherSessions
.take(3) .take(3)
.map { it.getAvatarData(AvatarSize.OtherAccountItem) } .map { it.user.getAvatarData(AvatarSize.OtherAccountItem) }
.toImmutableList(), .toImmutableList(),
avatarType = AvatarType.User, avatarType = AvatarType.User,
lastOnTop = true, lastOnTop = true,
@@ -271,16 +272,21 @@ private fun ColumnScope.MultiAccountSection(
thickness = 1.dp, thickness = 1.dp,
color = ElementTheme.colors.bgSubtleSecondary, color = ElementTheme.colors.bgSubtleSecondary,
) )
state.otherSessions.forEach { matrixUser -> state.otherSessions.forEach { otherAccount ->
MatrixUserRow( MatrixUserRow(
modifier = Modifier modifier = Modifier
.clickable { .clickable {
state.eventSink(PreferencesRootEvent.SwitchToSession(matrixUser.userId)) state.eventSink(PreferencesRootEvent.SwitchToSession(otherAccount.user.userId))
} }
.padding(top = 2.dp, bottom = 2.dp, end = 8.dp), .padding(top = 2.dp, bottom = 2.dp, end = 8.dp),
matrixUser = matrixUser, matrixUser = otherAccount.user,
avatarSize = AvatarSize.AccountItem, avatarSize = AvatarSize.AccountItem,
verticalSpaceWidth = 16.dp, verticalSpaceWidth = 16.dp,
trailingContent = if (otherAccount.unreadCount > 0) {
{ CounterAtom(count = otherAccount.unreadCount) }
} else {
null
},
) )
} }
AddAccountItem(onAddAccountClick) AddAccountItem(onAddAccountClick)
@@ -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.api.FeatureFlags
import io.element.android.libraries.featureflag.test.FakeFeature import io.element.android.libraries.featureflag.test.FakeFeature
import io.element.android.libraries.featureflag.test.FakeFeatureFlagService 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.api.user.MatrixUser
import io.element.android.libraries.matrix.test.AN_AVATAR_URL 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
import io.element.android.libraries.matrix.test.A_SESSION_ID_2 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.A_USER_NAME
import io.element.android.libraries.matrix.test.FakeMatrixClient 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.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.api.store.AppPreferencesStore
import io.element.android.libraries.preferences.test.InMemoryAppPreferencesStore import io.element.android.libraries.preferences.test.InMemoryAppPreferencesStore
import io.element.android.libraries.sessionstorage.api.SessionStore import io.element.android.libraries.sessionstorage.api.SessionStore
@@ -256,16 +261,62 @@ class PreferencesRootPresenterTest {
assertThat(state.isMultiAccountEnabled).isTrue() assertThat(state.isMultiAccountEnabled).isTrue()
assertThat(state.otherSessions).hasSize(1) assertThat(state.otherSessions).hasSize(1)
assertThat(state.otherSessions[0]).isEqualTo( assertThat(state.otherSessions[0]).isEqualTo(
MatrixUser( OtherAccount(
user = MatrixUser(
userId = A_SESSION_ID_2, userId = A_SESSION_ID_2,
displayName = "Bob", displayName = "Bob",
avatarUrl = "avatarUrl", avatarUrl = "avatarUrl",
avatarThumbnail = "avatarData", 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 @Test
fun `present - other accounts section is collapsed if the preference says so`() = runTest { fun `present - other accounts section is collapsed if the preference says so`() = runTest {
createPreferencesRootPresenter( createPreferencesRootPresenter(
@@ -305,6 +356,7 @@ class PreferencesRootPresenterTest {
sessionStore: SessionStore = InMemorySessionStore(), sessionStore: SessionStore = InMemorySessionStore(),
appPreferencesStore: AppPreferencesStore = InMemoryAppPreferencesStore(), appPreferencesStore: AppPreferencesStore = InMemoryAppPreferencesStore(),
locationService: LocationService = FakeLocationService(isServiceAvailable = true), locationService: LocationService = FakeLocationService(isServiceAvailable = true),
matrixClientProvider: MatrixClientProvider = FakeMatrixClientProvider(),
) = PreferencesRootPresenter( ) = PreferencesRootPresenter(
matrixClient = matrixClient, matrixClient = matrixClient,
analyticsService = FakeAnalyticsService(), analyticsService = FakeAnalyticsService(),
@@ -314,6 +366,7 @@ class PreferencesRootPresenterTest {
featureFlagService = featureFlagService, featureFlagService = featureFlagService,
locationService = locationService, locationService = locationService,
sessionStore = sessionStore, sessionStore = sessionStore,
matrixClientProvider = matrixClientProvider,
appPreferencesStore = appPreferencesStore, appPreferencesStore = appPreferencesStore,
userStatusPresenter = { aUserStatusState() }, userStatusPresenter = { aUserStatusState() },
preferencesAccountPresenter = { aPreferencesAccountState() }, preferencesAccountPresenter = { aPreferencesAccountState() },
@@ -76,9 +76,11 @@ class PreferencesRootViewTest : RobolectricTest() {
isMultiAccountEnabled = true, isMultiAccountEnabled = true,
isOtherAccountsSectionExpanded = true, isOtherAccountsSectionExpanded = true,
otherSessions = listOf( otherSessions = listOf(
aMatrixUser( anOtherAccount(
user = aMatrixUser(
id = A_USER_ID_2.value, id = A_USER_ID_2.value,
displayName = "Bob", displayName = "Bob",
),
) )
), ),
eventSink = eventsRecorder, eventSink = eventsRecorder,
@@ -88,6 +90,28 @@ class PreferencesRootViewTest : RobolectricTest() {
eventsRecorder.assertSingle(PreferencesRootEvent.SwitchToSession(A_USER_ID_2)) eventsRecorder.assertSingle(PreferencesRootEvent.SwitchToSession(A_USER_ID_2))
} }
@Test
fun `unread count of other session is displayed`() = runAndroidComposeUiTest {
val eventsRecorder = EventsRecorder<PreferencesRootEvent>(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 @Test
fun `clicking on Switch accounts sends a ToggleOtherAccountsExpanded`() = runAndroidComposeUiTest { fun `clicking on Switch accounts sends a ToggleOtherAccountsExpanded`() = runAndroidComposeUiTest {
val eventsRecorder = EventsRecorder<PreferencesRootEvent>() val eventsRecorder = EventsRecorder<PreferencesRootEvent>()
@@ -96,9 +120,11 @@ class PreferencesRootViewTest : RobolectricTest() {
isMultiAccountEnabled = true, isMultiAccountEnabled = true,
isOtherAccountsSectionExpanded = false, isOtherAccountsSectionExpanded = false,
otherSessions = listOf( otherSessions = listOf(
aMatrixUser( anOtherAccount(
user = aMatrixUser(
id = A_USER_ID_2.value, id = A_USER_ID_2.value,
displayName = "Bob", displayName = "Bob",
),
) )
), ),
eventSink = eventsRecorder, eventSink = eventsRecorder,
@@ -116,9 +142,11 @@ class PreferencesRootViewTest : RobolectricTest() {
isMultiAccountEnabled = true, isMultiAccountEnabled = true,
isOtherAccountsSectionExpanded = false, isOtherAccountsSectionExpanded = false,
otherSessions = listOf( otherSessions = listOf(
aMatrixUser( anOtherAccount(
user = aMatrixUser(
id = A_USER_ID_2.value, id = A_USER_ID_2.value,
displayName = "Bob", displayName = "Bob",
),
) )
), ),
eventSink = eventsRecorder, eventSink = eventsRecorder,
@@ -138,9 +166,11 @@ class PreferencesRootViewTest : RobolectricTest() {
isMultiAccountEnabled = true, isMultiAccountEnabled = true,
isOtherAccountsSectionExpanded = true, isOtherAccountsSectionExpanded = true,
otherSessions = listOf( otherSessions = listOf(
aMatrixUser( anOtherAccount(
user = aMatrixUser(
id = A_USER_ID_2.value, id = A_USER_ID_2.value,
displayName = "Bob", displayName = "Bob",
),
) )
), ),
eventSink = eventsRecorder, eventSink = eventsRecorder,