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.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<UserStatusState>,
private val preferencesAccountPresenter: Presenter<PreferencesAccountState>,
@@ -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<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 isMultiAccountEnabled: Boolean,
val isOtherAccountsSectionExpanded: Boolean,
val otherSessions: ImmutableList<MatrixUser>,
val otherSessions: ImmutableList<OtherAccount>,
val showAnalyticsSettings: Boolean,
val showDeveloperSettings: Boolean,
val showLabsItem: Boolean,
@@ -29,7 +29,7 @@ open class PreferencesRootStatePreviewParam : PreviewParameterProvider<Preferenc
myUser = aMatrixUser(avatarUrl = "anAvatarUrl"),
version = "Version 1.1 (1)",
isMultiAccountEnabled = true,
otherSessions = aMatrixUserList().drop(1).take(1),
otherSessions = aMatrixUserList().drop(1).take(1).map { anOtherAccount(it) },
showAnalyticsSettings = true,
showLabsItem = true,
),
@@ -40,7 +40,7 @@ open class PreferencesRootStatePreviewParam : PreviewParameterProvider<Preferenc
aPreferencesRootState(
isMultiAccountEnabled = true,
isOtherAccountsSectionExpanded = true,
otherSessions = aMatrixUserList().drop(1).take(3),
otherSessions = aMatrixUserList().drop(1).take(3).map { anOtherAccount(it) },
),
aPreferencesRootState(
showLabsItem = true,
@@ -55,6 +55,14 @@ open class PreferencesRootStatePreviewParam : PreviewParameterProvider<Preferenc
aPreferencesRootState(userStatusState = null),
// Location sharing not available
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,
theme: ThemeOption = ThemeOption.System,
availableThemeOptions: List<ThemeOption> = ThemeOption.entries,
otherSessions: List<MatrixUser> = emptyList(),
otherSessions: List<OtherAccount> = 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,
)
@@ -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)
@@ -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() },
@@ -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<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
fun `clicking on Switch accounts sends a ToggleOtherAccountsExpanded`() = runAndroidComposeUiTest {
val eventsRecorder = EventsRecorder<PreferencesRootEvent>()
@@ -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,